/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * API response utilities. */ import { AxiosError, HttpStatusCode, isAxiosError } from "axios" import type { StatusCodes } from "http-status-codes" import { ResourceError } from "../errors/schema.ts" import { isRetryableStatus } from "./retry.ts" /** * Whether an HTTP status is a 2xx success. * * Exists so a caller that opts out of throwing — `validateStatus: () => true`, for a graceful non-2xx path — can still * ask the question by name. Written against axios's own `HttpStatusCode` because this package already owns that * dependency; a consumer package spelling `>= 200 && < 300` inline would either name two bare thresholds or take an * undeclared dependency to avoid it. */ export function isSuccessStatus(status: number): boolean { return status >= HttpStatusCode.Ok && status < HttpStatusCode.MultipleChoices } /** * A response container, wrapping the actual response body. */ export interface ResponseContainer { data: Body } export type ResponseLike = ResponseContainer | Body /** * Type-helper to pluck the response body, possibly from within an Axios response. * * This is useful when normalizing a new Axios response and a cached local response. * * @internal */ export type ExtractResponseData = T extends ResponseContainer ? Body : T function isResponseContainer(responseContainer: ResponseLike): responseContainer is ResponseContainer function isResponseContainer(body: Body): body is Body function isResponseContainer(input: ResponseLike): input is ResponseContainer { return typeof input === "object" && input !== null && "data" in input } /** * Helper function to pluck the response body from an Axios response. */ function pluckResponseBody(responseContainer: ResponseContainer): Body function pluckResponseBody(rawBody: Body): Body function pluckResponseBody(input: ResponseContainer | Body): Body { if (isResponseContainer(input)) return input.data return input } /** * Type-helper to recursively pluck the `data` property from a response body. * * This is useful when an API nests the actual response body within a `data` property. * * @internal */ export type ExtractResponseBodyData = Body extends { data: infer Data } ? ExtractResponseBodyData : Body /** * Helper function to recursively pluck the `data` property from a response body. * * This is useful when an API nests the actual response body within a `data` property. * * @internal */ export function pluckResponseData(responseContainer: ResponseContainer): ExtractResponseBodyData export function pluckResponseData(input: ResponseContainer | Body): ExtractResponseBodyData { const body = pluckResponseBody(input) if (isResponseContainer(body)) return pluckResponseData(body) return body as ExtractResponseBodyData } /** * The `kind` component of a {@linkcode ResourceError}'s `(source, kind, reason)` URN, as produced by * {@linkcode delegateAxiosError}. This is the axis {@linkcode isTransientResourceError} branches on, so a caller never * has to pattern-match an error message. */ export const ResourceErrorKind = { /** * The request never reached an HTTP response: a connect failure, a DNS failure, a timeout, or a body read that died * mid-transfer. */ Network: "network", /** * An HTTP response came back and carried a failing status. */ Response: "response", /** * The request was rejected before (or independently of) the network — a caller-initiated cancel, a malformed config. * Never transient: re-issuing the identical request can only fail identically. */ Request: "request", /** * A response arrived with a SUCCESS status, but its body could not be decoded into the requested type — the classic * case being an upstream that serves an HTML error page under a 200. * * Never transient, deliberately: retrying an unchanged bad body cannot help, and a client that treated this as * retryable would spend its whole attempt budget re-downloading the same broken payload. */ Payload: "payload", } as const /** * The `kind` component of a {@linkcode ResourceError}'s URN. */ export type ResourceErrorKind = (typeof ResourceErrorKind)[keyof typeof ResourceErrorKind] /** * The `source` component every URN in this module carries — the machinery that produced the failure. */ const RESOURCE_ERROR_SOURCE = "axios" /** * The URN component separator {@linkcode ResourceError} joins segments with. */ const URN_SEPARATOR = ":" /** * The `kind` component of `error`'s URN, or `null` when it isn't a {@linkcode ResourceError} carrying one. * * Exposed so a caller can assert on the taxonomy directly (`kind === ResourceErrorKind.Network`) instead of splitting * the URN — or, worse, matching on `message` prose — at the call site. */ export function resourceErrorKind(error: unknown): ResourceErrorKind | null { if (!(error instanceof ResourceError)) return null const [, kind] = error.name.split(URN_SEPARATOR) switch (kind) { case ResourceErrorKind.Network: case ResourceErrorKind.Response: case ResourceErrorKind.Request: case ResourceErrorKind.Payload: return kind default: return null } } /** * Whether `error` is the kind of failure a caller should REQUEUE rather than give up on — every network-class failure * (connect, DNS, timeout, mid-transfer drop) and every transient HTTP status (408/429/5xx). * * This stays `true` even after a client exhausted its OWN bounded attempts: the client's ceiling is a statement about * one call, while a caller's requeue is a new, separate attempt budget minutes or hours later. Callers branch on this * plus {@linkcode ResourceError.status} — 404 to skip, 403 to abort — and never on message text. */ export function isTransientResourceError(error: unknown): boolean { if (!(error instanceof ResourceError)) return false switch (resourceErrorKind(error)) { case ResourceErrorKind.Network: return true case ResourceErrorKind.Response: return isRetryableStatus(error.status) default: return false } } /** * Build a {@linkcode ResourceError} carrying the `(source, kind, reason)` URN AND the originating `AxiosError` as its * `cause`, so a debugger keeps the full Axios context (config, request, response) that the mapped error summarizes. */ function taggedResourceError( cause: AxiosError, status: number, message: string, kind: ResourceErrorKind, reason: string ): ResourceError { const resourceError = ResourceError.from(status as StatusCodes, message, RESOURCE_ERROR_SOURCE, kind, reason) resourceError.cause = cause return resourceError } /** * The `reason` component for a failing HTTP status, chosen so the common branches a caller cares about are nameable * without re-deriving them from the number. */ function responseReason(status: number): string { switch (status) { case HttpStatusCode.Unauthorized: return "unauthorized" case HttpStatusCode.Forbidden: return "forbidden" case HttpStatusCode.NotFound: return "not-found" case HttpStatusCode.TooManyRequests: return "rate-limited" default: return isRetryableStatus(status) ? "server-error" : "status" } } /** * Delegate Axios errors to an appropriate error handler. * * ALWAYS throws — every failure past this point is a {@linkcode ResourceError} carrying a numeric `status`, a `(source, * kind, reason)` URN on `name`, and the originating `AxiosError` on `cause`. A non-Axios error is rethrown untouched. * * WHAT CHANGED, measured rather than recalled — a differential against `98c4dda1` across 18 failure shapes in the exact * `TileAPI` configuration found **16 of them changed**, not the two originally claimed: * * - Every RESPONSELESS failure (`ERR_NETWORK`, `ECONNREFUSED`, `ECONNRESET`, `ECONNABORTED`, `ETIMEDOUT`, `ERR_CANCELED`) * used to collapse into a uniform 500; they now split into 503 / 504 / 400 by cause, and `ERR_CANCELED` flips from * transient to terminal, which is the point — a caller who cancelled should not requeue. * - Every non-401 HTTP status used to rethrow the raw `AxiosError`, so `status`-based branching (404 → skip, 403 → abort) * had to reach into `error.response`. 401's own message and URN changed too. * * The earlier claim that `ECONNABORTED`/`ETIMEDOUT`/`ERR_CANCELED` RESOLVED the chain with `undefined` was wrong FOR * EVERY SHAPE AXIOS ACTUALLY PRODUCES: the old `if (!response) throw` ran BEFORE that `switch`, and axios never * attaches a `response` to a timeout or a cancellation, so a real one threw `axios:response:missing` 500 — a * misclassified 500, not a `TypeError` at the caller. Note the `return` arms were not unreachable in general, only * unreachable via axios: reaching the `switch` required a response to be PRESENT, and an error carrying both a * `response` and `ECONNABORTED` did resolve with `undefined`. Stock adapters never pair those, but this repo's own * `axiosLikeError(message, code, config, response)` helper builds that shape in one argument. No regression follows * from any of this — the sole call site has no `.catch`, and every shape that rejects now also rejected before. * * @internal */ export async function delegateAxiosError(error: unknown): Promise { if (!isAxiosError(error)) throw error const { response, code: networkErrorCode } = error if (response) { const { status } = response // A SUCCESS status that still produced an error means the STATUS was fine and the BODY was not — // Axios's `transformResponse` rejecting an unparseable JSON payload, most often. That is a // different failure from "the server said 500", and a different retry answer. if (status >= HttpStatusCode.Ok && status < HttpStatusCode.MultipleChoices) { throw taggedResourceError( error, HttpStatusCode.BadGateway, `Response body could not be decoded (${status} from ${error.config?.url ?? "unknown URL"}): ${error.message}`, ResourceErrorKind.Payload, "unreadable" ) } throw taggedResourceError( error, status, `${status} ${response.statusText || ""}`.trim() || `HTTP ${status}`, ResourceErrorKind.Response, responseReason(status) ) } if (networkErrorCode === AxiosError.ERR_CANCELED) { throw taggedResourceError( error, HttpStatusCode.BadRequest, "The request was canceled by its caller.", ResourceErrorKind.Request, "canceled" ) } if (networkErrorCode === AxiosError.ECONNABORTED || networkErrorCode === AxiosError.ETIMEDOUT) { throw taggedResourceError( error, HttpStatusCode.GatewayTimeout, `The request timed out before a response arrived (${error.config?.url ?? "unknown URL"}).`, ResourceErrorKind.Network, "timeout" ) } if (networkErrorCode === "ENOTFOUND") { // DELIBERATELY NO CONNECTIVITY PROBE. An earlier version issued a live `HEAD` here to word the // message as "are we connected to the internet?" rather than "could not resolve host". It cost // one unbounded, un-timed-out request per exhausted DNS failure — with a never-settling `fetch` // the client never settled at all — and no test could reach it: deleting the whole branch caused // 0 of 269 failures, and the hermetic no-live-network harness could not see it either, because // the probe swallowed its own synchronously-throwing `fetch` stub into `false`. Making the probe // incapable of throwing is exactly what made it invisible to the guard meant to catch it. The // classification is identical either way — network-class, transient — so the whole thing bought // one message string. throw taggedResourceError( error, HttpStatusCode.ServiceUnavailable, `Could not resolve host (${error.config?.url ?? "unknown URL"}).`, ResourceErrorKind.Network, "unreachable" ) } if (!networkErrorCode) { throw taggedResourceError( error, HttpStatusCode.InternalServerError, "Internal Server Error", ResourceErrorKind.Response, "missing" ) } // Everything left reached no response and wasn't cancelled: a dropped socket, a refused connection, // a TLS failure, a body read that died mid-transfer. All network-class, all worth another attempt. throw taggedResourceError( error, HttpStatusCode.ServiceUnavailable, `Service Unavailable (${networkErrorCode}): ${error.message}`, ResourceErrorKind.Network, "unavailable" ) }