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>
95 lines
4.5 KiB
TypeScript
95 lines
4.5 KiB
TypeScript
import { Provider } from '@angular/core';
|
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
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
|
|
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
|
|
* is the only place HTTP shapes are known; this is the only place it meets
|
|
* 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, stable per logical
|
|
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
|
|
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
|
|
* - 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).
|
|
*/
|
|
export function httpClientFetch(http: HttpClient) {
|
|
return {
|
|
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
|
const method = (init?.method ?? 'GET').toUpperCase();
|
|
const headers: Record<string, string> = {
|
|
...((init?.headers ?? {}) as Record<string, string>),
|
|
'X-Correlation-Id': 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(
|
|
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;
|
|
return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 });
|
|
} catch (e) {
|
|
if (e instanceof TimeoutError) return new Response('', { status: 504 });
|
|
const err = e as HttpErrorResponse;
|
|
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
|
|
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
|
|
// request reports status 0, which `new Response` rejects).
|
|
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
|
|
return new Response(body, { status });
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
|
|
* environment (relative '' in dev → proxy; configurable per deployment). */
|
|
export function provideApiClient(): Provider {
|
|
return {
|
|
provide: ApiClient,
|
|
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
|
|
deps: [HttpClient],
|
|
};
|
|
}
|
|
|
|
export type { ProblemDetails };
|