15 KiB
http
Professional HTTP client. Zero external dependencies. Tagged-result error
model. Per-call schema validation against any Standard Schema (Sium, Zod,
Valibot, ArkType). Idempotent-by-default retry with Retry-After parsing.
Per-attempt and total-budget timeouts. SvelteKit-native via injectable
fetch. Hooks for auth refresh, logging, transformation.
import { createEngineHttp } from '$http';
const http = createEngineHttp({ baseUrl: 'https://api.example.com' });
const r = await http.get('/users/me', { schema: UserSchema });
if (r.ok) {
console.log(r.value.email);
} else if (r.kind === 'http') {
console.error(`HTTP ${r.status}: ${r.statusText}`);
} else if (r.kind === 'validation') {
console.error('Server returned an unexpected shape:', r.issues);
} else {
console.error('Network failure:', r.error);
}
Why another one
fetch exists. axios, ky, ofetch, wretch exist. None ship with the
combination this artifact targets:
- Zero dependencies.
axioscarries IE polyfills,wretchships middlewares as separate addons,ofetchdepends onunjs/ufo. - Tagged-result error model. Every other library throws on non-2xx.
arts/httpreturns a discriminated union — errors are data, not exceptions (matches$sium'sResult<O>for uniform pattern matching). - Standard Schema first-class. Per-call
schemaparameter validates the response body. ReturnsOut<S>typed. Works with Sium / Zod / Valibot / ArkType identically. No runtime dep on Sium — the artifact only imports theStandardSchemaV1interface from$libs/standard-schema. bodySchemafor write methods. Validate the request payload before it leaves the engine. Catches "I sent the wrong shape" — a class of bugs no other generic HTTP client catches.- SvelteKit-aware.
engine.with({ fetch: event.fetch })returns a scoped client that inherits cookies, resolves relative URLs and skips network roundtrips when the route is local. - Retryer extracted. The retry primitive is a separable export — a
future
arts/cachecan reuse it without a duplicate implementation.
Architecture
http/
├── index.ts Barrel exports
├── types.ts EngineHttp, HttpResult, HttpInit, HttpHooks, RetryConfig
├── consts.ts DEFAULT_RETRY, DEFAULT_TIMEOUT, NULL_BODY_STATUSES, ...
├── engine-http.ts Factory createEngineHttp()
├── errors.ts HttpNetworkError / HttpTimeoutError / HttpAbortError /
│ HttpBodyValidationError + type guards
├── retry.ts parseRetryAfter, shouldRetryRequest, computeRetryDelay,
│ delayWithSignal (reusable by arts/cache)
├── timeout.ts composeSignals, attemptTimeoutSignal, totalTimeoutSignal,
│ classifyAbort
├── body.ts isJSONSerializable, serializeBody, parseBody, mergeHeaders
├── search.ts normalizeSearch, appendSearch, resolveUrl
└── test/
├── engine-http.test.ts end-to-end with stub fetch
├── retry.test.ts Retry-After parsing, policy, delay
├── timeout.test.ts signal composition + classification
├── body.test.ts JSON serialization + content-type sniffing
├── search.test.ts search + URL helpers
└── errors.test.ts hierarchy + type guards
Alias
Configured in svelte.config.js:
alias: { $http: 'src/arts/http' }
HttpResult — the discriminated union
Every method returns Promise<HttpResult<T>>:
type HttpResult<T> =
| { ok: true; value: T; response: Response }
| { ok: false; kind: 'http'; status; statusText; body; response: Response }
| { ok: false; kind: 'validation'; issues; response: Response }
| { ok: false; kind: 'network'; error };
The ok discriminator matches Sium's. Pattern-match identically:
if (r.ok) {
use(r.value);
} else switch (r.kind) {
case 'http': toast(`Server returned ${r.status}`); break;
case 'validation': console.error(r.issues); break;
case 'network': toast('Offline?'); break;
}
The response: Response is preserved on the three branches that have one —
auth flows, rate-limit headers and ETags all live there. The network
branch deliberately omits response because no HTTP exchange happened.
Validation — opt-in via schema
No schema → value is unknown, body is not parsed:
const r = await http.get('/api/health'); // r.value: unknown (undefined)
With a schema → body is parsed (JSON or text) and validated:
const UserSchema = sium.object({ id: sium.string(), email: sium.email() });
const r = await http.get('/api/users/me', { schema: UserSchema });
if (r.ok) r.value.email; // string, validated
Works with any Standard Schema vendor:
import { z } from 'zod';
const r = await http.get('/api/posts', {
schema: z.array(z.object({ id: z.string() }))
});
For POST / PUT / PATCH, validate the request body too:
await http.post('/api/users', {
body: payload,
bodySchema: CreateUserSchema, // pre-flight check; throws if invalid
schema: UserSchema // post-flight check on response
});
When bodySchema rejects, the engine throws HttpBodyValidationError
synchronously. This is a programmer error (you sent the wrong shape), not
a runtime data condition — different from kind: 'validation' which is
about the server's response.
API
createEngineHttp(options?)
const http = createEngineHttp({
baseUrl: 'https://api.example.com',
headers: { Accept: 'application/json' },
fetch: globalThis.fetch, // override per-engine
timeout: 10_000, // per-attempt (ms)
totalTimeout: 30_000, // total budget covering all attempts (0 = off)
retry: {
limit: 2,
statusCodes: [408, 425, 429, 500, 502, 503, 504],
methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'], // idempotent
delay: (n) => 1000 * 2 ** (n - 1), // exponential
backoffLimit: 30_000,
jitter: false
},
hooks: {
beforeRequest: [],
beforeRetry: [],
afterResponse: [],
beforeError: []
},
logger: App.logger // wired automatically when used via App.http
});
engine.with(overrides)
Returns a new engine with overrides merged on top of the current defaults.
Hook arrays concatenate (parent first); scalar fields are replaced. Never
mutates the parent engine.
const scoped = http.with({ fetch: event.fetch }); // SvelteKit pattern
const apiV2 = http.with({ baseUrl: 'https://api.example.com/v2' });
Method shortcuts
http.get <S>(url, init?) → Promise<HttpResult<Out<S>>>
http.head <S>(url, init?) → Promise<HttpResult<Out<S>>>
http.delete<S>(url, init?) → Promise<HttpResult<Out<S>>>
http.options<S>(url, init?) → Promise<HttpResult<Out<S>>>
http.post <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
http.put <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
http.patch <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
Out<S> is unknown when S is undefined, otherwise InferOutput<S>.
Per-call init
http.get('/users', {
schema: UserSchema, // validate response body
bodySchema: NewUserSchema, // (write methods only) validate body
body: { name: 'Ada' }, // any HttpBodyInit
headers: { 'X-Trace-Id': '...' }, // merged on top of engine defaults
search: { page: 2, q: 'ada' }, // appended to URL
fetch: event.fetch, // per-call fetch override
timeout: 5_000, // per-attempt (ms)
signal: abortCtrl.signal, // user signal — aborts cancel retry
retry: { limit: 0 }, // override retry policy (or `false`)
hooks: { beforeRequest: [trace] } // per-call hooks (engine first)
});
Retry policy
Defaults — idempotent-by-default: GET, HEAD, PUT, DELETE,
OPTIONS retry; POST and PATCH do not (unless methods is overridden).
Status codes: 408 425 429 500 502 503 504.
Retry-After
Five header variants are parsed in priority order:
Retry-After, RateLimit-Reset, X-RateLimit-Reset,
X-Rate-Limit-Reset, X-RateLimit-Retry-After
Numeric values below 1.7e9 are treated as delta-seconds; above as
epoch-seconds (the threshold is "no realistic retry delay is that large; no
realistic past timestamp is that small"). HTTP-dates (Sun, 06 Nov 1994 08:49:37 GMT) are also accepted. When the server signals a delay, it
overrides the policy's delay(attempt).
Signal hierarchy (no surprises)
| Signal source | Reaction |
|---|---|
User-provided signal |
Aborts the current attempt; no retry |
totalTimeout exceeded |
Aborts the current attempt; no retry |
Per-attempt timeout |
Aborts the attempt; retries if budget left |
Internal classification uses signal.reason (an HttpTimeoutError with
scope: 'attempt' | 'total', or an HttpAbortError for user signals) so
the retryer makes the right call without ambiguity.
Hooks lifecycle
Per attempt, in order:
beforeRequest[0..k] → fetch() → afterResponse[0..k] (success)
↘ beforeRetry[0..k] → wait(delay) → next attempt
Final failure: → beforeError[0..k] (may rescue with a Response)
Mutate ctx.request.headers in place — the change persists into the
fetch call. To rewrite the URL, mutate ctx.url. To short-circuit with a
cached response, return it from beforeRequest. To rescue a failed attempt,
return a Response from beforeError and the engine resolves as if it had
been received cleanly.
const http = createEngineHttp({
hooks: {
beforeRequest: [
(ctx) => {
ctx.request.headers.set('X-Trace-Id', crypto.randomUUID());
}
],
beforeRetry: [
({ attempt, retryDelay, error }) => {
console.warn(`retry ${attempt}, waiting ${retryDelay}ms`, error);
}
],
afterResponse: [
({ response }) => {
metrics.observe('http.duration', response.headers.get('x-time'));
}
],
beforeError: [
({ response }) => {
if (response?.status === 401) {
return refreshAndRetry(); // returns a new Response or undefined
}
}
]
}
});
Auth refresh pattern
Use a header hook so the token is read per attempt — beforeRetry can
update the underlying token between attempts and the next attempt's
headers reflect the new value:
let token = await getToken();
const http = createEngineHttp({
headers: () => ({ Authorization: `Bearer ${token}` }),
hooks: {
beforeRetry: [
async ({ error, response }) => {
if (response?.status === 401) {
token = await refreshToken();
}
}
]
}
});
SvelteKit integration
In a +page.ts / +page.server.ts / +layout.ts load, scope the engine
to the request via event.fetch:
// +page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const api = App.http.with({ fetch }); // inherits cookies + relative URLs
const user = await api.get('/api/users/me', { schema: UserSchema });
if (!user.ok) throw error(401, 'Not authenticated');
return { user: user.value };
};
Or per-call:
const r = await App.http.get('/api/users/me', {
fetch: event.fetch,
schema: UserSchema
});
Without event.fetch, server-side requests use globalThis.fetch and lose
SvelteKit's automatic cookie forwarding and relative-URL resolution.
Errors
Five subclasses, all with stable literal name and exported type guards:
| Class | When | Surfaces as |
|---|---|---|
HttpNetworkError |
Wrapper for fetch rejection |
kind: 'network' |
HttpTimeoutError |
Per-attempt or total timeout fired | kind: 'network' (final) |
HttpAbortError |
User signal aborted | kind: 'network' (final) |
HttpBodyValidationError |
bodySchema rejected the payload |
Thrown (programmer err) |
Type guards: isHttpNetworkError, isHttpTimeoutError, isHttpAbortError,
isHttpBodyValidationError. Each carries stable name so they survive
serialization across worker boundaries.
Composition with App
When used through createActiveApp(...), App.http is built with the
shared Logger injected automatically:
const App = createActiveApp({
http: {
baseUrl: 'https://api.example.com',
timeout: 10_000
}
});
await App.http.get('/users/me', { schema: UserSchema });
Per-request event.fetch for SSR:
const api = App.http.with({ fetch: event.fetch });
Validation messages with the App's locale:
const r = await App.http.get('/api/users/me', { schema: UserSchema });
if (!r.ok && r.kind === 'validation') {
const sium = App.sium;
console.error(sium.resolveIssues(r.issues));
}
Testing
The engine takes any fetch-compatible function. Stub it:
import { createEngineHttp } from '$http';
const http = createEngineHttp({
retry: { limit: 0 },
timeout: 0,
fetch: ((_url, _init) =>
Promise.resolve(new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' }
}))) as typeof fetch
});
Or queue several responses for retry scenarios — see
src/arts/http/test/engine-http.test.ts for the test helpers.
Bundle profile
| Layer | Approx. size (min) |
|---|---|
| Engine + Retryer + helpers | ~5 KB |
| Errors + type guards | ~1 KB |
| Total (everything reached) | ~6 KB |
Zero runtime dependencies. The StandardSchemaV1 import is type-only.