One accounting integration went down often enough that we stopped being surprised by it. Sometimes it came back with an expired TLS certificate. A different provider scheduled maintenance during our peak operating hours. Neither system became more reliable just because our code was willing to wait.
Waiting longer was not resilience. It turned someone else’s outage into our capacity problem while leaving the caller with the same unanswered question:
How long may this attempt wait before its result is no longer useful?That answer belongs to the operation, not the HTTP library. If we leave it to a library default, a transport setting decides how much of the customer’s time and our worker capacity one dependency may consume.
The caller owns the outer deadline
Suppose an API request has two seconds to return. It has already spent 400 milliseconds validating input and loading state, and it needs another 150 milliseconds to record the outcome and serialize a response. The remote call does not have two seconds left:
caller deadline 2,000 ms
time already spent 400 ms
response and cleanup reserve 150 ms
-------------------------------------
available remote budget 1,450 msIf the dependency’s own policy caps calls at 1,200 milliseconds, the request timeout is the smaller value:
request timeout =
min(dependency cap, caller deadline - now - response reserve)The DBG-07 fixture runs that calculation at three points in one synthetic request. At 400 milliseconds it allocates 1,200 milliseconds. At 1,700 it can allocate only 150, and at 1,900 the response reserve has already consumed what remains, so the call should not begin at all. These are teaching values; the historic settings are not retained, and I am not going to invent them now.
A hard-coded three-second timeout would look responsible in a code review and still violate the two-second caller contract. Declaring a timeout is only the first step. The number has to fit inside the work which owns it.
Connection and response waits are different
Laravel exposes both a request timeout and a connection timeout . Its current documentation gives them defaults of 30 seconds and 10 seconds respectively. Those values are framework defaults, not an architectural decision on our behalf.
The distinction matters because the failure tells us different things:
connection timeout
no connection was established within the bound
request timeout
a connection may exist, but a complete response did not arrive in timeDNS lookup, TCP connection, TLS negotiation, request upload, server processing, and response transfer do not share one failure mode. A short connection bound can reject an unreachable host quickly without giving a slow but healthy response the same tiny window.
The fixture gives connection establishment its own cap, but never lets it exceed the total request budget:
connect timeout = min(connect cap, request timeout)When only 150 milliseconds remain, the usual 200-millisecond connection bound becomes 150. A child budget cannot outlive its parent just because its default is larger.
An expired certificate is different again. A correctly validating client rejects it as a TLS failure; increasing a timeout does not repair trust. If we put certificate expiry, connection failure, and slow responses into the same bucket, we make all three harder to operate.
Reserve time for failure handling
Giving the remote call every remaining millisecond leaves no time to make the timeout useful. After the client abandons the wait, the application may still need to:
- roll back or close local work;
- persist the attempt outcome;
- release a lock or reservation;
- report the dependency and failure stage;
- return a stable response; and
- schedule reconciliation when the remote outcome is unknown.
The reserve is not padding added because round numbers look safer. It belongs to work which must happen before the caller’s deadline, so it should be measured and revised when that work changes.
Even then, a general-purpose application server cannot promise real-time execution. The runtime may pause, cleanup may block, or cancellation may not interrupt the underlying operation immediately. The budget is a design constraint and something we can observe, not a magical guarantee.
That limit should be visible in evidence:
caller deadline
elapsed before dependency
allocated connection timeout
allocated request timeout
elapsed at failure
cleanup elapsed
response elapsedWithout those fields, a timeout report tells us only that some configured number was crossed. It cannot show whether we allocated the caller’s time consistently or spent the whole budget before cleanup even began.
A timeout bounds waiting, not remote work
Stopping the wait does not stop the remote work. Consider a booking request:
client sends request
server accepts booking
client times out before response arrivesFrom our side, that outcome is unknown. Retrying on the assumption that nothing happened may create the booking twice. Calling it a success would be just as dishonest because we never received its identifier or result.
The safe next action depends on the remote contract:
| Remote capability | Timeout response |
|---|---|
| idempotency key with lookup | query or repeat using the same operation identity |
| stable client reference | search for the existing operation before creating another |
| cancellation API | cancel only when the remote identity and cancellation result are known |
| no lookup or idempotency | preserve an unknown attempt and reconcile manually or through a bounded process |
| read-only request | abandon and retry according to a separate policy when staleness permits |
A timeout therefore forces two decisions:
- stop allocating local waiting capacity; and
- classify what is known about the remote effect.
The first decision controls local capacity. The second owns business state. The previous article dealt with a known remote success followed by local failure; this case is nastier because the remote result may be unknown. Local cleanup still cannot erase an effect which may already exist.
Worker timeout and request timeout must agree
A queue worker can impose its own execution limit. That outer limit should not expire while the HTTP client still believes it may wait:
job execution budget
> HTTP request timeout
+ local cleanup reserveIf the worker kills the job first, application cleanup and failure recording may never run. If the HTTP timeout is much shorter for no reason, the job gives up usable dependency time while still occupying its execution slot.
The calculation gets less tidy when a job makes several calls. Serial calls share one shrinking budget; each cannot receive the original maximum. Parallel calls share the deadline but consume their own connections and downstream capacity. Transaction locks, uniqueness leases, and queue visibility timeouts may add more outer limits.
Write the hierarchy down:
caller or business deadline
-> job execution budget
-> attempt budget
-> connection budget
-> cleanup and response reserveEvery inner bound should fit inside its parent, or we should be able to explain why the outer operation survives when it does not. If nobody can explain the hierarchy, the values are just unrelated numbers which happen to live in the same codebase.
Static enforcement proves presence, not correctness
A retained codebase later added a static-analysis rule requiring outbound Laravel HTTP facade calls to declare a timeout. I still think that rule is useful: a new unbounded call cannot slip in unnoticed. It cannot prove that ten seconds is safe, that the caller has ten seconds left, or that a repeated remote effect is idempotent. It sees a method call, not the business deadline.
This is the useful division:
static rule
every recognized outbound call declares a bound
behavioral test
the bound follows the remaining caller budget
integration test
the client raises the expected failure at the boundary
workflow test
timeout leaves a declared local and remote outcomeA preconfigured client can own the normal ceiling centrally, but the call site still needs a way to pass a shorter remaining deadline. Central configuration should save repetition, not erase the caller’s budget.
Timeout is not retry
During the outages we experienced, repeated attempts could keep queues occupied and fill the queue backend long after a single wait should have ended. A timeout limits one attempt. It does not decide whether another attempt should exist, when that attempt should begin, or when the operation should finally fail.
Those questions belong to retry policy:
- Is the failure transient?
- Is the operation safe to repeat?
- How does delay increase?
- How many attempts or how much total elapsed time are allowed?
- Where is the durable intent if the queue is rebuilt?
The next article owns those decisions. Folding them into this one would make “timeout” mean both a bound on one wait and a policy for creating more waits. That confusion often ends with someone increasing the timeout because retries are expensive. A longer wait may be correct when the dependency is slow but progressing; it is not a serious response to a three-hour outage or an expired certificate.
Measure the consequence, then choose the number
I choose a timeout from the constraints around the operation:
- the caller’s remaining deadline;
- observed healthy latency and tail behavior;
- the dependency’s published or negotiated limits;
- connection versus response failure modes;
- local cleanup and response cost;
- the consequence of an unknown remote outcome; and
- the capacity consumed by one waiting attempt.
There is no honest universal number in that list. A five-second interactive request, a minute-long document conversion, and an overnight reconciliation job have different owners and consequences. Defaults remain useful as a final safety net, but they cannot know whether the current caller has 150 milliseconds or 15 minutes left.
Set the deadline where the business operation begins and carry the remaining budget inward. Leave enough time to record and communicate failure, then decide what an unknown remote outcome means before allowing another attempt. A timeout is not a prediction of how long a dependency normally takes. It is the point after which this caller can no longer wait without breaking another promise.