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:
eho
2026-07-04 20:03:41 +02:00
co-authored by Claude Sonnet 5
parent e276629107
commit 40dbcb2606
8 changed files with 322 additions and 44 deletions
@@ -1,12 +1,34 @@
import { Provider } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
import { ApiClient, ProblemDetails } from './api-client';
import { environment } from '../../../environments/environment';
/** Single place every API call passes through: the seam for cross-cutting concerns. */
const REQUEST_TIMEOUT_MS = 10_000;
/**
* A stable Idempotency-Key threaded down from the command layer (one per logical
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
* every non-GET call made synchronously inside `fn` picks up the same key.
* ponytail: a module-level variable, not a proper async-context primitive — holds
* up because every submit command calls its adapter synchronously (no await
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
* submits ever become possible.
*/
let pendingIdempotencyKey: string | undefined;
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
pendingIdempotencyKey = key;
return fn().finally(() => (pendingIdempotencyKey = undefined));
}
export function currentIdempotencyKey(): string {
return pendingIdempotencyKey ?? crypto.randomUUID();
}
/**
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
* client expects, so every API call flows through HttpClient interceptors (the
@@ -15,12 +37,14 @@ const REQUEST_TIMEOUT_MS = 10_000;
* Angular's HTTP stack — i.e. the one seam to add:
* - timeout (done — REQUEST_TIMEOUT_MS),
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
* - idempotency key for writes (done — Idempotency-Key; a real retry would thread
* a STABLE key per logical submit so re-sends dedupe; here it's per-attempt),
* - idempotency key for writes (done — Idempotency-Key, stable per logical
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
* - retry/backoff: wrap the pipe with rxjs `retry({ count, delay })` here.
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
* never auto-retried, which is exactly what makes the idempotency key above
* matter only for a future/manual retry, not routine traffic).
*/
function httpClientFetch(http: HttpClient) {
export function httpClientFetch(http: HttpClient) {
return {
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
@@ -28,17 +52,18 @@ function httpClientFetch(http: HttpClient) {
...((init?.headers ?? {}) as Record<string, string>),
'X-Correlation-Id': crypto.randomUUID(),
};
if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID();
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
try {
const request$ = http
.request(method, url as string, {
body: init?.body as string | undefined,
headers,
observe: 'response',
responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS));
const res = await firstValueFrom(
http
.request(method, url as string, {
body: init?.body as string | undefined,
headers,
observe: 'response',
responseType: 'text',
})
.pipe(timeout(REQUEST_TIMEOUT_MS)),
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
);
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;