feat(openzaak): bounded retry + flagged write divergence (WP-60)
Local aanvraag/document writes and their paired ZGW writes aren't transactional; a ZGW failure after the local write succeeds used to diverge silently. ZgwHttpClient now retries transport-shaped failures (not 500, which can follow a partial commit on the non-idempotent statussen/rollen POSTs), and a ZGW failure that survives retry sets Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing or diverging quietly. No outbox/reconcile job: three request-triggered write paths don't justify a persisted queue that would also need to carry citizen PII for the JWT audit claims. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,9 +58,12 @@ if (zgw.Enabled)
|
||||
{
|
||||
builder.Services.AddSingleton(zgw);
|
||||
builder.Services.AddSingleton<ZgwTokenProvider>();
|
||||
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>();
|
||||
// WP-60: a bounded client timeout matters once ZgwHttpClient retries — without one, the
|
||||
// sources' sync-over-async call (no CancellationToken threaded through) could block a
|
||||
// thread-pool thread for HttpClient's 100s default times 3 attempts.
|
||||
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
||||
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
|
||||
builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>();
|
||||
builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -351,13 +354,38 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
// in OpenZaak and maps its result back into this same response shape (ADR-0001/ADR-0005:
|
||||
// zero FE contract change either way). WP-53: the caller is threaded through so the minted
|
||||
// ZGW JWT's user_id/user_representation reflect the acting citizen, not a static config value.
|
||||
var (referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow, ctx.Caller());
|
||||
if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl);
|
||||
//
|
||||
// WP-60: the local submit above already committed — it is never rolled back on a ZGW
|
||||
// failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged
|
||||
// one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught
|
||||
// separately so a create-zaak failure doesn't also skip the (still-local) document link.
|
||||
var referentie = submitted.Referentie!;
|
||||
var status = submitted.ToStatusDto(DateTimeOffset.UtcNow);
|
||||
string? zaakUrl = null;
|
||||
try
|
||||
{
|
||||
(referentie, status, zaakUrl) = zaken.CreateZaak(submitted, DateTimeOffset.UtcNow, ctx.Caller());
|
||||
if (zaakUrl is not null) ApplicationStore.SetZaakUrl(id, zaakUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordZgwDivergence(ctx, id, referentie, ex);
|
||||
}
|
||||
|
||||
// WP-51: link the submitted documents to the zaak — LocalDocumentSource is exactly the
|
||||
// DocumentStore.Link call this used to make inline; OpenZaakDocumentSource additionally
|
||||
// POSTs a zaakinformatieobject per document, now that the zaak (zaakUrl) exists.
|
||||
if (documentIds is not null) documents.LinkToZaak(documentIds, zaakUrl, ctx.Caller());
|
||||
if (documentIds is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
documents.LinkToZaak(documentIds, zaakUrl, ctx.Caller());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RecordZgwDivergence(ctx, id, referentie, ex);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new SubmitApplicationResponse(referentie, status));
|
||||
})
|
||||
@@ -676,6 +704,18 @@ void AuditAuthz(HttpContext ctx, string action, string resource, bool allowed, P
|
||||
AuthzAuditStore.Record(action, resource, allowed, principal.Role.ToString(), cid);
|
||||
}
|
||||
|
||||
// WP-60: the local write already committed — this records that its ZGW counterpart didn't,
|
||||
// rather than letting the two sides diverge silently (openzaak-integration.md's "Write
|
||||
// resilience" section). Same audit trail AuditAuthz writes to (/beheer/audit), so a
|
||||
// divergence is visible next to every other decision, not a separate mechanism.
|
||||
void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exception ex)
|
||||
{
|
||||
app.Logger.LogError(ex, "zgw divergence aanvraag={Id} reference={Reference}", id, referentie);
|
||||
ApplicationStore.SetZgwError(id, ex.Message);
|
||||
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
|
||||
AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid);
|
||||
}
|
||||
|
||||
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
|
||||
// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree.
|
||||
static string MaskTail(string value, int keep) =>
|
||||
|
||||
Reference in New Issue
Block a user