@wtfalch/contracts
v0.1.0
Published
The shared wire contract for the estate's -service packages: one ServiceError code set, the code-to-HTTP-status map, the error JSON body, one SDK error class, and the shared header names. Pure TypeScript, no runtime dependencies.
Readme
@wtfalch/contracts
The shared wire contract for every estate -service package: one error type,
one error envelope on the wire, and the request conventions every service
follows. Pure TypeScript, no runtime dependencies.
Install
pnpm add @wtfalch/contractsThe closed code set
import { SERVICE_ERROR_CODES, STATUS_BY_CODE, isServiceErrorCode } from '@wtfalch/contracts';SERVICE_ERROR_CODES is the nine codes a service may answer, in the HTTP
status STATUS_BY_CODE maps each one to:
| code | status |
|---|---|
| invalid_request | 400 |
| unauthorized | 401 |
| forbidden | 403 |
| not_found | 404 |
| conflict | 409 |
| over_budget | 402 |
| not_configured | 503 |
| rate_limited | 429 |
| unavailable | 503 |
unauthorized is in the set deliberately: it is a no-credential 401, distinct
from a rejected-credential forbidden 403. isServiceErrorCode narrows an
unknown wire value (e.g. a service's raw JSON body) to this set.
Service side: throw a ServiceError
import { ServiceError, serviceErrorResponse } from '@wtfalch/contracts';
export async function handler(request: Request): Promise<Response> {
try {
if (!hasAccess) throw new ServiceError('forbidden', 'Access denied');
return Response.json(await doWork());
} catch (error) {
if (error instanceof ServiceError) return serviceErrorResponse(error);
throw error; // the host's own 500 path decides what an unexpected error looks like
}
}ServiceError's third constructor argument, retryAfterSeconds, only means
anything for rate_limited -- serviceErrorResponse sends it as the
Retry-After header and omits the header for every other code.
serviceErrorResponse builds the whole failure Response (status, the
{ error: { code, message } } body, and Retry-After when applicable) using
the global Fetch API, so it works unmodified in a Cloudflare Worker or a
Next.js route handler. If you only need the body -- for example inside a
framework that builds its own Response -- serviceErrorBody returns just
that { error: { code, message } } object.
Client side: catch a ServiceApiError
import { ServiceApiError, serviceApiErrorFrom } from '@wtfalch/contracts';
const response = await fetch(url, init);
if (!response.ok) {
throw serviceApiErrorFrom(response.status, await response.json(), response.headers.get('retry-after'));
}try {
await callService();
} catch (error) {
if (error instanceof ServiceApiError && error.code === 'rate_limited') {
await sleep((error.retryAfterSeconds ?? 1) * 1000);
}
}error.code is the closed ServiceErrorCode union, not a bare string, so
a caller can narrow on it directly instead of re-deriving a coarser table
from error.status. A code the wire sends that this package does not
recognise -- an unmigrated service's own vocabulary -- becomes unavailable,
never a made-up string outside the closed set.
serviceApiErrorFrom is the one place that parsing happens: build a
ServiceApiError yourself only if your transport already has its own
error.code/error.message extraction to keep.
Shared headers
import { ORGANISATION_HEADER, RETRY_AFTER_HEADER } from '@wtfalch/contracts';ORGANISATION_HEADER (x-organisation-id) is the org a caller is acting as,
verified by the service against the caller's credential.
RETRY_AFTER_HEADER (retry-after) is what serviceErrorResponse sets for
rate_limited.
Idempotency keys are not a header in this estate: every service that has
one takes it as a request-body field (idempotencyKey), enforced by a
database unique constraint, so this package does not name a header for it.
Out of scope
A service's own request and response schemas stay in that service's own SDK. This package is the failure shape and the codes, not the success shape.
Each consumer's own divergent codes -- integrations' connection_unavailable
and idempotency_conflict, activity's separate vocabulary with no
retryAfterSeconds -- are that consumer's own migration onto this set, not
something this package adapts for them.
