/**
* @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"
)
}