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; }
}