feat(fp): WP-21 — resilience seams (correlation-id, idempotency, retry)
Correlation id becomes real ASP.NET Core middleware instead of a per-endpoint
read: every request gets one (client-supplied or generated), it's echoed as
an X-Correlation-Id response header, and pushed into the logging scope so
every log line for that request carries it — not just the Submit helper's,
verified against LogBrief which never threads it explicitly.
Idempotency-Key moves from per-HTTP-attempt (defeating its own purpose) to
per-logical-submit: runSubmit mints one key and threads it through a small
bridge (withIdempotencyKey/currentIdempotencyKey) since the NSwag-generated
client has no per-call header hook. Backend gains an IdempotencyStore that
short-circuits a replayed key to the first call's result instead of minting
a second reference — scoped to the Submit-helper endpoints per the WP's own
decision.
GET requests now retry transient failures (rxjs retry({count:2, delay:500}));
writes never auto-retry. Proven with a fake-HttpClient spec
(api-client.provider.spec.ts) rather than a manual network-tab check — the
WP's suggested `?scenario=error` check turned out not to exercise a real
network call at all (the interceptor throws before calling next()), so the
automated test is the actual proof.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
25
backend/src/BigRegister.Api/Data/IdempotencyStore.cs
Normal file
25
backend/src/BigRegister.Api/Data/IdempotencyStore.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory idempotency-key ledger for the submit endpoints (Program.cs's `Submit`
|
||||
/// helper): a replayed `Idempotency-Key` returns the exact result computed the first
|
||||
/// time, instead of re-running the submission and minting a new reference.
|
||||
/// ponytail: one global lock + no TTL/eviction — fine for a single-process demo;
|
||||
/// swap for a TTL'd cache before this ever serves real traffic (an unbounded
|
||||
/// dictionary keyed on client-supplied strings is a memory leak at scale).
|
||||
/// </summary>
|
||||
public static class IdempotencyStore
|
||||
{
|
||||
private static readonly Dictionary<string, IResult> _results = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static bool TryGet(string key, out IResult? result)
|
||||
{
|
||||
lock (_gate) return _results.TryGetValue(key, out result);
|
||||
}
|
||||
|
||||
public static void Set(string key, IResult result)
|
||||
{
|
||||
lock (_gate) _results[key] = result;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -20,6 +21,9 @@ builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
// So the correlation-id scope pushed by the middleware below actually shows up in
|
||||
// the console, not just in memory for a formatter that never renders it.
|
||||
builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true);
|
||||
|
||||
const string SpaCors = "spa";
|
||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
@@ -27,6 +31,21 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
|
||||
// else generated), pushed into the logging scope for every log line the request
|
||||
// produces (not just the Submit helper's) and echoed back as a response header for
|
||||
// support/debugging correlation. Runs first so nothing downstream logs without it.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: Guid.NewGuid().ToString();
|
||||
ctx.Items["CorrelationId"] = cid;
|
||||
ctx.Response.Headers["X-Correlation-Id"] = cid;
|
||||
using (app.Logger.BeginScope("CorrelationId:{CorrelationId}", cid))
|
||||
await next(ctx);
|
||||
});
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors(SpaCors);
|
||||
@@ -345,33 +364,48 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
|
||||
|
||||
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
||||
// generated reference and the caller's correlation id (the observability seam — a
|
||||
// real system ships this to structured logging / an audit store).
|
||||
// real system ships this to structured logging / an audit store). A repeated
|
||||
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
|
||||
// — so a retried submit dedupes instead of minting a second reference.
|
||||
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
|
||||
{
|
||||
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: "none";
|
||||
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
|
||||
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||
? k.ToString()
|
||||
: null;
|
||||
|
||||
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
IResult result;
|
||||
if (reject is not null)
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
||||
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
result = Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
|
||||
if (documents is not null)
|
||||
else
|
||||
{
|
||||
// Link digital documents (blocks later user delete) and record post-delivery
|
||||
// intent so a caseworker knows to expect the physical document.
|
||||
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
||||
foreach (var d in documents.Where(d => d.Channel == "post"))
|
||||
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
||||
if (documents is not null)
|
||||
{
|
||||
// Link digital documents (blocks later user delete) and record post-delivery
|
||||
// intent so a caseworker knows to expect the physical document.
|
||||
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
|
||||
foreach (var d in documents.Where(d => d.Channel == "post"))
|
||||
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
|
||||
}
|
||||
|
||||
var reference = SubmissionRules.NewReference();
|
||||
app.Logger.LogInformation(
|
||||
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
||||
kind, reference, cid, DateTimeOffset.UtcNow);
|
||||
result = Results.Ok(new ReferentieResponse(reference));
|
||||
}
|
||||
|
||||
var reference = SubmissionRules.NewReference();
|
||||
app.Logger.LogInformation(
|
||||
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
||||
kind, reference, cid, DateTimeOffset.UtcNow);
|
||||
return Results.Ok(new ReferentieResponse(reference));
|
||||
if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||
|
||||
@@ -125,6 +125,22 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Correlation_id_supplied_by_the_caller_is_echoed_back()
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/notes");
|
||||
req.Headers.Add("X-Correlation-Id", "test-cid-123");
|
||||
var res = await _client.SendAsync(req);
|
||||
Assert.Equal("test-cid-123", res.Headers.GetValues("X-Correlation-Id").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Correlation_id_is_generated_when_the_caller_omits_it()
|
||||
{
|
||||
var res = await _client.GetAsync("/api/v1/notes");
|
||||
Assert.NotEmpty(res.Headers.GetValues("X-Correlation-Id").Single());
|
||||
}
|
||||
|
||||
// --- Document upload ---
|
||||
|
||||
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
||||
|
||||
69
backend/tests/BigRegister.Tests/IdempotencyTests.cs
Normal file
69
backend/tests/BigRegister.Tests/IdempotencyTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class IdempotencyTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private static HttpRequestMessage ChangeRequestWithKey(string key)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
||||
{
|
||||
Content = JsonContent.Create(new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }),
|
||||
};
|
||||
req.Headers.Add("Idempotency-Key", key);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replaying_the_same_idempotency_key_returns_the_same_reference_not_a_new_one()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
|
||||
var first = await _client.SendAsync(ChangeRequestWithKey(key));
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
var replay = await _client.SendAsync(ChangeRequestWithKey(key));
|
||||
replay.EnsureSuccessStatusCode();
|
||||
var replayBody = await replay.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
Assert.Equal(firstBody!.Referentie, replayBody!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Different_idempotency_keys_are_independent_submissions()
|
||||
{
|
||||
var first = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
|
||||
var second = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
var secondBody = await second.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
var badRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
||||
{
|
||||
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
|
||||
};
|
||||
badRequest.Headers.Add("Idempotency-Key", key);
|
||||
|
||||
var first = await _client.SendAsync(badRequest);
|
||||
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, first.StatusCode);
|
||||
|
||||
var replayRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
||||
{
|
||||
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
|
||||
};
|
||||
replayRequest.Headers.Add("Idempotency-Key", key);
|
||||
var replay = await _client.SendAsync(replayRequest);
|
||||
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, replay.StatusCode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user