DEV Community

Jeff
Jeff

Posted on Originally published at powerduck.com

Mocking Timeouts: When a Stub Never Returns and the Client Retries

Quick answer

A mock that always returns a happy 200 cannot test the one thing that makes real clients fragile: what happens when the server never responds. Timeouts, retries, backoff, and idempotency handling all live in the client, and they only get exercised if the mock deliberately stalls or delays. If you do not model a no-response case, the first time a real upstream hangs your client either waits forever, hammers the server with immediate retries, or creates a duplicate order. Build a stub that holds the connection open past the client's timeout and a second one that returns a slow-but-successful response, and assert the difference.

The two failures a happy-path mock misses

There are two distinct conditions and they need different tests.

Condition Mock behavior What the client should do
Hard timeout Never send a response; hold the socket Abort after the configured deadline, surface a typed error
Slow response Respond after a delay longer than the deadline Already aborted; ignore the late response safely
Transient then success Fail once, then answer on retry Retry with backoff and an idempotency key

Conflating the first two is a common mistake. A late 200 that arrives after the client has already aborted must not then be applied to application state, or you get the classic "request timed out but the record still appeared" defect.

Configuring a stall in a spec-driven mock

Many mock servers support a delay directive directly. The key is that the delay must exceed the client's timeout so the abort path actually runs, rather than accidentally passing.

Pair it with a client configured to give up sooner, so the test is deterministic and fast.

const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 400); try { await fetch(mockUrl + "/payments", { method: "POST", signal: controller.signal, headers: { "Idempotency-Key": "order-77" }, }); throw new Error("expected a timeout"); } catch (err) { assert.equal(err.name, "AbortError"); } finally { clearTimeout(timer); }

Testing retry and backoff honestly

A retry test should verify three things: the number of attempts, the spacing between them, and that the same idempotency key is sent on every attempt. Without the key, a retry against a real server can double-charge the user even if the client logic looks correct.

Asserting backoff timing in a unit test can make the suite slow, so inject a clock or scheduler rather than sleeping real milliseconds. The test then verifies that the second attempt was scheduled after the expected delay without waiting for it.

What not to do

  • Do not set an infinite client timeout in tests so the stall "passes." That hides the exact behavior you are trying to catch.
  • Do not mock the client's own retry function. You need the real retry loop to run against the stub; mocking the unit under test makes the test meaningless.
  • Do not forget to reset the delay stub between tests, or later tests inherit the stall and report flaky failures.
  • Do not treat a 503 and a timeout as the same retry case. A response you can read may carry a Retry-After header the client should honor; a timeout gives no such signal.

Connection timeout versus read timeout

One more distinction is worth its own assertion, because the client usually configures them separately. A connection timeout happens when the TCP handshake cannot complete at all, such as when the host is unreachable; a read timeout happens after the connection is established but no data arrives. A mock that stalls covers the second case only. To cover the first, point the client at a routable but unresponsive address or a port that drops packets, and confirm the error is surfaced as a connection failure rather than a read abort. Retry policy often differs between the two: failing to establish a connection is usually safe to retry immediately, while a read timeout against a write request may already have been processed server-side and demands the idempotency key before any retry. Encoding both cases keeps your client from applying one blanket retry rule to situations that carry different risk.

Connecting the mock to reality

The value of these tests is that they pin the contract between client and server under failure. Keep the timeout values in configuration rather than hard-coded, and run the same stub against a staging client that uses the production values. A mock proves the logic works at 400 milliseconds; the staging run confirms the production deadline is realistic relative to the real upstream's normal latency.

FAQ

Why not just test the retry logic with a plain function?

Because the interaction with the real network layer is where bugs appear: aborted sockets, late responses writing to dead state, and headers not being resent. A stub that exercises the actual HTTP client catches those; a pure function test of retry math does not.

How slow should I make the stall?

Just beyond the client's configured timeout, and no more. The goal is a deterministic abort, not a long real wait. Use an injected scheduler for backoff so the test itself stays fast even when the modeled delay is large.

What if my mock server cannot add delays?

Use a small local proxy that accepts the request and holds it before forwarding, or wrap the mock with a middleware that sleeps. The essential requirement is a server that can stay silent on demand; almost any tool can be wrapped to do this.

Should the mock return a 408 or just hang?

They are different tests. A 408 Request Timeout is a real response the client reads and may immediately retry; a hang forces the client's own deadline to fire. Keep both, because real systems exhibit each behavior.

This is part of the The API Mocking Guide — start there for the full set of failure conditions worth mocking honestly.

Top comments (0)