DEV Community

Emery Huang
Emery Huang

Posted on

The 200 That Lied: Debugging a Cold Start That Returned Nothing

The most dangerous API failure is the one that looks like success: HTTP 200, an empty body, no stack trace. The bug only appears after your server has been idle for a few minutes. I reproduced that exact failure mode so we can dissect it together, because the debugging technique matters more than the service involved. What do you do when the API returns exactly what you asked for — except the part that matters?

The symptom that made no sense

The setup was simple: a small webhook takes a GitHub issue, sends it to a model endpoint, and returns a one-paragraph summary. Most of the time it worked fine, but every so often it returned a perfect 200 OK with absolutely nothing in the body. No error, no partial text, no timeout message — just silence dressed up as success.

The pattern was the clue I almost missed: failures clustered after periods of inactivity. If I hammered the endpoint with requests, it behaved. If I waited twenty minutes and tried once, the empty 200 showed up. That timing asymmetry is the kind of detail that separates a lucky guess from a real diagnosis.

Step one: measure what you can't see

I started with curl timing instead of guessing, because a symptom without a measurement is just a story. The -w flag gives you the exact numbers:

curl -sS -o /dev/null -w "code=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
  https://your-endpoint.example/summarize \
  -d '{"issue": "The login flow crashes when the token expires mid-request"}'
Enter fullscreen mode Exit fullscreen mode

Run that once after idle and once in a loop, and the difference jumps out. In my repro, the cold request's time-to-first-byte was an order of magnitude higher than the warm ones, which were comfortably under a second. The empty 200 wasn't a model problem at all — it was a wake-up problem wearing a success costume.

Step two: read the boundary, not the blame

The next temptation is to blame the model, and that's exactly the wrong move. I logged at the boundary instead: request received, upstream call started, upstream call finished, response committed. The logs told a clearer story than any model card could:

  1. The server received the request and started the upstream model call.
  2. The upstream call took longer than my client's 30-second timeout.
  3. My client gave up and closed the connection, but the server had already committed to a 200 response.
  4. The empty body was the server's way of saying "I have nothing to send you," which is not the same as an error.

That last point is the real lesson: a committed response cannot be un-committed, so the server sends what it has. Empty means "something upstream failed after I promised you a response," and you have to treat it as a bug, not a quirk.

Step three: the root cause was a cold start

The root cause was boring and honest: the free server sleeps after a period of idle, and the first request pays the wake-up cost. Add a cold start to a slow model call, and the combined latency blows past any reasonable client timeout. The model was fine, the server was fine, the protocol was fine — only my assumption that a 200 always carries a body was broken.

This is where a service like MonkeyCode becomes relevant, because the same behavior shows up on any free or serverless endpoint. Free model access and a free server option are great for experiments, but they come with a sleep cycle you have to design around. (Disclosure: This article was prepared as part of MonkeyCode's product outreach.)

The fix: validate, retry, and warm up

The fix is three small habits that cost almost nothing and prevent a whole class of silent failures.

Validate the response as if the API is lying to you

import json

def parse_summary(raw: bytes) -> dict:
    if not raw.strip():
        raise ValueError("empty 200: server returned no body")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ValueError(f"partial body: {exc}") from exc
    if not data.get("summary"):
        raise ValueError("missing summary field")
    return data
Enter fullscreen mode Exit fullscreen mode

Retry with backoff, but only for idempotent calls

import time

def call_with_retry(client, payload, attempts=3):
    for i in range(attempts):
        try:
            resp = client.post("/summarize", json=payload, timeout=60)
            return parse_summary(resp.content)
        except ValueError as exc:
            if i == attempts - 1:
                raise
            wait = 2 ** i
            print(f"attempt {i + 1} failed: {exc}; retrying in {wait}s")
            time.sleep(wait)
Enter fullscreen mode Exit fullscreen mode

Warm the server before real traffic arrives

A health check is cheaper than a failed request, so ping the endpoint before you need it:

curl -sS https://your-endpoint.example/health > /dev/null
Enter fullscreen mode Exit fullscreen mode

The reusable checklist

Next time you see a silent failure, run this table instead of guessing:

Symptom Likely cause Check
Empty 200 after idle Server cold start Compare TTFB cold vs warm
Partial or truncated JSON Response cut off mid-stream Log body length and validate JSON
Timeout at exactly N seconds Client timeout too short Raise timeout above measured TTFB
Works when hammered, fails when quiet Sleep/wake cycle Add a warm-up ping or keep-alive

Who should not use this approach

If your workload is latency-critical or bursty, a free server with a sleep cycle is the wrong foundation. No amount of retry logic fixes a multi-second cold start for a user waiting on a button. Use this approach for experiments, batch jobs, and internal tooling where a few extra seconds are acceptable. Always check the current quota and pricing docs, because free tiers change and yesterday's numbers are tomorrow's outdated data.

The takeaway

If you want to practice the checklist on a real endpoint, MonkeyCode's free model access and free server option are a reasonable place to start. Just measure the cold start yourself before you trust it. The real takeaway is bigger than any single service: when an API returns 200 with nothing inside, that's not success, that's a clue. Measure the timing, read the boundary, validate the body, and design for the sleep cycle you actually have, not the one you wish you had.

Top comments (0)