Full-stack Tempest integration (React ⇄ FastAPI)
You have a backend built with the tempest-fastapi-sdk and a frontend built with this SDK. This page shows how the two speak the same contract with no manual glue: typed errors, request_id correlation, pagination, authentication, and service generation from the OpenAPI spec.
What you'll learn
- How a backend error becomes a typed
TempestApiErroron the frontend (err.code,err.requestId). - How to propagate
X-Request-IDto correlate logs across both sides. - How to honor
Retry-Afteron429/503. - How to consume offset and cursor pagination with ready-made hooks.
- How to wire turn-key JWT auth with
createTempestAuth. - How to generate typed services from
/openapi.json.
The contract both sides share
The tempest-fastapi-sdk standardizes a few shapes. This SDK is aligned to them:
| Concept | Backend shape |
|---|---|
| Error | { "detail": str, "code": "ERROR_CODE", "details": { "request_id": str } } |
Validation error (flattened RequestValidationError) |
{ "detail": str, "field": str, "location": str, "type": str } |
Business error naming a field (ValidationException) |
{ "detail": { "detail": str, "field": str }, "code": "VALIDATION_ERROR", "details": { "field": str } } |
| Offset pagination | { items, total, page, size, pages } · query ?page&size (fastapi-pagination convention) |
| Cursor pagination | { items, next_cursor, has_more, limit } · query ?cursor&limit&order_by&ascending |
| Login | { access_token, token_type: "bearer" } · header Authorization: Bearer <token> |
| Correlation | middleware reads/generates X-Request-ID; echoes it in details.request_id on errors |
| Rate limit | 429 Too Many Requests + Retry-After: <seconds> |
1. Typed errors (TempestApiError)
Every non-2xx response from createApiClient (and uploadWithProgress) becomes a TempestApiError — a real Error carrying the envelope fields.
import { createApiClient, isApiError } from "tempest-react-sdk";
const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL });
try {
await api.post("/api/users", { body: { email } });
} catch (err) {
if (isApiError(err) && err.code === "EMAIL_TAKEN") {
setFieldError("email", err.detail); // human message from the backend
console.warn("failed", err.requestId); // same id as the backend log line
} else {
throw err;
}
}
err looks like this:
interface ApiError {
status: number; // 409
detail: string; // "Email already taken"
code?: string; // "EMAIL_TAKEN" (from the `code` field)
requestId?: string; // "req-abc" (from details.request_id)
retryAfter?: number; // seconds, when Retry-After is present
fields?: Record<string, string>; // { city: "Cidade não encontrada…" }
body?: unknown; // raw body
}
Branch on code, not detail
detail is human text and may be translated/changed. code is stable — use it for logic (if (err.code === "INSUFFICIENT_FUNDS")).
Nothing to configure
This is the default behavior. Any call through createApiClient or the codegen-generated class (which uses ApiClient under the hood) delivers the error in this shape.
The guilty field, which the backend names and the frontend used to throw away
The backend knows which field brought the request down — it says the name. What was missing was the frontend listening: error.fields used to be populated only when the body carried plain FastAPI's list (detail: [{ loc, msg }]), which is exactly the shape an app with its own handler does not send. The name sat inside error.body, and every screen went back to casting and parsing prose.
Now the three singular keys tempest-fastapi-sdk uses land on the same error.fields:
import { createApiClient, describeApiError, isApiError } from "tempest-react-sdk";
const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL });
async function saveAddress(values: { city: string; phone: string }): Promise<void> {
try {
await api.post("/api/addresses", { body: values });
} catch (err) {
if (isApiError(err) && err.fields) {
for (const [field, message] of Object.entries(err.fields)) {
setError(field, { message });
}
}
toast.error(describeApiError(err, "Could not save the address"));
}
}
With the body { "detail": { "detail": "Cidade não encontrada para o estado informado.", "field": "city" }, "code": "VALIDATION_ERROR" }, err.fields is { city: "Cidade não encontrada para o estado informado." } — and setError("city", …) sticks the sentence on the right input.
The precedence order
detail[].loc (FastAPI's list) → detail.field → field → details.field. The list wins because a tempest-fastapi-sdk app with no RequestValidationError handler still answers with FastAPI's default 422. details answers last: it is AppException's generic context bag, and its field may be about something that is no input at all — a rejected sort column, say.
The toast changes sentence, on purpose
describeApiError returns the validation sentence whenever fields is set. A business error that names a field now lands there: the toast swaps "Cidade não encontrada para o estado informado." for "Confira os campos destacados e tente de novo.". The specific sentence did not vanish — it is on the input. If you want the backend's sentence in the toast, use codes: { VALIDATION_ERROR: "…" } (which beats everything) or validation: err.detail at the call site.
2. Correlation with X-Request-ID
createApiClient sends a unique X-Request-ID header per request. The backend's RequestIDMiddleware reuses it (instead of generating another) and echoes it back in the error's details.request_id — so a click on the frontend and the backend log line share the same id.
import { createApiClient, createLogger } from "tempest-react-sdk";
const log = createLogger({ namespace: "app" });
const api = createApiClient({
baseURL: import.meta.env.VITE_API_URL,
// Optional: bring your own generator (e.g. derived from a browser trace).
requestId: () => crypto.randomUUID(),
});
When something fails, log the requestId to match the backend:
catch (err) {
if (isApiError(err)) log.error("request failed", { requestId: err.requestId });
}
Disabling the header
Pass requestId: () => "" to skip the header (e.g. if a proxy already injects yours).
3. Honoring Retry-After
When the backend returns 429/503 with Retry-After, the TempestApiError already carries retryAfter (seconds). The retry() helper honors it automatically, overriding the exponential backoff.
import { retry, isApiError } from "tempest-react-sdk";
const data = await retry(() => api.get("/api/reports"), {
retries: 5,
// only retry on rate-limit / unavailability
shouldRetry: (err) => isApiError(err) && [429, 503].includes(err.status),
});
The hint is capped by maxDelay
If the server asks for 300s but your maxDelay is 10s, the wait stays at 10s. Raise maxDelay to obey long waits. To ignore the header, pass respectRetryAfter: false.
4. Pagination
Offset — usePaginatedQuery
For routes returning { items, total, page, size, pages }:
import { usePaginatedQuery } from "tempest-react-sdk";
function UsersTable() {
const users = usePaginatedQuery<User>({
queryKey: ["users"],
pageSize: 25,
queryFn: (params) => api.get<OffsetPage<User>>("/api/users", { params }),
});
if (users.isLoading) return <Spinner />;
return (
<>
<ul>
{users.items.map((u) => (
<li key={u.id}>{u.email}</li>
))}
</ul>
<button onClick={users.prev} disabled={!users.hasPrev}>
Previous
</button>
<span>
{users.pageNumber} / {users.pageCount} · {users.total} total
</span>
<button onClick={users.next} disabled={!users.hasNext}>
Next
</button>
</>
);
}
The hook owns the page state, sends page + size to your queryFn, keeps the previous page visible while the next loads (keepPreviousData), and derives hasNext/hasPrev/pageCount.
A new filter starts from page 1
Put the applied filters in the queryKey (["users", filters]). When it changes structurally, the page goes back to 1 in the same render — without the in-between request for the old page. Until 0.68.0 the page survived: filtering on page 7 asked for page 7 of a two-page result. To keep a draft apart from what is applied, see useDraftFilters.
size vs page_size
The default follows fastapi-pagination (?size=). If your backend uses page_size, pass sizeParam: "page_size". order_by/ascending are only sent when you set orderBy.
Cursor — useCursorQuery
For infinite-feed routes returning { items, next_cursor, has_more, limit }:
import { useCursorQuery } from "tempest-react-sdk";
function Feed() {
const feed = useCursorQuery<Post>({
queryKey: ["feed"],
limit: 30,
queryFn: (params) => api.get<CursorPage<Post>>("/api/feed", { params }),
});
return (
<>
{feed.items.map((p) => (
<Card key={p.id} post={p} />
))}
{feed.hasNextPage && (
<button onClick={feed.fetchNextPage} disabled={feed.isFetchingNextPage}>
Load more
</button>
)}
</>
);
}
The opaque next_cursor is fed straight back as the next cursor; the loop stops when has_more becomes false.
The cursor is opaque
Never try to interpret next_cursor — it's the backend's base64/JSON. Just return the value you received.
Telling which envelope arrived
An endpoint that answers in both shapes (or generic code that accepts either) has to tell them apart at runtime. The type guards do that, and narrow the type while they're at it:
import { emptyOffsetPage, isCursorPage, isOffsetPage } from "tempest-react-sdk";
const payload: unknown = await api.get("/api/items");
if (isOffsetPage<Item>(payload)) {
console.log(payload.total, payload.pages);
} else if (isCursorPage<Item>(payload)) {
console.log(payload.next_cursor, payload.has_more);
}
isOffsetPage requires items + total + pages; isCursorPage requires
items + has_more + the next_cursor key. For the first render before any
response, emptyOffsetPage<Item>(25) returns a zeroed envelope ready for
placeholderData/initialData — which spares you the items?.map sprinkled
across the screen.
5. Turn-key authentication — createTempestAuth
Instead of wiring createAuthStore + createRefreshQueue + createApiClient by hand, the preset connects everything to the real contract: login { access_token }, Bearer header, and deduplicated 401 → refresh → retry.
import { createTempestAuth } from "tempest-react-sdk";
export const auth = createTempestAuth<User, { email: string; password: string }>({
baseURL: import.meta.env.VITE_API_URL,
loginPath: "/api/auth/login", // default
refreshPath: "/api/auth/refresh", // default
mePath: "/api/auth/me", // optional: fetch the user after login
});
prefix: the root_path in one place
When the service is mounted under root_path="/api" — the norm with tempest-fastapi-sdk — pass prefix: "/api" and write the paths without it. Works for the preset and for any createApiClient; details in HTTP → Base URL and prefix.
export const auth = createTempestAuth<User>({
baseURL: import.meta.env.VITE_API_URL, // https://api.example.com
prefix: "/api",
mePath: "/auth/me",
});
await auth.api.get("/orders"); // https://api.example.com/api/orders
The loginPath/refreshPath defaults keep their written /api and do not double under the prefix — it is applied at most once.
Use it anywhere:
// Login (stores the session and resolves the user)
const user = await auth.login({ email, password });
// Authenticated requests — the Bearer token is injected, and a 401 triggers a
// single refresh and retries the original call
const orders = await auth.api.get("/api/orders");
// Logout (clears the session + stored refresh token, then runs onSessionEnd;
// with logoutPath, also revokes on the server)
await auth.logout();
The store is the same persisted createAuthStore, so it works with <AuthGuard>:
import { Navigate } from "react-router";
import { AuthGuard } from "tempest-react-sdk";
function ProtectedDashboard() {
const isAuthenticated = auth.useAuthStore((state) => state.isAuthenticated);
return (
<AuthGuard isAuthenticated={isAuthenticated} fallback={<Navigate to="/login" />}>
<Dashboard />
</AuthGuard>
);
}
When the session is genuinely dead
A refresh() that resolves is no proof the session is alive — the backend can hand back a token it then refuses (a revoked refresh token, a permission taken away, a race between tabs). The preset clears the session anyway: isAuthenticated flips to false, the stored token goes, and the <AuthGuard> above starts rendering its fallback. The app never sits on a store claiming "authenticated" while every request 401s.
That is enough for most apps, because the guard already navigates. When the expiry can happen outside any guarded subtree, pass redirectTo and the preset navigates itself:
export const auth = createTempestAuth<User>({
baseURL: import.meta.env.VITE_API_URL,
redirectTo: "/login", // hard navigation, only when the guard cannot cover it
});
Prefer the guard over redirectTo
redirectTo uses window.location.assign, which reloads the whole application and discards the router's history. The declarative path (<RouteGuard when={isAuthenticated} redirectTo="/login">) keeps the SPA alive and needs no option at all — the internal logout() already triggers the navigation.
redirectTo does not fire on an explicit auth.logout(): there the caller usually navigates already, and two competing navigations are worse than none.
Clear per-user state on both paths — onSessionEnd
An explicit sign-out and an automatic expiry end the session the same way, and onSessionEnd runs on both — with a session.api that still sends the ended session's token. That is where the push subscription, the persisted cache and the rest of the account's state go. The exact order and the recipe are in Auth → Ending the session.
Retries
The auth.api client takes the same retry option as createApiClient — off by default, and conservative when on (idempotent methods only, network/5xx/429 failures only):
export const auth = createTempestAuth<User>({
baseURL: import.meta.env.VITE_API_URL,
retry: true,
});
Refresh token: body or cookie
By default, if login returns refresh_token, it's stored and sent as { refresh_token } on refresh. If your backend uses an httpOnly cookie, pass withCredentials: true and refresh goes body-less, relying on the cookie.
Where to store the token
The preset persists to localStorage by default (DX). For sensitive apps, prefer the refresh token in an httpOnly cookie (withCredentials: true) — that way JS never touches it.
A cookie takes the token out of JS's reach and puts it in CSRF's: the browser attaches the cookie to a POST /auth/refresh that another site triggered. The pair is csrf: true, which echoes CSRFMiddleware's csrf_token cookie in X-CSRF-Token on every write — login and refresh included:
import { createTempestAuth } from "tempest-react-sdk";
export const auth = createTempestAuth({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
csrf: true,
});
The backend has to mount CSRFMiddleware and issue the cookie on a GET route before the first write. See CSRF.
The cookie also survives logout(), which only clears client state. Pass logoutPath so the backend revokes it, and restore the session at boot with auth.refresh() — an anonymous 401 with no session installed no longer refreshes on its own. Details in Auth → Cookie refresh and server-side logout.
import { createTempestAuth } from "tempest-react-sdk";
export const auth = createTempestAuth({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
csrf: true,
logoutPath: "/api/auth/logout",
});
await auth.refresh().catch(() => undefined);
6. Generating services from OpenAPI
The tempest gen api command reads the FastAPI /openapi.json and generates a typed service.ts per route group (see OpenAPI (codegen) for the full detail). Integration with everything above is automatic:
npx tempest gen api http://localhost:8000/openapi.json --out src/api
- The methods call
ApiClientunder the hood → errors are alreadyTempestApiError. - Routes returning the pagination envelopes generate
OffsetPage<T>/CursorPage<T>return types automatically — ready forusePaginatedQuery/useCursorQuery.
// generated in src/api/users/service.ts
import type { ApiClient, OffsetPage } from "tempest-react-sdk";
export class UsersService {
constructor(private readonly api: ApiClient) {}
/** `GET /api/users` */
async listUsers(params: { page?: number; page_size?: number }): Promise<OffsetPage<User>> {
return this.api.get<OffsetPage<User>>("/api/users", { params });
}
}
Wire the generated service to the authenticated client:
import { UsersService } from "./api/users";
const usersService = new UsersService(auth.api);
const users = usePaginatedQuery<User>({
queryKey: ["users"],
queryFn: (params) => usersService.listUsers(params),
});
The full loop
backend (tempest-fastapi-sdk)
│ exposes /openapi.json + contracts (error, pagination, JWT, X-Request-ID)
▼
tempest gen api ──► src/api/<group>/{schemas,types,service}.ts
│
▼
createTempestAuth ──► auth.api (Bearer + refresh + TempestApiError errors)
│
▼
usePaginatedQuery / useCursorQuery ──► typed UI, request_id correlation
Paired features that already exist on both sides
| Frontend (this SDK) | Backend (tempest-fastapi-sdk) |
|---|---|
createEventStream / useEventStream |
SSE (Real-time) |
WebPushClient / usePushSubscription |
Web Push (VAPID) |
validateCPF / validateCNPJ / useViaCEP |
Brazilian Helpers |
createApiClient (retry, idempotency) |
HTTP Client (retry/backoff) |
Recap
- Backend errors arrive as
TempestApiErrorwithcode+requestId— useisApiErrorand branch oncode. ✅ X-Request-IDis sent automatically and correlates frontend ↔ backend. ✅Retry-Afteris honored byretry()on429/503. ✅usePaginatedQuery(offset) anduseCursorQuery(cursor) consume the backend envelopes with no manual mapping. ✅createTempestAuthdelivers login + Bearer + refresh/retry in one call. ✅tempest gen apigenerates typed services that already speak all these contracts. ✅