From 5b82d68ea9b985cbc2bda4842a53f5d3436eb251 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 06:43:11 -0400 Subject: [PATCH] feat(health): IActiveNodeGate seam + RequireActiveNode filter --- .../ActiveNodeGateEndpointFilter.cs | 62 ++++++++++++++++ .../src/ZB.MOM.WW.Health/IActiveNodeGate.cs | 20 ++++++ .../ActiveNodeGateTests.cs | 70 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ActiveNodeGateEndpointFilter.cs create mode 100644 ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/IActiveNodeGate.cs create mode 100644 ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ActiveNodeGateTests.cs diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ActiveNodeGateEndpointFilter.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ActiveNodeGateEndpointFilter.cs new file mode 100644 index 0000000..0590798 --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/ActiveNodeGateEndpointFilter.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace ZB.MOM.WW.Health; + +/// +/// Endpoint filter that gates a route to the active node. Resolves +/// from request services; when it is registered and reports a standby +/// ( is false) the request is short-circuited with +/// HTTP 503 and a Retry-After header. When no gate is registered (non-clustered host / tests) +/// the request is served, preserving prior behaviour. +/// +public sealed class ActiveNodeGateEndpointFilter : IEndpointFilter +{ + /// Default Retry-After value (seconds) advertised on a standby 503 response. + private const int RetryAfterSeconds = 5; + + /// + /// Returns 503 (with Retry-After) when the resolved reports + /// a standby node; otherwise delegates to the next filter or endpoint handler. + /// + /// The endpoint filter invocation context. + /// The next filter or endpoint handler in the pipeline. + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + var httpContext = context.HttpContext; + var gate = httpContext.RequestServices.GetService(); + + if (gate is { IsActiveNode: false }) + { + httpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString(); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + return await next(context); + } +} + +/// +/// Route convention that gates endpoint(s) to the active node, returning 503 on standby nodes. +/// +public static class ActiveNodeGateExtensions +{ + /// + /// Applies to the decorated endpoint(s): the route is + /// served only when the DI-resolved reports the node active, and + /// returns 503 with a Retry-After header when the node is a standby. + /// + /// The endpoint convention builder to decorate. + /// The same for chaining. + public static IEndpointConventionBuilder RequireActiveNode(this IEndpointConventionBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.AddEndpointFilter(new ActiveNodeGateEndpointFilter()); + } +} diff --git a/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/IActiveNodeGate.cs b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/IActiveNodeGate.cs new file mode 100644 index 0000000..cd74623 --- /dev/null +++ b/ZB.MOM.WW.Health/src/ZB.MOM.WW.Health/IActiveNodeGate.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.Health; + +/// +/// Single-property seam: is this node the active / leader node? +/// +/// +/// Attach to endpoints or route groups via +/// . A standby node must not serve the +/// gated routes, so the filter returns HTTP 503 when is false. +/// The implementation is supplied by the consumer — the ZB.MOM.WW.Health.Akka package ships +/// AkkaActiveNodeGate for clustered nodes; non-Akka hosts provide their own. +/// +public interface IActiveNodeGate +{ + /// + /// true when this node is the active node and may serve gated routes; + /// false on a standby node. + /// + bool IsActiveNode { get; } +} diff --git a/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ActiveNodeGateTests.cs b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ActiveNodeGateTests.cs new file mode 100644 index 0000000..e89db46 --- /dev/null +++ b/ZB.MOM.WW.Health/tests/ZB.MOM.WW.Health.Tests/ActiveNodeGateTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.Health; + +namespace ZB.MOM.WW.Health.Tests; + +/// +/// Verifies : a decorated endpoint serves +/// normally (200) when the resolved reports the node active, and +/// returns 503 with a Retry-After header when the node is a standby. +/// +public sealed class ActiveNodeGateTests +{ + private sealed class FakeActiveNodeGate : IActiveNodeGate + { + public bool IsActiveNode { get; set; } + } + + private static async Task CallAsync(bool isActive) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(new FakeActiveNodeGate { IsActiveNode = isActive }); + + await using var app = builder.Build(); + app.MapGet("/x", () => "ok").RequireActiveNode(); + await app.StartAsync(); + + var client = app.GetTestClient(); + return await client.GetAsync("/x"); + } + + [Fact] + public async Task ActiveNode_Returns200() + { + var response = await CallAsync(isActive: true); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("ok", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StandbyNode_Returns503_WithRetryAfterHeader() + { + var response = await CallAsync(isActive: false); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.True( + response.Headers.Contains("Retry-After"), + "Standby response must carry a Retry-After header."); + } + + [Fact] + public async Task NoGateRegistered_AllowsRequest() + { + // When no IActiveNodeGate is registered (non-clustered host / tests), the endpoint is served. + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + await using var app = builder.Build(); + app.MapGet("/x", () => "ok").RequireActiveNode(); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync("/x"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +}