Skip to content

Commit 213d042

Browse files
committed
fix(worker): debounce outages behind a consecutive-failure threshold
A single failed probe opened an outage immediately, so a transient CDN 5xx or a one-off timeout - routine on GitHub Pages - created and mailed incidents several times a day. Require FailureThreshold (default 3) consecutive down probes before opening; any reachable probe, operational or degraded, resets the run. At the 60s interval this confirms a real outage within about two extra minutes. Set the threshold to 1 to restore opening on the first failed probe.
1 parent f1c9da4 commit 213d042

5 files changed

Lines changed: 125 additions & 3 deletions

File tree

config/appsettings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"IntervalSeconds": 60,
2929
"TimeoutSeconds": 5,
3030
"DegradedLatencyMs": 1000,
31+
"FailureThreshold": 3,
3132
"HistoryBars": 60,
3233
"AbuseContact": "bump@example.com",
3334
"MaintenanceWindows": [

src/Bump.Api/BumpSettings.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ public sealed class ServicesSettings
2222
/// <summary>Latency in milliseconds above which a healthy service is reported degraded.</summary>
2323
public int DegradedLatencyMs { get; set; } = 1000;
2424

25+
/// <summary>
26+
/// Consecutive down probes required before an outage is opened. Debounces
27+
/// transient single-probe failures (a one-off CDN 5xx or timeout) that would
28+
/// otherwise open and close incidents several times a day. At the default 60s
29+
/// interval, 3 confirms a real outage within about two extra minutes. Set to 1
30+
/// to open on the first failed probe.
31+
/// </summary>
32+
public int FailureThreshold { get; set; } = 3;
33+
2534
/// <summary>Number of history bars retained per service on the status page.</summary>
2635
public int HistoryBars { get; set; } = 60;
2736

src/Bump.Api/Services/ServiceClassifier.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,33 @@ public static string Classify(int? statusCode, long? latencyMs, bool networkErro
2323
return ServiceStatuses.Operational;
2424
}
2525
}
26+
27+
/// <summary>
28+
/// Decides when a run of failed probes has been confirmed long enough to open an
29+
/// outage. A single failed probe is not enough: transient blips (a CDN edge
30+
/// returning one 5xx, a one-off timeout — routine on GitHub Pages) would open and
31+
/// close incidents several times a day. Only a run of consecutive down probes that
32+
/// reaches the configured threshold counts as a real outage.
33+
/// </summary>
34+
public static class OutagePolicy
35+
{
36+
/// <summary>
37+
/// Count of trailing <c>down</c> probes at the end of the history. Any reachable
38+
/// probe — <c>operational</c> or <c>degraded</c> — breaks the run and resets it to
39+
/// zero, because a slow response is still a response.
40+
/// </summary>
41+
public static int TrailingDownStreak(IReadOnlyList<string> history)
42+
{
43+
int n = 0;
44+
for (int i = history.Count - 1; i >= 0 && history[i] == ServiceStatuses.Down; i--) n++;
45+
return n;
46+
}
47+
48+
/// <summary>
49+
/// True once the trailing down streak reaches <paramref name="failureThreshold"/>
50+
/// consecutive probes. A threshold of 1 or less preserves the old behavior of
51+
/// opening on the first down probe.
52+
/// </summary>
53+
public static bool OutageConfirmed(IReadOnlyList<string> history, int failureThreshold)
54+
=> TrailingDownStreak(history) >= Math.Max(1, failureThreshold);
55+
}

src/Bump.Worker/Services/ServiceProber.cs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public sealed class ServiceProber : BackgroundService
2525
private readonly TimeSpan _interval;
2626
private readonly TimeSpan _timeout;
2727
private readonly int _degradedLatencyMs;
28+
private readonly int _failureThreshold;
2829
private readonly int _historyBars;
2930
private readonly string _publicBaseUrl;
3031
private readonly string? _alertRecipient;
@@ -54,6 +55,7 @@ public ServiceProber(
5455
_interval = settings.Interval;
5556
_timeout = settings.Timeout;
5657
_degradedLatencyMs = settings.DegradedLatencyMs;
58+
_failureThreshold = settings.FailureThreshold;
5759
_historyBars = settings.HistoryBars;
5860
_publicBaseUrl = (config["Bump:Web:BaseUrl"] ?? "").TrimEnd('/');
5961
_alertRecipient = alerts.Contact;
@@ -151,10 +153,15 @@ private async Task ProbeOneAsync(Service service, HttpClient http, CancellationT
151153
int badCount = history.Count(s => s != ServiceStatuses.Operational);
152154
decimal uptimePct = Math.Max(95.00m, 100.00m - (decimal)badCount * 5m / _historyBars);
153155

156+
// Debounce: one failed probe does not open an outage. A transient blip
157+
// (a single CDN 5xx or a one-off timeout, routine on GitHub Pages) would
158+
// otherwise open and close incidents several times a day. Only a run of
159+
// consecutive down probes reaching FailureThreshold counts. Any reachable
160+
// probe in between resets the run.
154161
DateTimeOffset? lastOutageAt = state?.LastOutageAt;
155-
bool needsOutage = status == ServiceStatuses.Down;
156-
Outage? existingOutage = needsOutage ? await _outages.GetOpenForServiceAsync(service.ServiceId, ct) : null;
157-
bool freshOutage = needsOutage && existingOutage is null;
162+
bool outageConfirmed = OutagePolicy.OutageConfirmed(history, _failureThreshold);
163+
Outage? existingOutage = outageConfirmed ? await _outages.GetOpenForServiceAsync(service.ServiceId, ct) : null;
164+
bool freshOutage = outageConfirmed && existingOutage is null;
158165
if (freshOutage) lastOutageAt = DateTimeOffset.UtcNow;
159166

160167
await _services.UpsertStateAsync(
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
using Bump.Api.Services;
2+
using Xunit;
3+
4+
namespace Bump.Api.Tests;
5+
6+
public sealed class OutagePolicyTests
7+
{
8+
private static List<string> History(params string[] statuses) => statuses.ToList();
9+
10+
[Fact]
11+
public void TrailingDownStreak_counts_only_the_trailing_run()
12+
{
13+
var history = History(
14+
ServiceStatuses.Down, // earlier run, does not count
15+
ServiceStatuses.Operational, // breaks it
16+
ServiceStatuses.Down,
17+
ServiceStatuses.Down);
18+
Assert.Equal(2, OutagePolicy.TrailingDownStreak(history));
19+
}
20+
21+
[Fact]
22+
public void Degraded_breaks_the_run()
23+
{
24+
// A slow-but-reachable probe is not down and must reset the streak.
25+
var history = History(
26+
ServiceStatuses.Down,
27+
ServiceStatuses.Degraded,
28+
ServiceStatuses.Down);
29+
Assert.Equal(1, OutagePolicy.TrailingDownStreak(history));
30+
}
31+
32+
[Fact]
33+
public void Empty_history_has_no_streak()
34+
{
35+
Assert.Equal(0, OutagePolicy.TrailingDownStreak(new List<string>()));
36+
}
37+
38+
[Theory]
39+
[InlineData(1, 3, false)] // one failure, threshold 3 -> not yet
40+
[InlineData(2, 3, false)] // two failures, threshold 3 -> not yet
41+
[InlineData(3, 3, true)] // threshold reached
42+
[InlineData(4, 3, true)] // still open past the threshold
43+
public void OutageConfirmed_requires_threshold_consecutive_downs(int downCount, int threshold, bool expected)
44+
{
45+
var history = Enumerable.Repeat(ServiceStatuses.Down, downCount).ToList();
46+
Assert.Equal(expected, OutagePolicy.OutageConfirmed(history, threshold));
47+
}
48+
49+
[Fact]
50+
public void A_single_operational_probe_before_the_run_still_confirms()
51+
{
52+
var history = History(
53+
ServiceStatuses.Operational,
54+
ServiceStatuses.Down,
55+
ServiceStatuses.Down,
56+
ServiceStatuses.Down);
57+
Assert.True(OutagePolicy.OutageConfirmed(history, 3));
58+
}
59+
60+
[Theory]
61+
[InlineData(0)]
62+
[InlineData(-5)]
63+
public void Threshold_of_one_or_less_opens_on_the_first_down(int threshold)
64+
{
65+
var history = History(ServiceStatuses.Down);
66+
Assert.True(OutagePolicy.OutageConfirmed(history, threshold));
67+
}
68+
69+
[Fact]
70+
public void Threshold_of_one_does_not_open_on_a_reachable_probe()
71+
{
72+
Assert.False(OutagePolicy.OutageConfirmed(History(ServiceStatuses.Degraded), 1));
73+
Assert.False(OutagePolicy.OutageConfirmed(History(ServiceStatuses.Operational), 1));
74+
}
75+
}

0 commit comments

Comments
 (0)