fix(api): read the error code before the status

A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.

The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:48:15 +02:00
1 parent 0057b03699
commit c300574a8e
20 files changed
+212 -128

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
/**
* The `meta.errorCode` an error envelope carries when its route named none.
*
* `meta.errorCode` is what a client is told to branch on, and roughly half of
* the refusals in the tree carried one: the rest answered prose alone, so a
* client could branch on the code for one 404 and had to fall back to the
* status for the next. Every error envelope now has a code. A route that names
* its own keeps it; one that does not gets the status's code from this table,
* so the generic family is predictable from the status alone and a client
* never has to special-case an absent field.
*
* Deliberately coarse. These say which kind of refusal it was, never why —
* a route that has a reason worth branching on names its own code. Statuses
* not listed fall back to `http.client_error` / `http.server_error`.
*/
export const DEFAULT_ERROR_CODES = {
400: "http.bad_request",
401: "http.unauthenticated",
403: "http.forbidden",
404: "http.not_found",
405: "http.method_not_allowed",
409: "http.conflict",
410: "http.gone",
413: "http.payload_too_large",
415: "http.unsupported_media_type",
422: "http.invalid",
429: "http.rate_limited",
500: "http.internal",
502: "http.bad_gateway",
503: "http.unavailable",
504: "http.gateway_timeout",
client: "http.client_error",
server: "http.server_error",
} as const;
/** The default code for an error status, see {@link DEFAULT_ERROR_CODES}. */
export function defaultErrorCodeForStatus(status: number): string {
const listed = (DEFAULT_ERROR_CODES as Record<number, string | undefined>)[
status
];
if (listed) return listed;
return status >= 500
? DEFAULT_ERROR_CODES.server
: DEFAULT_ERROR_CODES.client;
}
+36
View File
@@ -1,3 +1,6 @@
import { AUTH_ERROR_CODES, REPROOF_REQUIRED_CODE } from "@/lib/api-errors";
import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
import { ApiError } from "./api-fetch";
/**
@@ -12,12 +15,45 @@ import { ApiError } from "./api-fetch";
* - `throttled`: 429. The read budget is spent for now; an immediate retry
* would only be refused again.
* - `failed`: everything else, a real read failure worth a retry.
*
* The code decides before the status. A 401 is not always an ended session:
* a step-up or re-proof gate answers 401 too, with the session intact, and
* sending that reader to sign-in would end a session that was fine. So a
* code that names a missing proof reads as `failed`, a code that names an
* ended credential reads as `unauthenticated` whatever the status, and only
* a code this table does not know (or none, from a proxy or an older server)
* falls back to the status.
*/
export type ReadFailureKind =
"unauthenticated" | "refused" | "throttled" | "failed";
/** Codes that say the credential itself is gone: sign in again. */
const SESSION_ENDED_CODES: ReadonlySet<string> = new Set([
AUTH_ERROR_CODES.missing,
AUTH_ERROR_CODES.expired,
AUTH_ERROR_CODES.invalid,
DEFAULT_ERROR_CODES[401],
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
]);
/** Codes that ask for a fresh proof on a session that is still good. */
const PROOF_NEEDED_CODES: ReadonlySet<string> = new Set([
"auth.stepup.required",
REPROOF_REQUIRED_CODE,
"auth.reproof.failed",
"auth.reproof.too_weak",
AUTH_ERROR_CODES.mfaCode,
]);
export function readFailureKind(error: unknown): ReadFailureKind {
if (error instanceof ApiError) {
const code = error.meta?.errorCode;
if (typeof code === "string") {
if (SESSION_ENDED_CODES.has(code)) return "unauthenticated";
if (PROOF_NEEDED_CODES.has(code)) return "failed";
}
if (error.status === 401) return "unauthenticated";
if (error.status === 403) return "refused";
if (error.status === 429) return "throttled";