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.
455 lines
15 KiB
455 lines
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.
|
|
|
|
```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<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`:
|
|
|
|
```js
|
|
alias: { $http: 'src/arts/http' }
|
|
```
|
|
|
|
---
|
|
|
|
## HttpResult — the discriminated union
|
|
|
|
Every method returns `Promise<HttpResult<T>>`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```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 <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
|
|
|
|
```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.
|