ErrLookup › Background articles › "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained
"API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained
"API error: {status}", "HTTP 401", "failed to fetch ... 403", "Poll failed: 500" — this family covers every error a library raises when an HTTP request comes back with a non-success status: the server, or a proxy in front of it, answered, but with 401, 403, 404, 429, 5xx, or another code outside the range the code accepts. Developers meet it wherever a library wraps HTTP: LLM proxy telemetry, CLI update checkers, OAuth discovery, messaging and sync APIs. The status and body carried in (or left out of) the message usually name the real cause — expired credentials, a wrong endpoint, rate limiting, an upstream outage — and this article maps the causes and fixes shared across the family, drawn from 122 documented records in 27 repositories.
Distilled from 122 documented records across 27 repositories.
Background
Every error in this family is produced by application-level HTTP client code after a request completes: name resolution, the connection, and the TLS handshake all succeeded, and a response arrived, just not with a status the library accepts. That is what separates it from connection and DNS failures, where no response ever comes back. The check itself is one line (response.ok, a status != 200 comparison, raise_for_status()), and the family exists because hundreds of libraries hand-roll the same wrapper that turns a status code plus a response body into an exception, a bail, or a console warning. Even the definition of success varies: most records accept any 2xx, some accept exactly 200 (litellm's PostHog and TogetherAI handlers turn 201/202/204 replies, and even 3xx redirects, into errors), and one deliberately tolerates a specific failure (openhuman accepts 405 on an MCP session delete).
What the caller sees varies more than the mechanism. Some errors embed status and body together (CodeWhale's update fetcher, turso's TursoException, zeroclaw's Lark send gate, claude-mem's worker and sync errors), so the log line itself names the cause. Others carry only the status (zeroclaw's Linq "API error: {status}", cc-switch's and airi's "HTTP {status}"), only the statusText (impeccable's poll and status probes, dawarich's points fetch, where statusText is often an empty string over HTTP/2 and the message comes out blank), or only the raw body (litellm's TogetherAI rerank, OpenMeter, DeepEval). At least one drops the HTTP detail entirely (docuseal's "Failed to start KBA"). When the message omits the cause, the cause usually still exists somewhere: zeroclaw records the real error body as a log attribute on the line above the bail, and most other cases reproduce with a single curl against the same URL.
Library architecture decides when the error fires. Some projects funnel every remote failure through one wrapper (turso's remote client, zeroclaw's shared gate for all Lark calls), so a single message shape covers auth, routing, and capacity alike. Others retry before surfacing: k6's provisioning SDK retries 5xx, and CodeWhale's updater retries 5xx, 408, and 429 in a bounded loop, so an error that reaches the caller means either a 4xx or an exhausted retry budget. Several records sit on telemetry paths (litellm's PostHog, OpenMeter, and DeepEval loggers) where the failure is logged and the main operation still succeeds, which makes these errors data loss rather than downtime. Vendored code adds a final trap: litellm's vendored DeepEval client never returns the httpx response, so its status-handling branch is dead code until patched.
From the caller's side the status works as a triage table: 401/403 point at credentials and scopes, 400/404 at payload and URL, 429/5xx at capacity and server health. Intermediaries blur the table. SSL-inspecting proxies answer 403 or 502 with their own HTML pages, reverse proxies cut deliberately idle long polls into 504s (impeccable), gateways return JSON bodies in a shape client parsers do not expect (docuseal's KBA fallback), and an http URL answered by a 301 to https reads as an error under exactly-200 checks (litellm's PostHog logger). The discipline that holds across all 30 records: recover the status and body, work out which side produced them, and only then choose between fixing configuration, fixing the payload, or backing off and retrying.
Common causes
- Expired, invalid, or wrong-source credentials (401). The most common trigger in the family. The token is wrong, expired, or drawn from the wrong source: k6's scoped test-run token instead of the global API token, a cloud token pointed at a local core in openhuman, a stale Copilot OAuth token in Zed, or rotated Linq, OpenMeter, AirTrail, Together, or sync-hub keys.
- Wrong URL, path, or identifier (404). The endpoint or identifier does not resolve: an OpenMeter base URL that already includes the path the code appends itself, an AirTrail instance too old to expose the route, a typo'd OAuth issuer, a model, release tag, or asset that does not exist, or the version-mismatched worker endpoint in claude-mem.
- Server-side failures and incidents (5xx). Genuine upstream trouble: handler panics, provider incidents, degraded backends. Where the library retries internally (k6's SDK, CodeWhale's updater), the error surfacing means the retry budget ran out, not that one request failed.
- Rate limiting (429, sometimes 403). Bursts against Lark or a sync hub, GitHub's unauthenticated 60 requests/hour limit surfacing as 403 on raw file fetches, secondary rate limits on release APIs, or Turso plan limits. Retryable with backoff, unlike most other 4xx.
- Missing scopes, entitlements, or permissions (403). The credential is valid but insufficient: a LinkedIn token without w_member_social, a Lark app without the im:message scope, a lapsed Copilot subscription, or a Turso token scoped to a different database.
- Intermediaries rewriting or answering for the server. Proxies and gateways answer with their own statuses and bodies: SSL-inspecting proxies return 403/502 HTML, reverse proxies time out deliberately idle long polls into 504s, CDNs and gateways return unexpected statuses and body shapes, and http-to-https redirects become errors when the client neither follows nor accepts them.
- Invalid or oversized request payloads (400/413/422). Malformed notification models, bad Vertex AI parameters, non-ISO date ranges, an unverified from_phone number, or sync batches over the hub's byte cap. The server's body text usually names the offending field.
- Strict exactly-200 success checks. Some handlers accept only status 200, so legitimate 201/202/204 replies and 3xx redirects surface as errors (litellm's PostHog, TogetherAI, and Confident AI paths). Nothing is broken on either side; the success check is too narrow.
What usually fixes it
- Extract the embedded status and body before changing anything. Where the message carries them (CodeWhale, turso, zeroclaw, claude-mem), the body names the real reason. Where it does not, reproduce the call with curl from the same host, or check adjacent logs — zeroclaw records the real error body as an attribute on the log line above the bail.
- Branch on the status class before acting. 401/403 mean credentials, scopes, or entitlements; 400/404 mean payload or URL; 408/429/5xx mean capacity or server health and are the only classes worth retrying, with bounded exponential backoff and jitter. Blindly re-sending a 4xx just burns rate limits.
- Log the numeric status, not statusText. HTTP/2 and h2c proxies leave statusText empty, producing blank messages (dawarich's points fetch). Pair response.status with a truncated body excerpt; proxy error pages can be huge.
- Verify URL and credential configuration against the actual target. Use the exact base URL the code expects — many clients append their own paths (OpenMeter, PostHog region roots) — and draw tokens from the same source as the target: scoped test-run tokens for k6, owner URNs matched to the LinkedIn token, core-matched bearers in openhuman.
- Keep auxiliary traffic non-blocking. Telemetry, logging, and pricing fetches (litellm's PostHog, OpenMeter, DeepEval; models.dev in cc-switch) should degrade gracefully: wrap them, cache the last-known-good payload, and never let their failure break the main operation.
- Design for intermediaries. Raise proxy read timeouts above the long-poll slice (impeccable), disable buffering on SSE paths (openhuman), configure or bypass intercepting proxies for download and telemetry hosts (CodeWhale, litellm), and treat one 401 on a non-authoritative read as a re-auth prompt rather than a session kill.
Go deeper
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
- HTTP status errors: handling 4xx and 5xx responses — how to handle 4xx and 5xx responses properly.
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Documented occurrences
- API error: {status} (zeroclaw-labs/zeroclaw)
- notify test run completed: %w (grafana/k6)
- Response from PostHog API status_code: {response.status_code}, text: {response.text} (BerriAI/litellm)
- Failed to start KBA (docusealco/docuseal)
- Failed to fetch ${item.path}: ${fileResponse.status} (upstash/context7)
- failed to fetch {description} from {url}: HTTP {status} {body} (Hmbown/CodeWhale)
- download failed with HTTP {status}: {body} (Hmbown/CodeWhale)
- Failed to connect to API: {} {} (zed-industries/zed)
- Poll failed: ${res.status} ${res.statusText} (pbakaus/impeccable)
- Error: {response.status_code} {response.text} (BerriAI/litellm)
- e.response.text (BerriAI/litellm)
- Worker API error (${response.status}): ${errorText} (thedotmack/claude-mem)
- HTTP {} while fetching {} — {} (tinyhumansai/openhuman)
- DeepEval logging error: {e.response.text} (BerriAI/litellm)
- response.text (BerriAI/litellm)
- Failed to fetch points: ${response.statusText} (Freika/dawarich)
- res.json().get("error", res.text) (BerriAI/litellm)
- HTTP ${response.status} (farion1231/cc-switch)
- LinkedIn image register failed ({status}): {body_text} (zeroclaw-labs/zeroclaw)
- send failed {context}: status={status}, body={body} (zeroclaw-labs/zeroclaw)
…and 102 more across the corpus — use search.
Honest provenance: generated on 2026-08-23 from AI-assisted analysis of the linked records. See how records are made.