All guides
No guide matches your search.
Bulkhead and cooldown
Cap concurrent operations with a bulkhead, learn what its lease promises, and pause new operations after a server asks you to wait.
On this page
Both protections keep their state in one PHP process. The bulkhead counts the operations in flight in that process only, and a cooldown pauses new operations in that process only. See Limits.
The code blocks come from examples/09-choosing-policies.php (opens in a new tab). A script runs it and compares it with the recorded output.
Bulkhead
One slow dependency can use up all the concurrent work a process can do, and then everything else waits behind it. A bulkhead limits the operations in flight at once, so the rest of the process keeps working.
The snippets start from this shared setup:
/** Every pipeline below starts from the same two required options. */
$base = static fn(): PipelineBuilder => PipelineBuilder::profile('choosing', 'v1')
->partition('shop', 'prod', 'catalog-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative);
$ok = static fn(): string => 'ok';
$context = static fn(CompiledPipeline $pipeline): ExecutionContext => $pipeline->newContext(Duration::seconds(30));This one allows one operation in flight. While the outer operation holds the only slot, an inner call is refused:
echo "-- 2. Bulkhead: at most one operation in flight\n";
$pipeline = $base()->bulkhead(1, Duration::seconds(10))->compileInMemory(ExceptionClassifier::create());
$outer = $pipeline->execute(static function () use ($pipeline, $context): string {
try {
// Called while the outer operation still holds the only slot.
return $pipeline->execute(static fn(): string => 'inner', $context($pipeline));
} catch (AdmissionRejectedException $e) {
return "inner call refused with {$e->reason()->value}";
}
}, $context($pipeline));
echo "{$outer}\n";-- 2. Bulkhead: at most one operation in flight
inner call refused with concurrency_capacity_exhaustedThe signature is bulkhead(capacity, lease).
capacityis the number of slots.- An attempt holds a slot while it runs, and the slot is released when the attempt settles. Nothing is held during a retry delay.
leasebounds how long a slot stays reserved if the caller disappears. It must exceed the attempt timeout by more than 1 second.
The refusal is an AdmissionRejectedException with the reason concurrency_capacity_exhausted. Your callable did not run.
What a lease does not do
An expiring lease does not stop a callback that is still running. The slot becomes free again, so a new operation may start while the first callback still runs, and the number of running callbacks can briefly exceed the capacity. A late release cannot free the slot of a newer holder, and every permit ends exactly once. Reqtide cannot stop your code, so make the callable respect the attempt timeout. Before each attempt is sent, Reqtide also checks that the slot's lease still outlasts the attempt. If a slow step before the dispatch (for example a slow rate-limit call) used the time, the attempt is not sent and ends with an AdmissionRejectedException with the reason ownership_lease_insufficient. The check uses the local monotonic clock and is not a fencing token. To keep a late callback from changing your data, see Fencing writes downstream.
Cooldown
When a server answers "slow down, retry after 60 seconds", every other operation in the same partition should wait too. They should not find out one by one. A cooldown is that shared pause.
cooldown(cleanupGrace) turns it on. cleanupGrace is how long Reqtide keeps the state after the pause has ended. The cooldown activates only when your classifier reports waits with ExceptionClassifier::retryAfterFrom(). Without it, the cooldown never activates. Reqtide has no HTTP awareness, so turning a 429 and its header into a Duration is your job.
The snippet below continues the pipeline from Retry and budget. There, the first operation was deferred because a wait of 60 seconds does not fit the 100 ms maximum delay of the retry policy. The second operation never calls the callable:
try {
$pipeline->execute($throttled, $pipeline->newContext(Duration::seconds(30), $safety()));
} catch (AdmissionDeferredException $e) {
echo "next operation deferred with {$e->reason()->value}; the callable ran {$calls} time(s) in total\n";
}next operation deferred with cooldown_exceeds_deadline; the callable ran 1 time(s) in totalA positive Retry-After extends the shared cooldown for the partition. While the cooldown is active, Reqtide defers new operations with cooldown_active. When the wait does not fit the deadline of the operation, as above, the reason is cooldown_exceeds_deadline. The callable ran once in total.
The order of checks
Reqtide checks the cooldown after the circuit and the bulkhead and before the rate limit. If a later check refuses, Reqtide releases the permits taken earlier. See Concepts.
Testing both
A bulkhead and a cooldown depend on time. Test them with a virtual clock instead of waiting. See Testing with the virtual clock.
Was this page helpful?