HTTP client
@sometic/http is a fetch-first client: interceptors, retry/backoff, in-flight dedupe, typed errors, mock fetcher helpers, and an optional auth refresh queue via @sometic/http/auth. It does not embed Firebase, Supabase, or OIDC SDKs. @sometic/auth is an optional peer used only when you import the auth interceptor.
System standout: epoch ledger + policy
createAuthInterceptor tags meta.sessionEpoch and refuses replay after an epoch bump (HTTP_SESSION_STALE). createPolicyInterceptor fails closed on capability checks. Defaults: allowAbsoluteUrl: false, optional maxResponseBytes. Prefer createAppShell / bindAuthToHttp for the full spine.
Overview
| Concern | API |
|---|---|
| Create client | createHttp(options?) |
| Verbs | get / post / put / patch / delete / request |
| Extend | client.extend(overrides) |
| Interceptors | interceptors option + composeInterceptors |
| Auth refresh | createAuthInterceptor from @sometic/http/auth |
| Policy | createPolicyInterceptor from @sometic/http/auth |
| Transport | allowAbsoluteUrl (default false), maxResponseBytes |
| Retry | retry option / per-request retry |
| Dedupe | dedupe option / per-request dedupe |
| Errors | HTTP_ERROR_CODES / createHttpError |
| Tests | createMockFetcher |
When to use
Shared HTTP behavior across Vanilla, React, and Vue without pulling Axios into every adapter, especially when paired with Sometic auth refresh.
When not to use
- You need a full GraphQL cache layer (use a GraphQL client; optionally wrap transport)
- You want provider SDKs inside the HTTP package (they belong in
@sometic/auth-*)
Installation
pnpm add @sometic/httpnpm install @sometic/httpyarn add @sometic/httpbun add @sometic/httpFor auth interceptors also install @sometic/auth (optional peer):
pnpm add @sometic/authUsage
Create and call
import { createHttp } from "@sometic/http";
const http = createHttp({
baseUrl: "https://api.example.com",
headers: { Accept: "application/json" },
timeoutMs: 15_000,
});
const users = await http.get("/users");
const created = await http.post("/users", JSON.stringify({ name: "Ada" }), {
headers: { "Content-Type": "application/json" },
});
http.dispose();import { createHttp } from "@sometic/http";
const http = createHttp({
baseUrl: "https://api.example.com",
headers: { Accept: "application/json" },
timeoutMs: 15_000,
});
const users = await http.get<User[]>("/users");
const created = await http.post<User>("/users", JSON.stringify({ name: "Ada" }), {
headers: { "Content-Type": "application/json" },
});
http.dispose();import { createHttp } from "@sometic/http";
const http = createHttp({
baseUrl: "https://api.example.com",
});
const users = await http.get("/users");
http.dispose();Auth refresh queue
import { createHttp } from "@sometic/http";
import { createAuthInterceptor } from "@sometic/http/auth";
import type { AuthController } from "@sometic/auth";
function createApi(auth: AuthController) {
return createHttp({
baseUrl: "https://api.example.com",
interceptors: [createAuthInterceptor({ auth })],
});
}On 401 (configurable), the interceptor calls auth.handleUnauthorized(), shares one in-flight refresh, and replays the request once. Details: Auth interceptors and Token refresh.
How it works
- Merge
baseUrl, default headers, and per-request config (joinUrl,mergeHeaders). - Run request interceptors in order.
- Optionally dedupe identical in-flight requests.
fetchwith timeout via composedAbortSignal.- Parse by
responseType(jsondefault). - Run response interceptors; on failure run error interceptors (auth may return a replay).
- Retry according to retry policy when enabled.
Auth core never imports HTTP. HTTP optionally peers on auth. Provider SDKs never enter this package.
API
createHttp
type CreateHttpOptions = {
baseUrl?: string;
headers?: Record<string, string>;
fetcher?: typeof fetch;
timeoutMs?: number;
interceptors?: HttpInterceptor[];
retry?: RetryOptions | false;
dedupe?: DedupeOptions | false;
now?: () => number;
};
type HttpClient = {
request: <T = unknown>(config: HttpRequestConfig) => Promise<HttpResponse<T>>;
get: <T>(url: string, init?) => Promise<HttpResponse<T>>;
post: <T>(url: string, body?, init?) => Promise<HttpResponse<T>>;
put: <T>(url: string, body?, init?) => Promise<HttpResponse<T>>;
patch: <T>(url: string, body?, init?) => Promise<HttpResponse<T>>;
delete: <T>(url: string, init?) => Promise<HttpResponse<T>>;
extend: (overrides: CreateHttpOptions) => HttpClient;
dispose: () => void;
};Request config
type HttpRequestConfig = {
method?: HttpMethod | string;
url: string;
headers?: Record<string, string>;
body?: BodyInit | null;
signal?: AbortSignal | null;
timeoutMs?: number;
responseType?: "json" | "text" | "blob" | "arrayBuffer" | "raw";
retry?: boolean | number;
dedupe?: boolean;
authReplay?: boolean;
meta?: Record<string, unknown>;
};Response
type HttpResponse<T = unknown> = {
data: T;
status: number;
headers: Headers;
url: string;
raw: Response;
};Interceptors
type HttpInterceptor = {
onRequest?: (config: HttpRequestConfig) => HttpRequestConfig | Promise<HttpRequestConfig>;
onResponse?: <T>(
response: HttpResponse<T>,
config: HttpRequestConfig,
) => HttpResponse<T> | Promise<HttpResponse<T>>;
onError?: (error: unknown, config: HttpRequestConfig) => unknown | Promise<unknown>;
};Helpers: composeInterceptors, runRequestInterceptors, runResponseInterceptors, runErrorInterceptors.
Retry
type RetryOptions = {
retries?: number;
minDelayMs?: number;
maxDelayMs?: number;
factor?: number;
retryOn?: (context: {
attempt: number;
error: unknown;
config: HttpRequestConfig;
response?: Response;
}) => boolean;
methods?: readonly string[];
};Also exported: computeRetryDelay, resolveRetryOptions, shouldRetryDefault, wait.
Dedupe
type DedupeOptions = {
enabled?: boolean;
methods?: readonly string[];
includeAuthorization?: boolean;
};dedupeKey builds the key; set includeAuthorization when distinct users share a client.
Auth interceptor
import { createAuthInterceptor } from "@sometic/http/auth";
createAuthInterceptor({
auth,
headerName?: string;
scheme?: string;
isUnauthorized?: (response: { status: number }, config) => boolean;
exclude?: (config) => boolean;
getAccessToken?: (auth) => string | null | undefined;
});Errors
| Code | Meaning |
|---|---|
HTTP_NETWORK | fetch failed / offline |
HTTP_TIMEOUT | timeout fired |
HTTP_ABORTED | signal aborted |
HTTP_STATUS | non-OK HTTP status |
HTTP_PARSE | body parse failed |
HTTP_UNAUTHORIZED | auth refresh recovery failed |
HTTP_DISPOSED | client used after dispose() |
import { HTTP_ERROR_CODES, createHttpError } from "@sometic/http";Errors are SometicError instances: stable code, safe to log, no tokens in details.
Patterns
Extend for a service slice
const api = createHttp({ baseUrl: "https://api.example.com" });
const billing = api.extend({
baseUrl: "https://api.example.com/billing",
headers: { "X-Service": "billing" },
});Mock in tests
import { createHttp, createMockFetcher } from "@sometic/http";
const fetcher = createMockFetcher([
{ method: "GET", url: "/me", status: 200, body: { id: "u1" } },
{ method: "GET", url: /\/items\/\d+/, status: 404, body: { error: "missing" }, times: 1 },
]);
const http = createHttp({ baseUrl: "https://example.test", fetcher });Abort and timeout
const ac = new AbortController();
const pending = http.get("/slow", { signal: ac.signal, timeoutMs: 5_000 });
ac.abort();Custom interceptor logging
const logging: HttpInterceptor = {
onRequest: (config) => {
console.info(config.method ?? "GET", config.url);
return config;
},
onError: (error, config) => {
console.warn("http failed", config.url, error);
return error;
},
};Security
| Rule | Detail |
|---|---|
| No secrets in errors | Do not put tokens in createHttpError details |
| Auth peer optional | Import /auth only when wiring Sometic auth |
| Cookies | Use fetcher wrapper with credentials: "include" when same-site cookies apply |
| SSR | Pass an explicit fetcher; do not assume window.fetch at import time |
| CSRF | Cookie sessions still need server CSRF strategy; Bearer headers are not a CSRF fix by themselves |
| Provider SDKs | Stay in auth adapters, never in HTTP |
Browser HTTP is untrusted input to your API. Status codes and bodies can be forged by XSS. Server authorization remains mandatory. See also Auth security boundary.
Edge cases
dispose()rejects subsequent requests withHTTP_DISPOSED.- Dedupe + auth replay: replay uses
meta.authRetriedto avoid infinite loops. responseType: "raw"returns theResponseindatahandling paths as configured; preferjsonfor APIs.- Relative URLs without
baseUrluse the request URL as given; join rules followjoinUrl.
FAQ
Why not Axios?
Fetch is ubiquitous, tree-shake friendly, and enough for interceptors + retry + dedupe. Axios remains fine if you already standardized on it; Sometic HTTP exists for a shared engine across adapters without Axios as a hard dependency.
Does HTTP refresh tokens itself?
No. It asks AuthController.handleUnauthorized(). Auth talks to the provider, which keeps the auth core provider-independent.
Is @sometic/auth required?
Only for @sometic/http/auth. Core createHttp works without auth.
How do I attach non-Bearer headers?
Use a request interceptor or default headers. For Bearer, prefer createAuthInterceptor.