Troubleshooting api-client: the bugs I actually hit, told straight
Generic FAQs are mostly useless because they answer questions nobody actually had. These are the real ones — bugs I hit while building and using this package on actual projects, with the specific symptom, the wrong guess I made first, and what the fix actually was.
”api.myModule.myMethod is not a function”
Symptom: you’ve configured openapi: { mode: 'codegen' }, pointed it at
a spec, and expect methods to just exist based on the spec.
Wrong guess I made: assumed codegen generates callable runtime methods, same as it generates types.
Actual fix: it doesn’t, on purpose (Part 2 covers why). Codegen produces
types and descriptors; you always write the method yourself with
defineModule, even in codegen mode:
get: async (ctx, id: string) =>
(await ctx.request({ method: 'GET', path: '/users/{id}', pathParams: { id } })).data,If the method genuinely doesn’t exist yet, that’s the fix — write it. If you
did write it and still get this error, check you’re calling it on the right
module namespace; a typo like api.user.get instead of api.users.get gives
this exact error and is easy to misread at a glance.
A beforeRequest hook that silently stops adding headers
Symptom: a trace header worked for weeks, then quietly stopped showing up in your backend logs. No error anywhere.
Wrong guess I made: assumed something changed on the backend side, since nothing on the frontend was throwing.
Actual fix: someone (me) edited the hook to add a conditional and forgot
to return config on one branch:
// broken — the early-return branch drops the config entirely
beforeRequest: (config) => {
if (config.skipTrace) return
config.headers['X-Trace-Id'] = crypto.randomUUID()
return config
}Part 10 covers this exact failure mode. beforeRequest and afterResponse
hooks must return the value on every code path — an implicit undefined
return doesn’t throw, it just silently breaks whatever the hook was
supposed to do for that branch. If a hook seems to have stopped working with
zero errors anywhere, check every return path in it first.
Two users briefly saw the same cached data in staging
Symptom: a QA report that seemed almost impossible to reproduce — one tester saw another tester’s cached profile data for a few seconds.
Wrong guess I made: assumed it was a backend session bug, spent an afternoon looking at the wrong layer entirely.
Actual fix: this was the bug that started the whole caching design covered in Part 6 — a hand-rolled cache keyed purely on URL, with no auth fingerprint in the key. Once cache keys included the auth fingerprint, the bug became structurally impossible rather than just less likely. If you’re seeing anything resembling cross-user data bleed and you’re on an older hand-rolled cache instead of this package’s built-in one, check your cache key composition first — it’s almost always the URL-only key.
OAuth2 users getting logged out for no visible reason
Symptom: users occasionally get bounced to the login page mid-session, with no obvious trigger, more often on pages that fire several requests on mount.
Wrong guess I made: assumed the refresh token’s expiry window was too short and just extended it — which “fixed” the symptom but didn’t touch the actual cause.
Actual fix: the concurrent-401 refresh race from Part 5 — several
requests hitting a 401 at once, each triggering its own refresh() call,
racing against a refresh-token-rotation backend that invalidates the old
token the moment it’s used once. The real fix was refresh coalescing (built
into the pipeline), not a longer token lifetime. If you’re on a hand-rolled
OAuth2 layer and seeing this, check whether concurrent 401s each trigger
an independent refresh call — that’s almost always it.
Retry-After seemingly ignored, hammering a rate-limited endpoint
Symptom: an endpoint returning 429s with a Retry-After: 30 header was
still getting hit every 800ms by client-side retries.
Wrong guess I made: assumed Retry-After support was just missing.
Actual fix: it was present, but retryOn didn’t include 429 in this
particular module’s override — the client was retrying on a different
timeline because it wasn’t retrying via the 429-aware path at all, it was
hitting a different failure branch entirely (a proxy timeout that looked
similar in the logs). Lesson here isn’t really about this package
specifically — when a retry behavior looks wrong, check what status code is
actually triggering the retry before assuming the backoff logic itself is
broken. Two different failures can look identical in a request log if you’re
not checking the actual status code.
Generated types say a field exists; the backend doesn’t send it
Symptom: response.data.taxAmount typed as number, actually
undefined at runtime, no TypeScript error anywhere.
Wrong guess I made: assumed the codegen tool had a bug.
Actual fix: it didn’t — this is drift, covered fully in Part 12. The
spec said taxAmount was there when types were generated; the backend
stopped sending it since then, without the spec file being updated. Turning
on validateResponses: true with onValidationFailure: 'warn' surfaced this
immediately in logs, and running drift-check against the live backend
would have caught it proactively before it ever showed up as a
production bug. If TypeScript says a field exists but you’re getting
undefined at runtime with no error, that’s drift, not a codegen bug —
check whether runtime validation is even turned on before assuming the
generated types are wrong.
What sticks from this post
Every one of these had the same shape: a confusing symptom, a plausible but wrong first guess, and a fix that traced back to a mechanism covered somewhere earlier in this series. If you hit something not on this list, the fastest path is usually: which post’s feature does this symptom sound closest to, and re-read that one’s problem/solution framing with your specific bug in mind instead of the generic example.
Next, the last post in the series: the roadmap, and what I’d actually build next if I kept going.
- developerehsan