Askr documentation
Guides

API Integration and Error Handling

API Integration and Error Handling: a worked guide from route registry through to a production build.

Example

Define the endpoint once, pass cancellation through every request, and branch on the typed result before reading data.

import { createClient, defineApi, get, json } from '@askrjs/fetch';

const api = createClient(defineApi({
  project: get('/projects/{id}').params<{ id: string }>().returns(json<Project>()),
}), { baseUrl: env.API_URL });

const result = await api.project({ params: { id }, signal });
if (!result.ok) return <ProjectError problem={result.error} />;
return <ProjectDetails project={result.data} />;

Goal and architecture

@askrjs/fetch gives you a typed client built from defineApi({...}) descriptors, where each endpoint declares its path, params, query, and response codecs with get/post/put/patch/del. Every call resolves to a discriminated ClientResult instead of throwing, so error handling is a type-narrowing problem, not a try/catch problem. That result carries an ok boolean plus a kind you can switch on — 'http' for a non-2xx response your API described, or transport-level kinds for network, timeout, and decode failures — which keeps error branches as visible in the type system as the success branch.

Implementation

Call createClient(api, { baseUrl, middleware }) once per backend and reuse it; add bearerAuth, logging, retry, or telemetry from @askrjs/fetch/middleware in whatever order you want them to run, since middleware executes outward in declaration order. Declare response and error shapes with .returns(200, json<T>()) and .errors({ 404: json<E>() }) so a 404 decodes into your typed error body instead of falling through to a generic failure. For requests without a full API descriptor, createFetch({ baseUrl }) gives you the same result/codec/middleware machinery for one-off calls, like a health check or a webhook receiver.

Failure states

Branch on result.ok first, then on result.kind for the failure path: 'http' means the server responded with a status your API declared (read result.status and result.error), while other kinds cover network drops, AbortSignal timeouts (kind: 'timeout'), caller cancellation (kind: 'abort'), and decode failures when a response doesn't match its codec. Don't collapse all of these into one generic 'something went wrong' toast — a timeout usually means 'retry', an HTTP 404 usually means 'not found, don't retry', and a decode failure means your client and server have drifted. The built-in retry middleware only retries idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) on specific statuses (408, 425, 429, 500, 502, 503, 504) and honors Retry-After, so requests with bodies won't get silently duplicated.

Verification

Assert against the shape of ClientResult in tests — construct expected success and failure branches and confirm your UI renders the right thing for each kind, not just the happy path. Use unwrap(result) in test helpers where throwing is more convenient than narrowing, but keep production code on the discriminated result. For codec correctness, feed a validator (safeParse) a value that fails and confirm the client surfaces a decode failure rather than crashing, since a validator's safeParse return type dictates exactly what shape errors take upstream.