{"slug": "the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint", "title": "The Health Check Said 200 While the Model Said 401: Debugging a Silent Endpoint Fallback", "summary": "A developer documented a recurring debugging pattern in which AI coding assistants fail with a generic \"Request failed\" message even though the endpoint's /health route returns 200, because the health check answers reachability while the chat completions route returns 401 for an invalid key on that path. The writeup, prepared as part of MonkeyCode's product outreach, proposes a three-rung curl ladder that separates DNS/TLS reachability, authorization and path errors, and streaming time-to-first-byte, so each failure mode can be isolated outside the browser.", "body_md": "I have watched the same failure three times this month, and it never looks like the same failure at first glance.\n\nSomeone wires an AI coding assistant to an endpoint they copied out of a README, sends one prompt, and gets a spinner that turns for thirty seconds before collapsing into the words \"Request failed.\" The console is clean, the network tab has a response worth reading, and the UI tells them none of it.\n\nIs it the key? The URL? The model id? The proxy? The stream flag? The browser? Six suspects, one vague sentence on screen, and no way to bisect them from inside the app.\n\nThe walkthrough below is a reconstruction of that class of bug, written so you can execute every step against your own endpoint. It is not a benchmark and it is not a product review; it is a debugging retrospective with a runnable harness at the end.\n\nThe number of AI endpoints a frontend developer is expected to juggle went up, and the amount of configuration discipline per endpoint went down. A single app now resolves a base URL from a UI field, a shell variable, a `.env` file, and a hard-coded default, and whichever one wins is usually decided by accident.\n\nI have been testing this with MonkeyCode, an open-source AI coding project whose operator states that you can use free model access (up to 10 million tokens) and a free server option. Those two paths hand you two different endpoints, which is exactly the condition that produces the bug below.\n\nDisclosure: This article was prepared as part of MonkeyCode's product outreach.\n\nTo be clear about scope: the free tier is operator-supplied availability, not a guarantee, and I am not quoting any quota, model list, or uptime number I could not re-verify on the project's own page at publication time. Re-check the current terms before you build a workflow on top of any free tier.\n\nBefore touching code, write down what each layer reports. In the incident I keep seeing, the layers disagree in a very specific way:\n\n| Layer | What it reports | What it means | \n|---|---|---|\n| `GET /health` | `200 OK` in 40 ms | The gateway process is alive | \n| `POST /v1/chat/completions` | `401` with a JSON body | Your key is not valid *for this route* | \n| App fetch wrapper | `ok === false` | Truth | \n| App UI | \"Request failed.\" | Useless | \n| User | Reloads the page and tries again | Wasted afternoon | \n\nThe health endpoint answered a different question than the one the user was asking. That is the whole bug, and everything below is mechanical: prove it, isolate it, then make the UI incapable of lying that way again.\n\nOpen a terminal and send the exact request the app sends. A three-rung curl ladder separates reachability, authorization, and first-byte timing, which are three completely different failures that the browser collapses into one.\n\n```\nexport MC_BASE=\"https://your-endpoint.example.com\"\nexport MC_KEY=\"sk-...\"        # from a local .env, never committed\nexport MC_MODEL=\"your-model-id\"\n\nBASE=\"${MC_BASE%/}\"          # strip one trailing slash, not all of them\n\n# Rung 1: DNS, TLS, and whether the host answers at all\ncurl -sS -o /dev/null -w 'health: %{http_code} in %{time_total}s\\n' \"$BASE/health\"\n\n# Rung 2: auth and path, non-streaming, one token of output\ncurl -sS -D /tmp/headers.txt -o /tmp/body.txt \\\n  -X POST \"$BASE/v1/chat/completions\" \\\n  -H \"Authorization: Bearer $MC_KEY\" \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"model\\\":\\\"$MC_MODEL\\\",\\\"messages\\\":[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"ping\\\"}],\\\"max_tokens\\\":1}\"\nhead -n 1 /tmp/headers.txt; head -c 300 /tmp/body.txt; echo\n\n# Rung 3: streaming on, measure time to first byte\ncurl -sS -N -o /dev/null -w 'ttfb: %{time_starttransfer}s total: %{time_total}s\\n' \\\n  -X POST \"$BASE/v1/chat/completions\" \\\n  -H \"Authorization: Bearer $MC_KEY\" -H 'Content-Type: application/json' \\\n  -d \"{\\\"model\\\":\\\"$MC_MODEL\\\",\\\"messages\\\":[{\\\"role\\\":\\\"user\\\",\\\"content\\\":\\\"ping\\\"}],\\\"stream\\\":true,\\\"max_tokens\\\":1}\"\n```\n\nIn the case I keep seeing, rung 1 returns `200` and rung 2 returns `401`. That single pair of lines ends the guessing phase, and it costs you about eight seconds.\n\n`-N` disables curl's own buffering, so a fast `ttfb` with a slow `total` means streaming genuinely works end to end. A slow `ttfb` means something between you and the model is holding bytes back, and no amount of client-side work will fix it. Keep those two conclusions separate, because mixing them is how people end up rewriting a perfectly good render loop.\n\nThe 401 was not a credential problem in the end. The app had read `OPENAI_BASE_URL` from a shell profile it inherited before the user typed anything into the settings panel, and the explicit value lost the precedence fight silently.\n\nFix the resolution order first, then log it exactly once at startup:\n\n``` js\n// config.js — loud, ordered, and logged once\nconst sources = [\n  ['ui',    process.env.MC_BASE_URL],\n  ['shell', process.env.OPENAI_BASE_URL],\n  ['file',  readConfigFile()?.baseUrl],\n];\n\nconst resolved = sources.find(([, value]) => typeof value === 'string' && value.length > 0);\nif (!resolved) {\n  throw new Error(`No base URL configured. Checked: ${sources.map(([name]) => name).join(', ')}`);\n}\n\nconsole.info(`[config] base=${resolved[1]} source=${resolved[0]}`);\n```\n\nA silent fallback is worse than a missing value, because a missing value stops you at boot and a fallback follows you into production. If the user set something explicitly, a lower-precedence source should never override it, not even when the explicit value looks malformed.\n\n`/v1` trap\nMost endpoints accept `https://host/v1/chat/completions`, and a large number of copy-pasted configs store `https://host/v1` as the base. Join those two naively and you request `https://host/v1/v1/chat/completions`, which typically returns a `404` that reads like a model problem and is not one.\n\n``` js\nconst joinUrl = (base, path) => {\n  const b = base.replace(/\\/+$/, '');\n  const p = path.replace(/^\\/+/, '');\n  // if the base already ends in /v1, drop the duplicate segment from the path\n  const deduped = b.endsWith('/v1') && p.startsWith('v1/') ? p.slice(3) : p;\n  return `${b}/${deduped}`;\n};\n```\n\nRoute every request through one joiner. One function is testable; twelve template literals scattered across a codebase are not.\n\nA boolean `fetch('/health').then(r => r.ok)` answers \"is a process listening,\" which nobody has ever needed to know. What you want is a probe that performs a minimal real completion and classifies the failure into something the UI can say out loud.\n\n``` js\nconst STATUS_REASONS = {\n  401: 'auth', 403: 'auth',\n  404: 'path',\n  429: 'rate',\n  500: 'server', 502: 'server', 503: 'server', 504: 'server',\n};\n\nexport async function probe({ baseUrl, apiKey, model, timeoutMs = 8000 }) {\n  const ac = new AbortController();\n  const timer = setTimeout(() => ac.abort(), timeoutMs);\n  const started = performance.now();\n\n  try {\n    const res = await fetch(`${baseUrl.replace(/\\/+$/, '')}/v1/chat/completions`, {\n      method: 'POST',\n      signal: ac.signal,\n      headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },\n      body: JSON.stringify({\n        model,\n        messages: [{ role: 'user', content: 'ping' }],\n        max_tokens: 1,\n        stream: false,\n      }),\n    });\n\n    const ms = Math.round(performance.now() - started);\n    const text = await res.text();\n\n    if (!res.ok) {\n      return { ok: false, reason: STATUS_REASONS[res.status] ?? 'server', status: res.status, ms, detail: text.slice(0, 200) };\n    }\n\n    let json;\n    try { json = JSON.parse(text); }\n    catch { return { ok: false, reason: 'server', status: res.status, ms, detail: 'non-JSON body' }; }\n\n    const content = json?.choices?.[0]?.message?.content;\n    if (typeof content !== 'string' || content.trim() === '') {\n      return { ok: false, reason: 'empty', status: res.status, ms };\n    }\n\n    return { ok: true, status: res.status, ms };\n  } catch (err) {\n    const ms = Math.round(performance.now() - started);\n    return { ok: false, reason: ac.signal.aborted ? 'timeout' : 'network', status: 0, ms, detail: String(err) };\n  } finally {\n    clearTimeout(timer);\n  }\n}\n```\n\nTwo notes on honesty rather than cleverness. A probe spends tokens, so keep `max_tokens` at 1 and run it on session start or an explicit \"Test connection\" click, never per keystroke. If your provider enforces a larger floor than 1, raise it to the smallest accepted value and say so in a code comment, because the next maintainer will assume 1 works everywhere.\n\nThe `empty` branch is the one that saves the most time in practice. A proxy that rewrites your model alias happily returns `200` with `choices: []`, and an app that only checks `res.ok` renders a blank bubble with no error at all.\n\nOnce the probe can classify, the client should not be allowed to improvise. This table is the whole contract between transport and screen, and I keep a copy of it in the repository next to the fetch wrapper:\n\n| Probe result | Visible UI | Screen reader | Retry policy | \n|---|---|---|---|\n| `ok` | normal composer | silent | n/a | \n| `auth` | \"This key was rejected by `host` \" + link to settings | assertive alert once | blocked until edited | \n| `path` | \"Endpoint path looks wrong (404)\" | assertive alert once | blocked | \n| `rate` | \"Rate limited — try again shortly\" | polite status once | enable after `Retry-After` if present | \n| `server` | \"Endpoint error, one automatic retry\" | polite status once | one automatic retry, then manual | \n| `timeout` | \"No response in 8s\" + Cancel | polite status once | manual | \n| `empty` | \"The model returned an empty reply\" | assertive alert once | manual | \n\nThree rules matter more than the table itself, because a table is easy to write and easy to violate in a status component.\n\n`alert` for states the user must act on. Auth and path failures block everything, so they earn assertiveness; slow networks do not.\nTypes are the cheapest way to stop the UI from inventing a seventh state nobody designed. Four phases cover the whole lifecycle, and the blocked phase carries the reason as a union rather than a string.\n\n```\ntype BlockReason = 'auth' | 'path' | 'rate' | 'server' | 'timeout' | 'empty' | 'network';\n\ntype Session =\n  | { phase: 'idle' }\n  | { phase: 'probing' }\n  | { phase: 'ready'; endpoint: string; model: string }\n  | { phase: 'blocked'; reason: BlockReason; host: string; retryAfterMs?: number };\n\nconst timeoutBudget = { connectMs: 3000, firstTokenMs: 8000, totalMs: 60000 };\n```\n\nIn practice I use the same object to drive both the visual state and the announcement copy, so a new reason cannot ship without a message. If you add a variant and the build still passes, you forgot an exhaustive switch somewhere, and TypeScript will tell you where.\n\nThe timeout budget is deliberately three numbers instead of one. A connect timeout tells the user their network is the problem, a first-token timeout points at buffering or model queueing, and a total timeout points at a runaway generation you should let them cancel rather than silently truncate.\n\nReproducing a bug is half the job; proving you fixed the whole class is the other half. These six rows are the ones that have caught real regressions for me:\n\n| Scenario | Setup | Expected probe | Expected UI | \n|---|---|---|---|\n| Direct connection | no proxy | `ok` | composer enabled | \n| Corporate proxy | `HTTPS_PROXY` set, TLS intercepted | `network` or`server` | error names the host, no infinite spinner | \n| Wrong key | key from another project | `auth` | alert + settings link, retry blocked | \n| Doubled path | base ends in `/v1` | `path` | alert, no auto-retry loop | \n| Idle free endpoint | first request after a quiet period | slow `ok` | one polite status, then success | \n| Empty rewrite | proxy strips `choices` | `empty` | explicit empty reply message | \n\nRun the matrix with the network panel open and once with a screen reader on. The two passes catch different bugs, and the screen-reader pass is the one that surfaces double announcements you will never notice visually.\n\nThis workflow is a debugging aid, not an architecture. A single probe proves one path at one moment, and it will happily pass while a streaming request through the same endpoint still stalls on a buffering proxy.\n\nPlease do not build on it if any of these apply:\n\nIf you want a second endpoint to run the curl ladder against, the free model access and free server option in MonkeyCode are a low-friction way to get one, and having two configs is precisely what makes precedence bugs visible. Run the three rungs against both, and tell me which rung fails first.\n\nWhen you report a reproduction, include your Node version, curl version, whether a proxy sits in the path, and the exact transition that broke — probe to first token, or first token to done. That detail is what turns \"it doesn't work\" into a bug somebody can actually close.", "url": "https://wpnews.pro/news/the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint", "canonical_source": "https://dev.to/babycat/the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint-fallback-2cg3", "published_at": "2026-10-20 09:12:27+00:00", "updated_at": "2026-10-20 09:19:08.465304+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-agents"], "entities": ["MonkeyCode"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint", "markdown": "https://wpnews.pro/news/the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint.md", "text": "https://wpnews.pro/news/the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint.txt", "jsonld": "https://wpnews.pro/news/the-health-check-said-200-while-the-model-said-401-debugging-a-silent-endpoint.jsonld"}}