feat(health): IActiveNodeGate seam + RequireActiveNode filter

This commit is contained in:
Joseph Doherty
2026-06-01 06:43:11 -04:00
parent d1b837e718
commit 5b82d68ea9
3 changed files with 152 additions and 0 deletions
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.Health;
/// <summary>
/// Endpoint filter that gates a route to the active node. Resolves <see cref="IActiveNodeGate"/>
/// from request services; when it is registered and reports a standby
/// (<see cref="IActiveNodeGate.IsActiveNode"/> is <c>false</c>) the request is short-circuited with
/// HTTP 503 and a <c>Retry-After</c> header. When no gate is registered (non-clustered host / tests)
/// the request is served, preserving prior behaviour.
/// </summary>
public sealed class ActiveNodeGateEndpointFilter : IEndpointFilter
{
/// <summary>Default <c>Retry-After</c> value (seconds) advertised on a standby 503 response.</summary>
private const int RetryAfterSeconds = 5;
/// <summary>
/// Returns 503 (with <c>Retry-After</c>) when the resolved <see cref="IActiveNodeGate"/> reports
/// a standby node; otherwise delegates to the next filter or endpoint handler.
/// </summary>
/// <param name="context">The endpoint filter invocation context.</param>
/// <param name="next">The next filter or endpoint handler in the pipeline.</param>
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
var httpContext = context.HttpContext;
var gate = httpContext.RequestServices.GetService<IActiveNodeGate>();
if (gate is { IsActiveNode: false })
{
httpContext.Response.Headers.RetryAfter = RetryAfterSeconds.ToString();
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
}
return await next(context);
}
}
/// <summary>
/// Route convention that gates endpoint(s) to the active node, returning 503 on standby nodes.
/// </summary>
public static class ActiveNodeGateExtensions
{
/// <summary>
/// Applies <see cref="ActiveNodeGateEndpointFilter"/> to the decorated endpoint(s): the route is
/// served only when the DI-resolved <see cref="IActiveNodeGate"/> reports the node active, and
/// returns 503 with a <c>Retry-After</c> header when the node is a standby.
/// </summary>
/// <param name="builder">The endpoint convention builder to decorate.</param>
/// <returns>The same <paramref name="builder"/> for chaining.</returns>
public static IEndpointConventionBuilder RequireActiveNode(this IEndpointConventionBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
return builder.AddEndpointFilter(new ActiveNodeGateEndpointFilter());
}
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.Health;
/// <summary>
/// Single-property seam: is this node the active / leader node?
/// </summary>
/// <remarks>
/// Attach to endpoints or route groups via
/// <see cref="ActiveNodeGateExtensions.RequireActiveNode"/>. A standby node must not serve the
/// gated routes, so the filter returns HTTP 503 when <see cref="IsActiveNode"/> is <c>false</c>.
/// The implementation is supplied by the consumer — the <c>ZB.MOM.WW.Health.Akka</c> package ships
/// <c>AkkaActiveNodeGate</c> for clustered nodes; non-Akka hosts provide their own.
/// </remarks>
public interface IActiveNodeGate
{
/// <summary>
/// <c>true</c> when this node is the active node and may serve gated routes;
/// <c>false</c> on a standby node.
/// </summary>
bool IsActiveNode { get; }
}
@@ -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;
/// <summary>
/// Verifies <see cref="ActiveNodeGateExtensions.RequireActiveNode"/>: a decorated endpoint serves
/// normally (200) when the resolved <see cref="IActiveNodeGate"/> reports the node active, and
/// returns 503 with a <c>Retry-After</c> header when the node is a standby.
/// </summary>
public sealed class ActiveNodeGateTests
{
private sealed class FakeActiveNodeGate : IActiveNodeGate
{
public bool IsActiveNode { get; set; }
}
private static async Task<HttpResponseMessage> CallAsync(bool isActive)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddSingleton<IActiveNodeGate>(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);
}
}