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:
@@ -1,18 +1,23 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
|
||||
|
||||
/**
|
||||
* Run a mutating API call and fold it into a `Result` — the one place the
|
||||
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||
* its own payload mapping. The backend re-validates and returns a 422
|
||||
* ProblemDetails on rejection, surfaced here as the error string.
|
||||
*
|
||||
* Also the one place a logical submit's Idempotency-Key is minted — once per
|
||||
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
|
||||
* dedupes on the backend (see `withIdempotencyKey`).
|
||||
*/
|
||||
export async function runSubmit<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: string,
|
||||
): Promise<Result<string, T>> {
|
||||
try {
|
||||
return ok(await fn());
|
||||
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, fallback));
|
||||
}
|
||||
|
||||
85
src/app/shared/infrastructure/api-client.provider.spec.ts
Normal file
85
src/app/shared/infrastructure/api-client.provider.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
|
||||
|
||||
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
|
||||
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
|
||||
function fakeHttpClient(
|
||||
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
|
||||
): HttpClient {
|
||||
return { request } as unknown as HttpClient;
|
||||
}
|
||||
|
||||
describe('withIdempotencyKey / currentIdempotencyKey', () => {
|
||||
it('threads the key to every read made inside the wrapped fn', async () => {
|
||||
const seen: string[] = [];
|
||||
await withIdempotencyKey('fixed-key', async () => {
|
||||
seen.push(currentIdempotencyKey());
|
||||
seen.push(currentIdempotencyKey());
|
||||
});
|
||||
expect(seen).toEqual(['fixed-key', 'fixed-key']);
|
||||
});
|
||||
|
||||
it('clears the key once the wrapped fn settles', async () => {
|
||||
await withIdempotencyKey('fixed-key', async () => undefined);
|
||||
expect(currentIdempotencyKey()).not.toBe('fixed-key');
|
||||
});
|
||||
|
||||
it('falls back to a generated uuid-shaped key when none is pending', () => {
|
||||
expect(currentIdempotencyKey()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpClientFetch', () => {
|
||||
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
|
||||
let sentHeaders: Record<string, string> | undefined;
|
||||
const http = fakeHttpClient((_method, _url, opts) => {
|
||||
sentHeaders = opts.headers;
|
||||
return of(new HttpResponse({ status: 200, body: '' }));
|
||||
});
|
||||
|
||||
await withIdempotencyKey('logical-submit-key', () =>
|
||||
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
|
||||
);
|
||||
|
||||
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
|
||||
});
|
||||
|
||||
// `http.request(...)` itself is only called once per `fetch()` — it returns a
|
||||
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
|
||||
// again (exactly how Angular's real HttpClient triggers a fresh network call
|
||||
// per subscription). So attempts are counted where the resubscription lands:
|
||||
// the `throwError` factory, not the outer mock call.
|
||||
it('retries a failing GET twice before giving up', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
|
||||
|
||||
expect(attempts).toBe(3); // 1 original + 2 retries
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('never retries a failing write', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user