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
@@ -1,3 +1,4 @@
using System.Net;
using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
using BigRegister.Domain.Authorization;
@@ -77,15 +78,38 @@ public class OpenZaakDocumentSourceTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
}
// WP-60: once DocumentStore.Add has committed, a ZGW-side failure (config gap or transport)
// no longer throws — the local document is authoritative and DrcUrl stays null (the same
// detector LinkToZaak already skips on for pre-Zgw documents).
[Fact]
public void Upload_throws_when_the_category_has_no_configured_informatieobjecttype()
public void Upload_keeps_the_local_document_when_the_category_has_no_configured_informatieobjecttype()
{
var options = Options();
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<InvalidOperationException>(() =>
source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], Caller));
var response = source.Upload("local-1", "unknown-category", "registratie", "f.pdf", "application/pdf", [1, 2, 3], Caller);
Assert.Equal("local-1", response.LocalId);
Assert.Empty(handler.Requests);
Assert.Null(DocumentStore.Get(response.DocumentId)!.DrcUrl);
}
[Fact]
public void Upload_keeps_the_local_document_and_does_not_throw_when_drc_rejects_it()
{
var options = Options();
var handler = new ZgwStubHandler(
url => throw new InvalidOperationException($"unexpected success body requested for {url}"),
(_, _) => HttpStatusCode.BadRequest);
var source = new OpenZaakDocumentSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var response = source.Upload("local-1", "identiteit", "registratie", "paspoort.pdf", "application/pdf",
"%PDF-1.4 fake"u8.ToArray(), Caller);
Assert.Equal("local-1", response.LocalId);
Assert.Null(DocumentStore.Get(response.DocumentId)!.DrcUrl);
}
[Fact]