feat(health): IActiveNodeGate seam + RequireActiveNode filter
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user