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:
eho
2026-07-30 18:11:55 +02:00
co-authored by Claude Sonnet 5
parent 67abc58052
commit 3ff80c124f
18 changed files with 855 additions and 82 deletions
+45 -5
View File
@@ -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) =>