BitBootcampBitBootcamp ← all explainers 🔄 Review ⚙ Settings

Drill 76 — The Timeout That Wasn't Set

Theme: Networking & DNS · Format: 20-min drill · Difficulty: core

The story

A digital bank's onboarding service verifies every new applicant against a third-party KYC (Know Your Customer) vendor — identity, sanctions, and politically-exposed-person screening — as a synchronous call on the signup path. The service runs a pooled HTTP client over a fixed pool of two hundred worker threads, the same pool that serves login and the account dashboard. For two years it has been dull and reliable: the vendor's ninety-ninth-percentile latency sits around four hundred milliseconds, a signup completes in under a second, and nobody thinks about it.

One Tuesday afternoon the vendor ships a bad deploy. They do not go down. Every response is still a 200 OK — but latency creeps, then climbs: median goes from three hundred milliseconds to nine seconds, and a long tail hangs for thirty, sixty seconds before answering. No errors, no 500s, no dropped connections. Just slow.

Inside the onboarding service, every request that calls the vendor holds its worker thread while it waits — and because no read timeout was ever configured, the thread waits as long as the vendor takes. Within about two minutes all two hundred threads are parked on the vendor. New requests queue and then time out at the load balancer. The health-check endpoint needs a worker thread too, can't get one, and stops answering — so the load balancer marks the box unhealthy and pulls it. Then the next box, and the next. Login stops. The dashboard stops. A service with nothing structurally wrong is fully dark — and the dependency that caused it never returned a single error.

⏸ Stop here and ask the room: the vendor kept returning 200 OK the entire time and never went down. So how did a slow dependency take down the whole service — including login and the dashboard, which never call it?

Run it — facilitator script

Timing for a 20-minute session: 2 min story · 10 min staged questions · 3 min reveal · 5 min transfer.

Name the resource that actually ran out.

Why is a slow dependency worse than a down one?

What's the single missing line — and why isn't it enough on its own?

The reveal

It was a default. The HTTP client's read timeout was infinite out of the box, and no one had ever set one — so "we never configured a timeout" quietly meant "we wait forever." The vendor's slow deploy turned that default into a total outage. The fix was not one line: they set an explicit connect timeout of two seconds and read timeout of five seconds, wrapped the vendor in a circuit breaker (opens at fifty-percent failures over a rolling twenty-request window, stays open thirty seconds), moved vendor calls onto their own bulkhead pool of twenty-five threads separate from login and the dashboard, and pushed a request-level deadline down the chain so no hop waits longer than the caller has left. At the next vendor slowdown, signups degraded for a few minutes and auto-recovered; nothing else noticed.

Why this happens — the mechanics

A synchronous call over a bounded pool couples your availability to your slowest dependency's latency. The resource that runs out is threads (or pooled connections), not CPU — every in-flight call parks one, and with no read timeout the hold time is unbounded. Little's Law makes it concrete: concurrency equals arrival rate times hold time, so if hold time jumps from four hundred milliseconds to thirty seconds at a constant arrival rate, the concurrency you'd need climbs seventy-five-fold and a fixed pool empties in seconds. Once the shared pool is empty, everything on it starves — including endpoints that never touch the vendor, and the health check itself, which is exactly why one slow dependency reads as a full outage.

Slow is worse than down because of what each does to the resource. A down dependency refuses the connection and returns instantly, freeing the thread and raising a loud, local error. A slow-but-200 dependency holds the thread and emits nothing, so no error rate rises and no alarm trips — the system fails silently, by starvation. The defaults conspire: most HTTP clients ship with no read timeout, so the one dependency you control least ends up setting the ceiling on your own availability. And the organizational seam matches the technical one — the vendor is someone else's system, the pool is shared infrastructure no single feature owns, and the timeout is a client default nobody ever reviewed.

Diagrams

Problem — how the failure unfolds:

sequenceDiagram
    autonumber
    participant A as Applicant
    participant O as Onboarding<br/>pool
    participant K as KYC vendor
    participant L as LB / health
    A->>O: signup - holds a thread
    O->>K: verify identity
    Note over K: bad deploy - slow,<br/>still 200 OK
    Note over O: no read timeout -<br/>thread parked
    A->>O: more signups arrive
    Note over O: all 200 threads<br/>parked on vendor
    L--xO: health check - no thread
    Note over L: box marked<br/>unhealthy, pulled
    A--xO: login - pool empty
    Note over A,L: total outage from a<br/>slow dependency

Solution — where the guardrails go:

flowchart TD
    A["Call to KYC vendor"] --> B["Connect timeout 2s +<br/>read timeout 5s"]
    B --> C["Bulkhead: vendor gets its<br/>own 25-thread pool"]
    C --> D["Circuit breaker: opens at<br/>50% fails / 20 reqs, 30s"]
    D --> E["Timeout budget: deadline<br/>propagates down the chain"]
    B -.-> AL["Fail fast - free the thread,<br/>degrade one feature"]
    C -.-> AL
    D -.-> AL
    E --> F["Slow dependency degrades<br/>one feature, not the service"]

What good looks like

The lesson

Every call to something you don't control needs a deadline and a blast-radius limit: a timeout so a slow dependency can't hold your resources forever, and a bulkhead plus a circuit breaker so its slowness degrades one feature instead of the whole service. A missing timeout is an unbounded resource leak that just hasn't met a slow day yet.

Named concepts: client-side timeouts, circuit breaker, bulkhead isolation, timeout budget.

Real-world echoes

Transfer

Pick your single most-called third-party dependency. What is the read timeout on that client right now — the actual configured value, not "there's probably one"? And if that dependency went slow (not down) for ten minutes, which of your own endpoints would go down with it?

Make it a ticket: audit every outbound client for an explicit connect + read timeout, put your top external dependency behind its own bulkhead pool and a circuit breaker, add a pool-saturation alarm at eighty percent, and run a staging game day that injects ten seconds of vendor latency and proves the rest of the service stays up.

Related