-
Notifications
You must be signed in to change notification settings - Fork 0
Troubleshooting
A segment was null, undefined or "" — almost always an unresolved ID.
api.get("/users", { addToUrl: [userId] }); // userId is undefinedGuard, or use a template:
if (!userId) return;
api.get("/users/{id}", { addTemplateToUrl: { id: userId } });0 is explicitly allowed — it's a valid ID.
Streams aren't structured-cloneable. Three fixes, best first:
await api.post("/upload", file); // send a Blob/File instead
const streamApi = createClient({ baseUrl, worker: false }); // a dedicated client
const api = createClient({ baseUrl, worker: false }); // disable the workerSee Uploads and Binary Bodies.
A 401 hit mid stream-upload. The token has been refreshed, so retrying with a fresh stream succeeds. Prevent it with uploadSkewMs:
await api.post("/upload", makeStream(), { uploadSkewMs: 600_000, timeout: 0 });The per-attempt budget elapsed (default 30 s).
await api.get("/report", { timeout: 120_000 });
await api.post("/upload", form, { timeout: 0 }); // no limitRemember the budget is per attempt — a 401 + retry can take twice as long in wall-clock terms.
The request was canceled: your AbortSignal fired, cancel() matched it, takeLatest superseded it, or destroy() was called. Usually intentional. Filter it out with the flag:
catch (e) {
if (e instanceof ApiError && e.canceled) return;
throw e;
}e.cancelReason tells you which cancel() call did it, when a reason was given. onError is never fired for these.
Cancellation is opt-in, and only tracked requests can be canceled. Check, in order:
-
The client never enabled it.
createClient({ cancel: true }), or use acancelScope, which enables itself. -
It's a write. Only
GETis tracked by default. Passcancelable: true, orcancel: { methods: "all" }. -
The request isn't in flight yet.
awaiting a slowawaitbefore firing? Checkapi.pending()to see what's actually tracked. -
The pattern doesn't match. Patterns are segment-aware, so
/api/productsdoes not match/api/products-archive. Printapi.pending()and compare againstpath, which is what patterns match — no origin, no query.
console.log(api.pending().map((r) => `${r.method} ${r.path}`));throwOnCancel follows throwError, so a throwing client throws on cancel too. Split them:
createClient({ cancel: { throwOnCancel: false } }); // errors throw, cancels resolveKeep the default with TanStack Query and SWR — they only recognise failure through a rejected promise.
That is exactly why writes are not cancelable by default. Once the request is on the wire the server may commit it, and the client will never learn the outcome. If you opt a write in with cancelable: true, make the endpoint idempotent or reconcile on the next load.
The request never reached the server. Causes, in order of likelihood:
- CORS — check the browser console for the real reason; the JS error is deliberately vague.
-
Offline —
navigator.onLine. -
Bad
baseUrl— logapiconfig, or watch the Network tab. -
Mixed content — an
http:API from anhttps:page. - DNS / connection refused — the server isn't up.
const res = await api.get("/health", { throwError: false, skipAuth: true });
console.log(res.statusCode, res.message, res.error);Symptom: a GET /users shows up in the Network tab as https://my-app.com/users and returns your index.html (or a 404 from your own router) rather than hitting the API.
That means no baseUrl was found, so the client used the page's own origin (its default in a browser). Check what detection found:
import { detectBaseUrl } from "@mrzr/api-client";
console.log(JSON.stringify(detectBaseUrl())); // "" means nothing was foundOn the server there is no page to fall back to, so the same mistake fails with No base URL for "/users"…, naming the option and the variables to set.
Common causes:
-
The variable isn't exposed to the client. Browser bundlers only inline names with the right prefix.
API_URLworks on the server but is stripped from the browser bundle — useNEXT_PUBLIC_API_URL,VITE_API_URL,NUXT_PUBLIC_API_URLorPUBLIC_API_URL. -
You used a custom variable name. Auto-detection only knows the names listed in Client Options. Pass it yourself:
createClient({ baseUrl: import.meta.env.MY_API }). -
Config is loaded at runtime, after the bundle was built. Set
globalThis.__API_BASE_URL__before creating the client, or passbaseUrlexplicitly. -
The dev server wasn't restarted after editing
.env. Most bundlers only read it at startup.
Being explicit always beats detection:
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL });Fixed in v1.0.2. Earlier versions only read env vars through a dynamic
process.env[key]index, which bundlers cannot inline and browsers don't have — so auto-detection always returned""on the client, and in worker mode regardless of environment.
A request was made before the worker signalled ready, or after destroy(). The client awaits readiness internally, so this almost always means the client was destroyed and then reused. Create a new one.
Pending promises reject with this when destroy() is called. Make sure you aren't destroying a shared module-level client from a component unmount.
Almost always a stale or missing build rather than the bug itself.
dist/ is git-ignored and generated. Packing or linking without building ships
whatever was there last — or nothing at all. Check what you actually installed:
grep -c storageResult node_modules/@mrzr/api-client/dist/index.js
# 0 → stale build predating the storage fixThe build runs from the prepare hook, so npm link, npm pack and folder
installs all rebuild automatically. But prepare runs once, at link time —
if you're linked and editing the library, keep npm run dev running or the
consumer keeps seeing the build from when you linked.
Then clear stale copies:
rm -rf node_modules/.vite .next/cache # bundler caches hold the old moduleA Blob worker's base URL is blob:http://your-origin/uuid, and relative paths
cannot resolve against it — unlike on the main thread, where they resolve
against the page origin.
Fixed in v1.0.2: the host now passes the page origin to the worker, so relative URLs behave identically in both modes. On earlier versions the workaround was:
baseUrl: import.meta.client ? window.location.origin : config.public.apiUrl,which is no longer needed — and was a footgun during SSR, where window is
undefined.
With authMode: "cookie" the tokens are httpOnly, so JS cannot read them. The
client only learns about a session from the server's responses.
-
Right after login it should now be
true— a 2xx fromlogin()marks the session active. If it isn't, upgrade: before v1.0.2isAuthenticatedrequired a readable access token, so cookie mode reportedfalseforever. -
On a fresh page load it starts
falseby design. The cookie is there, but invisible. Ask the server once on startup:const state = await api.restoreSession("/api/auth/me");
-
If it flips to
falseunexpectedly, the refresh endpoint rejected the session (401/403). That is the server rejecting the cookie — check that it is actually being sent (credentials: "include", and for a cross-origin API,Access-Control-Allow-Credentials: truewith an explicit origin).
First, check whether the tokens are being written at all — look for apiclient.tokens in DevTools → Application → Local Storage (or Cookies).
If nothing is stored:
-
storagedefaults to"memory", which is designed not to survive a reload. Set it explicitly:createClient({ storage: "local" });
-
On v1.0.1 and earlier this was a bug: in worker mode (the default)
"local","session"and"cookie"all silently discarded every write, because the worker built the adapter in a scope with nolocalStorage. Upgrade to v1.0.2+, where the main thread owns the adapter.
If the tokens are stored but you're still logged out:
- Your server may not return them in a shape the default extractor recognises. Supply
extractTokens— but note that opts out of worker mode. - Check
storageKey: two clients with different keys don't share a session. - In
authMode: "cookie", nothing is stored locally by design; the browser holds httpOnly cookies and the session depends on those, not onstorage.
Confirm the round trip:
await api.login({ email, password });
console.log(await api.getAuthState()); // isAuthenticated: true
// reload, then:
console.log(await api.getAuthState()); // should still be truecreateClient runs on both server and client in Nuxt. On the server there is no localStorage, so create the client in a client-side plugin — or guard on import.meta.client — and keep one shared instance rather than one per component:
// plugins/api.client.ts
export default defineNuxtPlugin(() => {
const api = createClient({
baseUrl: useRuntimeConfig().public.apiUrl,
storage: "local",
});
return { provide: { api } };
});Creating a fresh client on every render also loses the session, because each one hydrates independently.
For SSR that needs the token on the server too, use authMode: "cookie" with httpOnly cookies, or the "cookie" adapter so the server can read the record.
Check, in order:
-
multiTab: falsein your options. -
storage: "memory"(the default) — tabs share no storage, so a login in tab A can't be adopted by tab B. Logout still propagates. Use"local"or"cookie"for full sync. - Different origins or different
storageKeyvalues — neither shares a channel. -
v1.0.1 and earlier: cross-tab sync was silently dead in worker mode (the default), because the worker scope has no
windowand was misdetected as SSR. Upgrade to v1.0.2+.
Check, in order:
-
worker: falsein your options. -
extractTokensorbuildRefreshBodypassed as functions — they can't cross the boundary. The declarativeTokenFieldMap/RefreshBodyConfigforms keep worker mode. - CSP blocking
blob:— addworker-src 'self' blob:. Browsers often report the block only after the client exists, soisWorkercan switch fromtruetofalseonce the worker fails to start (or after 10 seconds without starting); check it after the first request. - SSR — expected;
windowis undefined.
console.log({
isWorker: api.isWorker,
hasWorkerAPI: typeof Worker !== "undefined",
hasBlobURL: typeof URL?.createObjectURL === "function",
});storage: "memory" is the default and is intentionally non-persistent. Options:
createClient({ storage: "local" }); // persists, but readable by page scriptsBetter: keep "memory" and mint a fresh access token on boot from a long-lived httpOnly refresh cookie:
await api.refresh();See Security Model.
Your token looks perpetually near-expiry. Likely causes:
-
expiresAtreturned in seconds where ms is expected. The default extractor disambiguates values below1e12, but a customextractTokensmust do so itself. - Server clock skew larger than
refreshSkewMs. - The refresh endpoint returns an already-expired token.
console.log(new Date((await api.getAuthState()).expiresAt ?? 0).toISOString());-
Opaque token — no readable JWT
exp, so the client can't predict expiry. Expected; the 401 path still works. SupplyexpiresAtexplicitly insetTokensif you know it. -
refreshSkewMs: 0disables the proactive path. - No refresh token stored — check
login()actually extracted one. - Cookie mode — proactive refresh is skipped by design.
extractTokens returned null for your response shape. Log the raw body:
const res = await api.post("/auth/login", creds, { skipAuth: true, fullData: true });
console.log(JSON.stringify(res.data, null, 2));Then write a matching extractor:
createClient({
extractTokens: (b: any) => ({ accessToken: b?.result?.jwt, refreshToken: b?.result?.renew }),
worker: false, // required: functions can't cross the worker boundary
});Your refresh endpoint is itself returning 401, and the client is retrying it. Mark your auth calls:
await api.post("/auth/verify", body, { refreshTokenCheck: false, skipAuth: true });The built-in refresh() never recurses (it uses raw fetch), so a loop means a manual call is involved.
Unwrapping only happens when the body has a top-level data key. If your server returns { payload: … }, unwrap yourself:
await api.get("/users", { afterFunc: (d: any) => d.payload });Conversely, if you want the wrapper, pass fullData: true.
Legitimate cases: a 204, an empty body, or a non-JSON response that failed to parse. Check res.headers?.["content-type"] and res.statusCode.
The client reads errors from the top level of the response body:
{ "message": "Validation failed", "errors": { "email": ["taken"] } }Nested elsewhere? Map them:
catch (e) {
if (e instanceof ApiError) {
const errors = (e.data as any)?.detail?.fields ?? e.errors;
}
}-
multiTab: falseset. - No
BroadcastChannel(old Safari) — the client degrades to single-tab. - Different
storageKeys across your clients means different channels — that may be intentional. - Different origins never share a channel;
localhost:3000andlocalhost:3001are separate.
Watch the traffic:
new BroadcastChannel("apiclient.auth").onmessage = (e) => console.log(e.data);BroadcastChannel is a ref'd handle. The client never opens one on the server, but if you constructed a client in a browser-like test environment, call destroy():
afterEach(() => api.destroy());Also pass worker: false, multiTab: false in tests.
Checklist:
- Is the method unsafe?
GETnever gets the header, by design. - Is
xsrfCookieNameorgetCsrfTokenconfigured? - Is the cookie readable (i.e. not httpOnly)?
- In worker mode, does
getCsrfTokenwork on the main thread? - Did you set the header explicitly per-request? Yours wins.
console.log(document.cookie); // is the cookie there?
createClient({ getCsrfToken: () => { const t = read(); console.log("csrf:", t); return t; } });Requires all of:
createClient({ authMode: "cookie" }); // sets credentials: "include"Set-Cookie: session=…; HttpOnly; Secure; SameSite=None
Access-Control-Allow-Origin: https://app.example.com (never *)
Access-Control-Allow-Credentials: true
SameSite=None requires Secure, which requires HTTPS — including in local development.
You're on an old version, or you converted the body yourself. Current versions detect typed arrays via ArrayBuffer.isView(). Verify:
await api.post("/upload", new Uint8Array([72, 105]), {
headers: { "Content-Type": "application/octet-stream" },
});Precedence: per-request header → non-JSON client header → the body's own type. A client-wide application/json never sticks to FormData, Blob, typed arrays or URLSearchParams, and FormData always gets the runtime's multipart boundary.
If a different client-wide type (say application/xml) is sticking, it counts as deliberate. Set the right type on that request, or drop the default and set it only where you need it:
await api.post("/upload", pngBlob, { headers: { "Content-Type": "image/png" } });const api = createClient({
baseUrl,
onLog: (e) => console.log("[api]", e),
onError: (r) => console.error("[api error]", r),
onAuthStateChanged: (s) => console.log("[auth]", s),
onAuthFailure: () => console.warn("[auth] failed"),
});
console.log("worker:", api.isWorker);
console.log("state:", await api.getAuthState());
await api.get("/health", { log: true, skipAuth: true, throwError: false });Then check the Network tab for the actual request URL, headers and response — the client is a thin layer over fetch, so the truth is always visible there.
Open an issue with:
- Package version, runtime and framework
- Your
createClientoptions (with secrets removed) - The failing call and the full
IRes/ApiError api.isWorker- A minimal reproduction, ideally against
https://httpbin.org
Next: FAQ
Getting started
Requests
Authentication
Advanced
- Web Worker Isolation
- Multi-Tab Sync
- WebSockets and Socket.io
- Plugins
- Logging and Observability
- Security Model
Reference
Guides