From f256b47fee29147b2a73efcdfeb6a3da6a4a9c22 Mon Sep 17 00:00:00 2001 From: John Simons Date: Thu, 13 Aug 2026 15:35:00 +1000 Subject: [PATCH 1/2] Fix flaky ETag validation in event log acceptance tests The test could fail if new events were written to the log between the initial request and the conditional poll, resulting in a 200 OK instead of a 304 Not Modified. The validation now accounts for this by checking if the ETag has changed when a 200 OK is received. --- ...en_the_event_log_is_polled_with_an_etag.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs b/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs index aef1816f6f..2db0895997 100644 --- a/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs +++ b/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs @@ -45,21 +45,25 @@ await Define() return false; } - // Raw header: an unquoted value fails EntityTagHeaderValue parsing, and this test - // has to observe that rather than throw on it. - if (!first.Headers.TryGetValues("ETag", out var values)) + var currentEtag = ReadEtag(first); + + if (string.IsNullOrEmpty(currentEtag)) { return false; } - etag = values.FirstOrDefault(); + var current = await Poll(currentEtag); - if (string.IsNullOrEmpty(etag)) + // The endpoint keeps writing startup events, so a 200 here means either the + // validator went stale between the two requests, or the server ignored + // If-None-Match. Only the second is a failure, and the ETag tells them apart. + if (current.StatusCode == HttpStatusCode.OK && ReadEtag(current) != currentEtag) { return false; } - currentEtagStatus = (await Poll(etag)).StatusCode; + etag = currentEtag; + currentEtagStatus = current.StatusCode; var unknown = await Poll("\"not-an-etag-this-instance-ever-issued\""); unknownEtagStatus = unknown.StatusCode; @@ -87,6 +91,11 @@ await Define() } } + // Raw header: an unquoted value fails EntityTagHeaderValue parsing, and this test + // has to observe that rather than throw on it. + static string ReadEtag(HttpResponseMessage response) => + response.Headers.TryGetValues("ETag", out var values) ? values.FirstOrDefault() : null; + Task Poll(string ifNoneMatch) { var request = new HttpRequestMessage(HttpMethod.Get, "/api/eventlogitems/"); From ee2a455cec119fc9438fb08ef3d239043cd96904 Mon Sep 17 00:00:00 2001 From: John Simons Date: Fri, 14 Aug 2026 14:40:51 +1000 Subject: [PATCH 2/2] Improve reliability of ETag validation in event log acceptance tests The test could fail if new events were written to the log between the initial request and the conditional poll. The validation now polls until the log stabilizes or a 304 Not Modified is received to avoid false positives caused by background activity. --- ...en_the_event_log_is_polled_with_an_etag.cs | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs b/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs index 2db0895997..1e98c47e1d 100644 --- a/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs +++ b/src/ServiceControl.AcceptanceTests/EventLogs/When_the_event_log_is_polled_with_an_etag.cs @@ -52,12 +52,9 @@ await Define() return false; } - var current = await Poll(currentEtag); + var current = await PollUntilTheLogGoesQuiet(currentEtag); - // The endpoint keeps writing startup events, so a 200 here means either the - // validator went stale between the two requests, or the server ignored - // If-None-Match. Only the second is a failure, and the ETag tells them apart. - if (current.StatusCode == HttpStatusCode.OK && ReadEtag(current) != currentEtag) + if (current == null) { return false; } @@ -96,6 +93,35 @@ await Define() static string ReadEtag(HttpResponseMessage response) => response.Headers.TryGetValues("ETag", out var values) ? values.FirstOrDefault() : null; + // The endpoint keeps writing startup events, so a 200 carrying a *different* ETag only + // means the log moved on between the two requests, and says nothing about conditional + // GET support. Poll again with the ETag that response handed back, until either the log + // stops changing (304, or a 200 repeating the validator we sent, which is the real + // failure) or the attempts run out and the caller retries from a fresh read. + async Task PollUntilTheLogGoesQuiet(string etag) + { + for (var attempt = 0; attempt < MaxPollAttempts; attempt++) + { + var response = await Poll(etag); + + if (response.StatusCode != HttpStatusCode.OK) + { + return response; + } + + var newEtag = ReadEtag(response); + + if (string.IsNullOrEmpty(newEtag) || newEtag == etag) + { + return response; + } + + etag = newEtag; + } + + return null; + } + Task Poll(string ifNoneMatch) { var request = new HttpRequestMessage(HttpMethod.Get, "/api/eventlogitems/"); @@ -104,6 +130,8 @@ Task Poll(string ifNoneMatch) return HttpClient.SendAsync(request); } + const int MaxPollAttempts = 5; + public class StartingEndpoint : EndpointConfigurationBuilder { public StartingEndpoint() =>