You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

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. axios carries IE polyfills, wretch ships middlewares as separate addons, ofetch depends on unjs/ufo.
  • Tagged-result error model. Every other library throws on non-2xx. arts/http returns a discriminated union — errors are data, not exceptions (matches $sium's Result<O> for uniform pattern matching).
  • Standard Schema first-class. Per-call schema parameter validates the response body. Returns Out<S> typed. Works with Sium / Zod / Valibot / ArkType identically. No runtime dep on Sium — the artifact only imports the StandardSchemaV1 interface from $libs/standard-schema.
  • bodySchema for 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/cache can 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.

Powered by TurnKey Linux.