# 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. ```ts 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` for uniform pattern matching). - **Standard Schema first-class.** Per-call `schema` parameter validates the response body. Returns `Out` 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`: ```js alias: { $http: 'src/arts/http' } ``` --- ## HttpResult — the discriminated union Every method returns `Promise>`: ```ts type HttpResult = | { 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: ```ts 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: ```ts const r = await http.get('/api/health'); // r.value: unknown (undefined) ``` With a schema → body is parsed (JSON or text) and validated: ```ts 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: ```ts 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: ```ts 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?)` ```ts 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. ```ts const scoped = http.with({ fetch: event.fetch }); // SvelteKit pattern const apiV2 = http.with({ baseUrl: 'https://api.example.com/v2' }); ``` ### Method shortcuts ```ts http.get (url, init?) → Promise>> http.head (url, init?) → Promise>> http.delete(url, init?) → Promise>> http.options(url, init?) → Promise>> http.post (url, init?) → Promise>> http.put (url, init?) → Promise>> http.patch (url, init?) → Promise>> ``` `Out` is `unknown` when `S` is undefined, otherwise `InferOutput`. ### Per-call init ```ts 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. ```ts 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: ```ts 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`: ```ts // +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: ```ts 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: ```ts 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: ```ts const api = App.http.with({ fetch: event.fetch }); ``` Validation messages with the App's locale: ```ts 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: ```ts 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.