fix(api): take a new idempotency key once the server has answered

A create form kept its key until a save succeeded. The server stores a
refusal under the key for a day, so after a 422 or 409 the corrected
retry ran as a different body without any protection, and a timeout on
it could store the entry twice; an unchanged retry after the cause was
gone replayed the stale refusal until the form was closed.

Any answer now settles the key. Only a timeout, a dropped connection, or
the 409 that says the first request under the key is still running
(`X-Idempotent-Replay: false`, now `ApiError.inFlight`) keeps it, so
those retries still replay the first answer.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:50:42 +01:00
1 parent 0c77eaa085
commit 9683daf4f2
3 files changed
+73 -8

No files matched your search

+9
View File
@@ -79,6 +79,12 @@ export class ApiError extends Error {
* so a form can mark the one the server refused. Empty when it named none.
*/
readonly issuePaths: readonly string[];
/**
* The idempotency layer's "a request under this key is still running" (a
* 409 with `X-Idempotent-Replay: false`): nothing was decided, and a retry
* under the same key replays the first request's answer.
*/
readonly inFlight: boolean;
constructor(
message: string,
@@ -86,12 +92,14 @@ export class ApiError extends Error {
meta?: Record<string, unknown>,
requestId?: string | null,
issuePaths: readonly string[] = [],
inFlight = false,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.meta = meta;
this.issuePaths = issuePaths;
this.inFlight = inFlight;
const fromMeta =
typeof meta?.requestId === "string" && meta.requestId.length > 0
? meta.requestId
@@ -133,6 +141,7 @@ export async function throwApiError(res: Response): Promise<never> {
meta,
res.headers.get("x-request-id"),
issuePaths,
res.status === 409 && res.headers.get("x-idempotent-replay") === "false",
);
// A server-side failure is worth an id in the session's support info,
// whether or not the call site ends up showing it.