One remote accounting service would disappear for long periods and sometimes return with an expired TLS certificate. Another integration scheduled maintenance during peak operating hours. Work which normally completed quickly could occupy application capacity while the dependency was unable to help.
A timeout would not have made either provider reliable. It would have answered a question our callers still needed answered:
How long may this attempt wait before its result is no longer useful?That answer belongs to the operation’s contract. Leaving it to a client library’s default lets a transport decide 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. Four hundred milliseconds have already been spent validating input and loading state. The response path needs another 150 milliseconds in reserve to record the outcome and serialize the result.
The remote call does not have two seconds:
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 applies that calculation to three points in one synthetic request. At 400 milliseconds it allocates 1,200 milliseconds. At 1,700 milliseconds it can allocate only 150. At 1,900 milliseconds the reserve has already consumed the remaining budget, so it refuses to begin the call.
Those numbers are present-day teaching inputs. The historic timeout values are not retained and should not be reconstructed.
A fixed three-second timeout would look explicit in code and still violate the two-second caller contract. “We set a timeout” is not enough. The bound has to fit inside the work which owns it.
Connection and response waits are different
Current Laravel documentation exposes both a request timeout and a connection timeout . It currently documents defaults of 30 seconds for the response wait and 10 seconds for connection establishment. Those are framework defaults, not recommendations for this workflow.
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 necessarily share one failure mode. A short connection bound can reject an unreachable host without giving a slow but healthy response only that same small 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, a nominal 200-millisecond connection bound becomes 150. A child budget cannot outlive its parent merely because it normally uses a larger value.
An expired certificate is different again. A correctly validating client should reject it as a TLS failure rather than wait for the response timeout. Increasing a timeout does not repair trust. Classify that failure separately so certificate expiry does not look like a slow provider.
Reserve time for failure handling
Using every remaining millisecond for the remote wait 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 arbitrary padding. It belongs to work which must happen before the caller’s deadline. Measure that work and revise the reserve when its cost changes.
A hard deadline can still be missed. The runtime may pause, cleanup may block, or cancellation may not interrupt the underlying operation immediately. The budget is a design constraint and an observable target, not a real-time guarantee from a general-purpose application server.
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 says only that some configured number was crossed. It cannot show whether the caller’s budget was internally consistent.
A timeout bounds waiting, not remote work
When a client stops waiting, it may not know what the server did.
Consider a booking request:
client sends request
server accepts booking
client times out before response arrivesFrom the client’s perspective, the outcome is unknown. Treating the timeout as “nothing happened” and submitting the same request again can duplicate the remote effect. Treating it as success invents an identifier and result the client never received.
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 is therefore two decisions:
- stop allocating local waiting capacity; and
- classify what is known about the remote effect.
The first is transport control. The second is business state.
The previous article discussed a known remote success followed by local failure. A timeout is harder because the remote outcome may be unknown. The same principle applies: local cleanup cannot erase a remote effect which may already exist.
Worker timeout and request timeout must agree
A queue worker can also impose a maximum execution time. That outer bound should not expire while an HTTP client still believes it may wait.
For one attempt:
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 without a reason, the job abandons usable dependency time while still occupying its execution slot.
The exact inequality grows when the job performs several remote calls. Serial calls share one remaining budget; each cannot receive the original maximum. Parallel calls share the deadline but consume different connection and downstream capacity. A transaction lock, uniqueness lease, or visibility timeout may impose another outer boundary.
Write the hierarchy down:
caller or business deadline
-> job execution budget
-> attempt budget
-> connection budget
-> cleanup and response reserveEvery inner bound should either fit inside the outer one or have an explicit reason why the outer owner can survive it.
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. That rule is useful because an unbounded new call cannot enter unnoticed.
It cannot prove that ten seconds is safe, that the caller has ten seconds left, that connection and response waits are separated, or that the remote effect is idempotent. It sees method calls, 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 its policy centrally, but the call site still needs a way to pass a shorter remaining deadline. Central configuration should provide a ceiling, not erase the caller’s budget.
Timeout is not retry
During the supplied outages, 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 it should start, or when the operation should fail permanently.
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. Combining them here would make “timeout” mean both a wait bound and an unbounded sequence of new waits.
The distinction also prevents one common mistake: increasing a timeout because retries are expensive. A longer wait may be correct when the dependency is slow but progressing. It is not a substitute for classifying a three-hour outage or an expired certificate.
Measure the consequence, then choose the number
A timeout value should follow from:
- 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 different consequences.
Defaults are still useful as a final safety net. They are poor substitutes for a decision. The current Laravel defaults can prevent an infinite HTTP wait, but they do not know whether this caller has 150 milliseconds or 15 minutes left.
Set the deadline where the business operation begins. Carry its remaining budget inward. Leave enough time to record and communicate failure. Then decide what a timed-out remote call means before allowing the next 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 usefully wait without violating another promise.