Call API contracts
Call API contracts from imperative code or components with shared transport, caching, retry, and typed failure policies.
Create a reusable API client
Use a client for one origin or policy:
import { createApiClient } from "brix/contracts";
export const catalogClient = createApiClient({
baseUrl: "https://catalog.example.com",
headers: async () => ({ Authorization: `Bearer ${await getAccessToken()}` }),
timeout: 5_000,
retry: { maxAttempts: 3, on: [429, 503], respectRetryAfter: true },
redirect: "error",
});
const result = await catalogClient.execute(getProductApiContract, {
params: { productId: "123" },
headers: { "X-Request-ID": requestId },
});
client.execute returns ResultPromise<T, ApiRequestError> and preserves contract inference. Client defaults can include baseUrl, headers, timeout, retry, cache, redirect, Fetch credentials, response validation, and onFetch. Per-call values override defaults; headers merge case-insensitively with call headers winning. Omitted credentials preserve Fetch's same-origin default.
An async header resolver runs once per execution before retries, even for a cache hit. Its failure becomes an ApiRequestError, not a rejected promise. Timeout covers credential resolution and HTTP work. Use request-scoped server clients for user credentials rather than mutable global current-user state.
Use plain executeApi(contract, options) for one-off calls. Both forms are uncached by default and work anywhere with standard Fetch.
Consume from a component
import { useApi } from "brix/hooks";
function ProfileForm() {
const profileApi = useApi(updateProfileApiContract, {
onSuccess: (profile) => notifications.saved(profile.id),
});
const save = () =>
profileApi.execute({
params: { profileId: "profile-123" },
body: { displayName: "Ada" },
});
if (profileApi.isLoading.value) return <Spinner />;
return (
<button disabled={profileApi.isFetching.value} onClick={save}>
Save
</button>
);
}
useApi forwards the same credentials option. Exceptions or rejected promises from onSuccess
and onError are logged without replacing the execution Result or changing hook state.
The hook exposes data, error, status, isLoading, isFetching, isSuccess, isError, execute, and reset. Mounting does not fetch. isLoading means a first load with no data; isFetching includes background refreshes. Existing data remains visible during refetch.
Every execution returns its own Result. Supersession, reset, and unmount fence stale signal and callback updates. They abort non-cacheable prior work; shared cacheable work continues until its last caller detaches.
Pass a configured client to share transport and cache defaults:
const productApi = useApi(getProductApiContract, {
client: catalogClient,
initialData: serverProduct,
});
initialData starts local state as successful but does not populate shared storage. Preact applications import from brix/hooks; React applications use the matching binding described in React.
Opt into native GET caching
import { createDataCache } from "brix/cache";
export const dataCache = createDataCache();
export const catalogClient = createApiClient({
baseUrl: "https://catalog.example.com",
cache: dataCache.public("30s"),
});
const product = await catalogClient.execute(getProductApiContract, {
params: { productId: "123" },
});
const productApi = useApi(getProductApiContract, { client: catalogClient });
await productApi.execute({ params: { productId: "123" } });
The hook can reuse the imperative read because both share one cache. Only validated JSON GETs participate. Mutations, text or streams, disabled response validation, and cache: false bypass caching.
Public policy permits sharing. Private reads require an explicit nonsecret partition:
cache: dataCache.private(session.cacheScope, "30s");
Partitioning is not authorization. Rotate the scope on login or permission change and invalidate/reset on logout. Cookie-only identity is not visible to Brix. See caching for TTL, stale fallback, DynamoDB, response vetoes, and security boundaries.
Mutations never invalidate reads automatically:
await updateProfileApi.execute({ params: { profileId }, body }).unwrap();
await dataCache.invalidate(getProfileApiContract);
Awaiting invalidation confirms storage removal. Active hooks refresh in the background; refresh failure updates hook state but does not reject successful invalidation.
Configure retries deliberately
const result = executeApi(getProductApiContract, {
params: { productId: "123" },
retry: {
maxAttempts: 3,
on: [429, 500, 502, 503, 504],
respectRetryAfter: true,
},
});
maxAttempts includes the initial request. Delays use full jitter with a 1,000 ms base and 10,000 ms cap by default. Positive Retry-After seconds replace that delay when enabled.
GET, PUT, and DELETE retry network errors and timeouts. POST and PATCH do not unless retryNonIdempotent: true; enable that only when an idempotency key or endpoint semantics make duplicate execution safe. Cache stale fallback is considered only after retries finish.
Handle request failures
const result = await catalogClient.execute(getProductApiContract, {
params: { productId: "123" },
});
if (result.isErr()) {
result.error.kind;
result.error.status;
result.error.data;
result.error.hasStatus(404);
}
Error kinds are http, network, timeout, aborted, decode, and response-validation. Treat them as expected typed outcomes; use .unwrap() only where converting an error into a panic is intentional. See results.
Cross-origin browser clients need an explicit base URL and a matching server CORS allowlist. Set redirect: "error" for server calls where redirects could cross a trust boundary. Test handlers through the route harness and external calls with contract mocks; see testing.