Skip to content

@absoluteui/helpers/http

Status reason phrases and messages for every status, failure categories, Problem Details (RFC 9457), header parsers, and response validation.

import { ... } from '@absoluteui/helpers/http';

The HTTP knowledge a client needs without the client: status classes and plain-language messages, RFC 9457 Problem Details, retry rules, and parsers for the headers you actually act on (Retry-After, Link, Content-Disposition, ETag, Cache-Control). Reach for it when you call fetch yourself or handle responses from another library. The HTTP client in @absoluteui/services/http (in preview) uses all of it; the HTTP guide covers the client, error categories, and translating status messages.

How do I turn a failed fetch into a message and field errors?

Section titled “How do I turn a failed fetch into a message and field errors?”

assertResponse throws an HttpError for an unexpected status or content type (its message never contains the query or body). For a failure, read the Problem Details body: problemToFieldErrors understands the common shapes (RFC 9457 errors, invalid-params, ASP.NET, violations), and userMessageForStatus gives a message when the body has none.

import { assertResponse, parseProblemDetails, problemToFieldErrors, userMessageForStatus } from '@absoluteui/helpers/http';
const res = new Response(
JSON.stringify({ title: 'Invalid leave request', status: 422, errors: [{ detail: 'Must be a working day.', pointer: '#/startDate' }] }),
{ status: 422, headers: { 'content-type': 'application/problem+json' } },
);
let fields: Record<string, string> = {};
let message = '';
try {
assertResponse(res, { expectContentType: 'json' });
} catch {
const problem = parseProblemDetails(await res.text());
fields = problemToFieldErrors(problem);
message = userMessageForStatus(res.status);
}
fields; // => { startDate: 'Must be a working day.' }
message; // => 'Some of the information is not valid. Check it and try again.'

How do I decide whether to retry, and when?

Section titled “How do I decide whether to retry, and when?”

Retry only statuses that may succeed later, and only methods that are safe to repeat: a retried POST can create a second leave request. Honour Retry-After, which comes as seconds or an HTTP date.

import { isIdempotentMethod, isRetryableStatus, parseRetryAfter } from '@absoluteui/helpers/http';
const retryDelay = (method: string, status: number, retryAfter: string | null, attempt: number): number | undefined => {
if (!isRetryableStatus(status) || !isIdempotentMethod(method) || attempt >= 3) return undefined;
return parseRetryAfter(retryAfter, Date.now(), { maxMs: 30_000 }) ?? 500 * 2 ** attempt;
};
retryDelay('GET', 503, '5', 0); // => 5000
retryDelay('GET', 429, null, 2); // => 2000
retryDelay('POST', 503, '5', 0); // => undefined // not idempotent
retryDelay('PUT', 404, null, 0); // => undefined // will not change

APIs that page with a Link header (rel="next") need no page arithmetic: getPaginationLinks resolves the targets against the request URL.

import { getPaginationLinks } from '@absoluteui/helpers/http';
const link = '</api/employees?cursor=b7Kq&limit=50>; rel="next", </api/employees?limit=50>; rel="first"';
const pages = getPaginationLinks(link, 'https://hr.example.com/api/employees?limit=50');
pages.next; // => 'https://hr.example.com/api/employees?cursor=b7Kq&limit=50'
pages.prev; // => undefined // on the first page

How do I save a download under the server’s file name?

Section titled “How do I save a download under the server’s file name?”

parseContentDisposition decodes filename* (non-ASCII names) and gives safeFilename, a name that is safe on every operating system. Never write the raw filename: it can contain a path.

import { parseContentDisposition } from '@absoluteui/helpers/http';
const header = `attachment; filename="payslip.pdf"; filename*=UTF-8''Gehaltsabrechnung%20M%C3%BCller%202026-09.pdf`;
parseContentDisposition(header)?.safeFilename; // => 'Gehaltsabrechnung Müller 2026-09.pdf'
parseContentDisposition('attachment; filename="../../boot.ini"')?.safeFilename; // => 'boot.ini'

How do I avoid overwriting someone else’s change?

Section titled “How do I avoid overwriting someone else’s change?”

Keep the ETag from the load, send it as If-Match on save, and treat 412 as a conflict. etagMatchesAny is the same check for a server or a mock; strong comparison is required for If-Match.

import { etagMatchesAny, httpFailureCategory, httpStatus } from '@absoluteui/helpers/http';
const loadedEtag = '"v41"';
const headers = { 'content-type': 'application/json', 'if-match': loadedEtag };
etagMatchesAny(headers['if-match'], '"v42"', { weak: false }); // => false // someone saved in between
httpFailureCategory(httpStatus.PRECONDITION_FAILED); // => 'conflict'

isProblemDetails accepts any object with a well-typed type, title, or status, so it cannot tell your API’s errors from a proxy’s. Oversized error bodies are rejected before parsing. Log URLs with safeUrlForLog, which drops credentials, query, and fragment (tokens often travel there).

import { assertResponse, parseProblemDetails, safeUrlForLog } from '@absoluteui/helpers/http';
assertResponse(new Response('<html>', { headers: { 'content-type': 'text/html' } }), { expectContentType: 'json' }); // => throws HLP_HTTP_CONTENT_TYPE
parseProblemDetails('{"title":"x"}'.padEnd(2000), { maxLength: 1000 }); // => throws HLP_HTTP_PROBLEM_TOO_LARGE
parseProblemDetails('{"message":"Oops"}'); // => undefined // not problem details
safeUrlForLog('https://hr.example.com/reset?token=s3cr3t'); // => 'https://hr.example.com/reset'

decodeExtValue

Decodes an RFC 8187 / RFC 5987 ext-value such as UTF-8''na%C3%AFve.txt. Supports UTF-8 and ISO-8859-1; returns undefined for other charsets or malformed encoding.

function decodeExtValue(value: string): string | undefined;

Example:

decodeExtValue("UTF-8''%E2%82%AC%20rates") // => '€ rates'

etagMatches

Compares entity tags (RFC 9110 the specification). Weak comparison (default) ignores the W/ flag; strong comparison requires both to be strong. Unparseable tags never match.

function etagMatches(a: string | ETag | null | undefined, b: string | ETag | null | undefined, options?: {
readonly weak?: boolean;
}): boolean;

Example:

etagMatches('W/"1"', '"1"') // => true

Example:

etagMatches('W/"1"', '"1"', { weak: false }) // => false

etagMatchesAny

True when etag matches an If-None-Match (weak) or If-Match (weak: false) header.

function etagMatchesAny(header: string | null | undefined, etag: string | ETag | null | undefined, options?: {
readonly weak?: boolean;
}): boolean;

Example:

etagMatchesAny('"a", "b"', '"b"') // => true

getPaginationLinks

Extracts next, prev (or previous), first and last targets from a Link header.

function getPaginationLinks(header: string | readonly Link[] | null | undefined, baseUrl?: string): PaginationLinks;

Example:

getPaginationLinks('<?page=3>; rel="next", <?page=1>; rel="prev"', 'https://api.example.com/items') // => { next: 'https://api.example.com/items?page=3', prev: 'https://api.example.com/items?page=1' }

isJsonContentType

True for application/json and any +json media type (application/problem+json).

function isJsonContentType(value: string | null | undefined): boolean;

Example:

isJsonContentType('application/vnd.api+json') // => true

parseAcceptLanguage

Parses Accept-Language, drops q=0 and malformed entries, and sorts by quality (stable for ties). At most max entries (default 50) are read.

function parseAcceptLanguage(value: string | null | undefined, max?: number): LanguageRange[];

Example:

parseAcceptLanguage('fr-CH, fr;q=0.9, en;q=0.8, *;q=0.5') // => [{ tag: 'fr-CH', q: 1 }, { tag: 'fr', q: 0.9 }, { tag: 'en', q: 0.8 }, { tag: '*', q: 0.5 }]

parseCacheControl

Parses Cache-Control (RFC 9111). Invalid delta-seconds are ignored; values over 2^31 are capped.

function parseCacheControl(value: string | null | undefined): CacheControl;

Example:

parseCacheControl('public, max-age=3600, stale-while-revalidate=60').maxAge // => 3600

parseContentDisposition

Parses Content-Disposition (RFC 6266), decoding filename* (RFC 8187) and producing a sanitized file name candidate for saving.

function parseContentDisposition(value: string | null | undefined): ContentDisposition | undefined;

Example:

parseContentDisposition(`attachment; filename="plain.txt"; filename*=UTF-8''%E2%82%AC.txt`)?.filename // => '€.txt'

parseContentType

Parses a Content-Type value.

function parseContentType(value: string | null | undefined): ContentType | undefined;

Example:

parseContentType('application/json; charset=UTF-8') // => { type: 'application', subtype: 'json', suffix: '', mediaType: 'application/json', params: { charset: 'utf-8' } }

parseETag

Parses one entity tag ("abc" or W/"abc"), RFC 9110 the specification

function parseETag(value: string | null | undefined): ETag | undefined;

Example:

parseETag('W/"v1"') // => { weak: true, tag: 'v1' }

parseETagList

Parses an If-Match / If-None-Match list: '*' or entity tags (invalid members skipped).

function parseETagList(value: string | null | undefined): '*' | ETag[];

Example:

parseETagList('"a", W/"b"') // => [{ weak: false, tag: 'a' }, { weak: true, tag: 'b' }]

parseHttpDate

Parses an HTTP-date (RFC 9110 the specification): IMF-fixdate, obsolete RFC 850 and asctime formats. Returns epoch milliseconds or undefined.

function parseHttpDate(value: string | null | undefined, now?: number | Date): number | undefined;

Example:

parseHttpDate('Sun, 06 Nov 1994 08:49:37 GMT') // => 784111777000

parseLinkHeader

Parses a Link header (RFC 8288): multiple links, quoted parameters (commas and semicolons inside quotes), multiple relation types, and RFC 8187 title*. Malformed links are skipped.

function parseLinkHeader(value: string | null | undefined, options?: LinkHeaderOptions): Link[];

Example:

parseLinkHeader('<https://api.example.com/items?page=2>; rel="next"')[0]?.rel // => ['next']

parseRetryAfter

Converts Retry-After (delta-seconds or HTTP-date) to a delay in milliseconds, clamped to 0...maxMs (default one hour). Returns undefined when absent or invalid.

function parseRetryAfter(value: string | null | undefined, now?: number | Date, options?: {
readonly maxMs?: number;
}): number | undefined;

Example:

parseRetryAfter('120') // => 120000

Example:

parseRetryAfter('Wed, 21 Oct 2015 07:28:00 GMT', Date.UTC(2015, 9, 21, 7, 27, 0)) // => 60000

CacheControl

interface CacheControl {
readonly maxAge?: number;
readonly sMaxAge?: number;
readonly staleWhileRevalidate?: number;
readonly staleIfError?: number;
readonly maxStale?: number;
readonly minFresh?: number;
readonly noCache: boolean;
readonly noStore: boolean;
readonly noTransform: boolean;
readonly mustRevalidate: boolean;
readonly proxyRevalidate: boolean;
readonly mustUnderstand: boolean;
readonly private: boolean;
readonly public: boolean;
readonly immutable: boolean;
readonly onlyIfCached: boolean;
readonly directives: Readonly<Record<string, string | true>>;
}

ContentDisposition

interface ContentDisposition {
readonly type: string;
readonly filename: string | undefined;
readonly safeFilename: string | undefined;
readonly params: Readonly<Record<string, string>>;
}

ContentType

interface ContentType {
readonly type: string;
readonly subtype: string;
readonly suffix: string;
readonly mediaType: string;
readonly params: Readonly<Record<string, string>>;
}

ETag

interface ETag {
readonly weak: boolean;
readonly tag: string;
}

LanguageRange

interface LanguageRange {
readonly tag: string;
readonly q: number;
}

Link

interface Link {
readonly uri: string;
readonly rel: readonly string[];
readonly params: Readonly<Record<string, string>>;
}

LinkHeaderOptions

interface LinkHeaderOptions {
readonly baseUrl?: string;
readonly maxLinks?: number;
readonly maxLength?: number;
}

PaginationLinks

interface PaginationLinks {
readonly next?: string;
readonly prev?: string;
readonly first?: string;
readonly last?: string;
}

isProblemDetails

True for an object that looks like RFC 9457 problem details: at least one of type, title or status, and every standard member present has the right type.

function isProblemDetails(value: unknown): value is Record<string, unknown>;

Example:

isProblemDetails({ title: 'Out of credit', status: 403 }) // => true

parseProblemDetails

Parses RFC 9457 problem details from a JSON string or an already-parsed value. Returns undefined when the input is not a problem-details object. JSON text over maxLength (default 1 000 000) throws HLP_HTTP_PROBLEM_TOO_LARGE.

function parseProblemDetails(input: unknown, options?: {
readonly maxLength?: number;
}): ProblemDetails | undefined;

Example:

parseProblemDetails('{"type":"https://example.com/probs/out-of-credit","title":"You do not have enough credit.","status":403,"balance":30}')
// { type: 'https://example.com/probs/out-of-credit', title: '…', status: 403, extensions: { balance: 30 }, fieldErrors: {} }

problemToFieldErrors

Extracts field errors from common problem-details extensions: invalid-params ([{ name, reason }], RFC 7807), errors as [{ detail, pointer }] (RFC 9457 example), [{ field, message }], or { field: string | string[] } (ASP.NET), and violations ([{ field, message }]). Pointers become paths like items[2].qty; first message per field.

function problemToFieldErrors(problem: unknown, maxFields?: number): Record<string, string>;

Example:

problemToFieldErrors({ errors: [{ detail: 'must be positive', pointer: '#/age' }] }) // => { age: 'must be positive' }

ProblemDetails

Normalized problem details. Members with the wrong JSON type are ignored (RFC 9457 the specification).

interface ProblemDetails {
readonly type: string;
readonly title?: string;
readonly status?: number;
readonly detail?: string;
readonly instance?: string;
readonly extensions: Readonly<Record<string, unknown>>;
readonly fieldErrors: Readonly<Record<string, string>>;
}

httpFailureCategory

Failure category of a status: specific statuses first, then client (4xx) or server (5xx).

function httpFailureCategory(status: number): HttpFailureCategory;

Example:

httpFailureCategory(422) // => 'validation'

Example:

httpFailureCategory(503) // => 'unavailable'

Example:

httpFailureCategory(418) // => 'client'

HttpFailureCategory

Why an HTTP call failed, for choosing a message, a retry, or a sign-in flow. client covers the 4xx statuses without a more specific category.

type HttpFailureCategory = 'network' | 'offline' | 'timeout' | 'cancelled' | 'unauthorized' | 'forbidden' | 'not-found' | 'conflict' | 'validation' | 'rate-limited' | 'client' | 'server' | 'unavailable' | 'bad-response' | 'too-large';

assertResponse

Throws HttpError unless the response has an expected status (HLP_HTTP_STATUS) and content type (HLP_HTTP_CONTENT_TYPE). Messages never contain the URL query or body. 204, 205 and 304 responses skip the content-type check.

function assertResponse(response: ResponseLike, options?: AssertResponseOptions): void;

Example:

const res = new Response('{"id":7}', { headers: { 'content-type': 'application/json' } });
assertResponse(res, { expectContentType: 'json' });
const data: unknown = await res.json(); // => { id: 7 }
assertResponse(new Response(null, { status: 404 })); // => throws HttpError

classifyHttpError

Classifies anything thrown by a fetch-based call.

function classifyHttpError(error: unknown): HttpErrorCategory;

Example:

classifyHttpError(new TypeError('Failed to fetch')) // => 'network'

Example:

import { HttpError } from '@absoluteui/helpers/error';
classifyHttpError(new HttpError('X', 'Failed.', { status: 401 })); // => 'auth'

safeUrlForLog

Strips credentials, query and fragment from a URL so it is safe to log.

function safeUrlForLog(url: string): string;

Example:

safeUrlForLog('https://u:p@api.example.com/a?token=x#f') // => 'https://api.example.com/a'

AssertResponseOptions

interface AssertResponseOptions {
readonly expectStatus?: number | readonly number[] | ((status: number) => boolean);
readonly expectContentType?: 'json' | (string & {}) | readonly string[];
readonly method?: string;
}

ResponseLike

Minimal response shape (satisfied by the Fetch API Response).

interface ResponseLike {
readonly status: number;
readonly statusText?: string;
readonly url?: string;
readonly headers: {
get(name: string): string | null;
};
}

HttpErrorCategory

Coarse error category for choosing a message, retry or sign-in flow.

type HttpErrorCategory = 'network' | 'timeout' | 'aborted' | 'client' | 'server' | 'auth' | 'unknown';

composeStatusMessages

One catalog from several; the first that defines a status wins.

function composeStatusMessages(...catalogs: readonly (StatusMessageCatalog | undefined)[]): StatusMessageCatalog;

Example:

const app = composeStatusMessages(statusMessageCatalog({ 404: 'No such employee.' }), statusMessageCatalog({ 404: 'Not here.', 409: 'Someone else saved first.' }));
[app(404), app(409), app(500)]; // => ['No such employee.', 'Someone else saved first.', undefined]

describeStatus

Reason phrase, message, and class of a status.

function describeStatus(status: number, catalog?: StatusMessageCatalog): StatusDescription;

Example:

describeStatus(429);
// => { status: 429, reason: 'Too Many Requests', message: 'Too many requests. Wait a moment and try again.', statusClass: 'client-error' }

isClientError

4xx.

function isClientError(status: number): boolean;

Example:

isClientError(404) // => true

isIdempotentMethod

RFC 9110 the specification idempotent methods: GET, HEAD, OPTIONS, TRACE, PUT, DELETE (case-insensitive).

function isIdempotentMethod(method: string): boolean;

Example:

isIdempotentMethod('put') // => true

isInformational

1xx.

function isInformational(status: number): boolean;

Example:

isInformational(103) // => true

isRedirect

3xx.

function isRedirect(status: number): boolean;

Example:

isRedirect(302) // => true

isRetryableStatus

True when a request that failed with status may succeed if retried. Combine with {@link isIdempotentMethod}: never blindly retry a POST.

function isRetryableStatus(status: number, retryable?: ReadonlySet<number> | readonly number[]): boolean;

Example:

isRetryableStatus(503) // => true

isSafeMethod

RFC 9110 the specification safe (read-only) methods: GET, HEAD, OPTIONS, TRACE.

function isSafeMethod(method: string): boolean;

Example:

isSafeMethod('POST') // => false

isServerError

5xx.

function isServerError(status: number): boolean;

Example:

isServerError(503) // => true

isSuccess

2xx.

function isSuccess(status: number): boolean;

Example:

isSuccess(204) // => true

localizedStatusMessages

Catalog for a locale from per-locale message objects, falling back from the full tag to its language (fr-CA -> fr). Missing entries fall through to the English defaults.

function localizedStatusMessages(catalogs: Readonly<Record<string, StatusMessages>>, locale: string): StatusMessageCatalog;

Example:

const catalog = localizedStatusMessages({ fr: { 404: 'Élément introuvable.' } }, 'fr-CA');
userMessageForStatus(404, catalog); // => 'Élément introuvable.'
userMessageForStatus(503, catalog); // => 'The service is temporarily unavailable. Try again in a few minutes.'

statusClass

Status class of a code.

function statusClass(status: number): StatusClass;

Example:

statusClass(0) // => 'network'

Example:

statusClass(422) // => 'client-error'

statusMessageCatalog

Catalog from a messages object.

function statusMessageCatalog(messages: StatusMessages): StatusMessageCatalog;

Example:

const catalog = statusMessageCatalog({ 404: 'No such employee.' });
[catalog(404), catalog(500)]; // => ['No such employee.', undefined]

statusText

Standard reason phrase, or '' for unregistered codes.

function statusText(status: number): string;

Example:

statusText(404) // => 'Not Found'

userMessageForStatus

Plain-language message for a status (0 = network failure). catalog entries win; then {@link DEFAULT_STATUS_MESSAGES}; then a message for the status class.

function userMessageForStatus(status: number, catalog?: StatusMessageCatalog): string;

Example:

userMessageForStatus(503) // => 'The service is temporarily unavailable. Try again in a few minutes.'

Example:

userMessageForStatus(404, (s) => (s === 404 ? 'No such employee.' : undefined)); // => 'No such employee.'
userMessageForStatus(499); // => 'The request could not be completed. Check the information and try again.'

DEFAULT_RETRYABLE_STATUSES

Statuses retried by default: 408, 425, 429, 500, 502, 503, 504.

const DEFAULT_RETRYABLE_STATUSES: ReadonlySet<number>;

Example:

DEFAULT_RETRYABLE_STATUSES.has(503) // => true

DEFAULT_STATUS_MESSAGES

Plain-language English message for every registered status code, plus 0 (network failure), for alerts, toasts, and error summaries. Frozen; copy it as the starting point of a translation.

const DEFAULT_STATUS_MESSAGES: StatusMessages;

Example:

DEFAULT_STATUS_MESSAGES[404] // => 'The item could not be found. It may have been moved or deleted.'

Example:

Object.keys(DEFAULT_STATUS_MESSAGES).length // => 63

httpStatus

Named status codes (RFC 9110 and registered extensions).

const httpStatus: Readonly<{
readonly CONTINUE: 100;
readonly SWITCHING_PROTOCOLS: 101;
readonly PROCESSING: 102;
readonly EARLY_HINTS: 103;
readonly OK: 200;
readonly CREATED: 201;
readonly ACCEPTED: 202;
readonly NON_AUTHORITATIVE_INFORMATION: 203;
readonly NO_CONTENT: 204;
readonly RESET_CONTENT: 205;
readonly PARTIAL_CONTENT: 206;
readonly MULTI_STATUS: 207;
readonly ALREADY_REPORTED: 208;
readonly IM_USED: 226;
readonly MULTIPLE_CHOICES: 300;
readonly MOVED_PERMANENTLY: 301;
readonly FOUND: 302;
readonly SEE_OTHER: 303;
readonly NOT_MODIFIED: 304;
readonly USE_PROXY: 305;
readonly TEMPORARY_REDIRECT: 307;
readonly PERMANENT_REDIRECT: 308;
readonly BAD_REQUEST: 400;
readonly UNAUTHORIZED: 401;
readonly PAYMENT_REQUIRED: 402;
readonly FORBIDDEN: 403;
readonly NOT_FOUND: 404;
readonly METHOD_NOT_ALLOWED: 405;
readonly NOT_ACCEPTABLE: 406;
readonly PROXY_AUTHENTICATION_REQUIRED: 407;
readonly REQUEST_TIMEOUT: 408;
readonly CONFLICT: 409;
readonly GONE: 410;
readonly LENGTH_REQUIRED: 411;
readonly PRECONDITION_FAILED: 412;
readonly CONTENT_TOO_LARGE: 413;
readonly URI_TOO_LONG: 414;
readonly UNSUPPORTED_MEDIA_TYPE: 415;
readonly RANGE_NOT_SATISFIABLE: 416;
readonly EXPECTATION_FAILED: 417;
readonly IM_A_TEAPOT: 418;
readonly MISDIRECTED_REQUEST: 421;
readonly UNPROCESSABLE_CONTENT: 422;
readonly LOCKED: 423;
readonly FAILED_DEPENDENCY: 424;
readonly TOO_EARLY: 425;
readonly UPGRADE_REQUIRED: 426;
readonly PRECONDITION_REQUIRED: 428;
readonly TOO_MANY_REQUESTS: 429;
readonly REQUEST_HEADER_FIELDS_TOO_LARGE: 431;
readonly UNAVAILABLE_FOR_LEGAL_REASONS: 451;
readonly INTERNAL_SERVER_ERROR: 500;
readonly NOT_IMPLEMENTED: 501;
readonly BAD_GATEWAY: 502;
readonly SERVICE_UNAVAILABLE: 503;
readonly GATEWAY_TIMEOUT: 504;
readonly HTTP_VERSION_NOT_SUPPORTED: 505;
readonly VARIANT_ALSO_NEGOTIATES: 506;
readonly INSUFFICIENT_STORAGE: 507;
readonly LOOP_DETECTED: 508;
readonly NOT_EXTENDED: 510;
readonly NETWORK_AUTHENTICATION_REQUIRED: 511;
}>;

Example:

httpStatus.NOT_FOUND // => 404

StatusDescription

Everything a UI needs to describe a status.

interface StatusDescription {
readonly status: number;
readonly reason: string;
readonly message: string;
readonly statusClass: StatusClass;
}

HttpStatusName

type HttpStatusName = keyof typeof httpStatus;

StatusClass

Status class of a code; network for 0 (no response).

type StatusClass = 'network' | 'informational' | 'success' | 'redirect' | 'client-error' | 'server-error' | 'unknown';

StatusMessageCatalog

Replacement messages by status; return undefined to fall back to the defaults.

type StatusMessageCatalog = (status: number) => string | undefined;

StatusMessages

Messages keyed by status code, for translation files and overrides.

type StatusMessages = Readonly<Partial<Record<number, string>>>;