All guides
No guide matches your search.
Concepts
Learn what Reqtide keeps apart: the pipeline, the operation, the attempt and the partition. See where each rule lives and in what order the checks run.
On this page
Every code block below comes from examples/10-concepts.php (opens in a new tab) or examples/09-choosing-policies.php (opens in a new tab). A script runs both and compares them with the recorded output.
State is process-local and in memory, and Reqtide never interrupts a running callback. See Limits.
Four things Reqtide keeps apart
| Thing | What it is | Lifetime |
|---|---|---|
| Pipeline | The configuration (policies and their numbers) plus the state those policies keep: rate-limit tokens, circuit history and retry budget. | Built once, reused for every call. |
| Operation | One logical piece of work you want done, such as "load this order". It has one context, one total deadline and one result. | One call to execute(). |
| Attempt | One physical call of your callable. An operation makes one attempt, or several when retries are allowed. | One dispatch. |
| Partition | Whose state this is: application, environment, subject and, optionally, a tenant. | As long as the pipeline lives. |
Because they are separate, one pipeline can serve many concurrent operations without sharing any per-call data. A deadline can also cover all the attempts of one operation.
The pipeline is built once
PipelineBuilder collects your options and checks the combination. compileInMemory() wires state for this process and returns a CompiledPipeline. The builder has no defaults. A policy exists only if you call its method, and every number is yours.
$catalog = PipelineBuilder::profile('concepts', 'v1')
->partition('shop', 'prod', 'catalog-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
->rateLimit(PipelineBuilder::tokenBucket(5, 5, Duration::seconds(1)))
->circuitBreaker(PipelineBuilder::lastCalls(10), 4, Ratio::of(1, 2), Duration::seconds(30), 1, 1, Duration::seconds(10))
->compileInMemory(ExceptionClassifier::create());
$explanation = $catalog->explain();
echo "profile: {$explanation->profileId->value} {$explanation->profileVersion->value}\n";
echo 'enabled policies: ', implode(', ', $explanation->enabledPolicies), "\n";
foreach ($explanation->capabilityNotes as $note) {
echo " {$note}\n";
}explain() describes what you built. It calls nothing, touches no state and does no I/O. Use it for a start-up log line, or in a test to assert that a pipeline is what you think it is:
profile: concepts v1
enabled policies: attempt, retry(maxAttempts=1), rate_limit, circuit
invocation=synchronous
settlement=inline_return_or_throw
attempt_timeout=unsupported
cancellation=checkpoint_only
scheduling=bounded_blocking
queue_ownership=none
detached_completion=unsupported
nonblocking=not_claimed
physical_stop=not_guaranteed
require_finite_backend_io=falseRead the list of enabled policies first. retry(maxAttempts=1) is always there and means "no retries" until you call retry().
The capability notes state what the runtime can do. attempt_timeout=unsupported and physical_stop=not_guaranteed say that the synchronous runtime cannot stop your callable. scheduling=bounded_blocking says that retry delays really sleep.
Build the pipeline at start-up and reuse it. It holds the state that makes limits work, so a pipeline you rebuild for every request forgets everything each time. It keeps no per-operation state, so operations in one process can share it safely.
What happens to one attempt
For each attempt the pipeline runs its checks in a fixed order:
| Order | Check | If it says no |
|---|---|---|
| 1 | The deadline and the context | The operation ends with deadline_exceeded or operation_cancelled. |
| 2 | Retry eligibility (attempts after the first) | Your last result is returned. |
| 3 | The minimum remaining allowance for the attempt (attempts after the first) | Your last result is returned. |
| 4 | The retry budget, which reserves one token (attempts after the first) | retry_budget_exhausted is recorded and your last result is returned. |
| 5 | The circuit breaker | circuit_open, and your callable does not run. |
| 6 | The bulkhead | concurrency_capacity_exhausted. |
| 7 | The shared cooldown | cooldown_active or cooldown_exceeds_deadline. |
| 8 | The rate limit | rate.exhausted or a related code. |
| 9 | Dispatch | Your callable runs. |
If a later check refuses, the permits that earlier checks took are released. A circuit probe is abandoned without counting, the bulkhead slot is freed, and a reserved retry token is released. Nothing is held while the pipeline waits between retries. A refusal never reaches your callable, and it does not count as a failure of your dependency.
An operation has one deadline and many attempts
You build one context per operation with newContext(). It carries the total deadline, your statement about repeating the call, an optional fallback and an operation id. On every attempt the pipeline gives your callable an AttemptContext.
$retrying = PipelineBuilder::profile('concepts', 'v1')
->partition('shop', 'prod', 'catalog-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
->retry(3, BackoffMode::Constant, Duration::milliseconds(5), Duration::milliseconds(5), JitterMode::None)
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10))
->compileInMemory(ExceptionClassifier::create()->retryOn(Hiccup::class));
$operationIds = [];
$attemptIds = [];
$flaky = static function (AttemptContext $attempt) use (&$operationIds, &$attemptIds): string {
$operationIds[] = $attempt->operationId->value;
$attemptIds[] = $attempt->attemptId->lowerHex;
if ($attempt->dispatchedAttemptNumber < 3) {
throw new Hiccup('try again');
}
return "succeeded on attempt {$attempt->dispatchedAttemptNumber}";
};
$safety = OperationSafety::replayableRead(ReplaySafetyContract::forReadOnlyCallable('list-products', Duration::seconds(3600)));
echo $retrying->execute($flaky, $retrying->newContext(Duration::seconds(30), $safety, operationId: 'concepts-1')), "\n";
echo 'attempts: ', count($attemptIds), ', distinct attempt ids: ', count(array_unique($attemptIds)), "\n";
echo 'distinct operation ids: ', count(array_unique($operationIds)), ' (', $operationIds[0], ")\n";succeeded on attempt 3
attempts: 3, distinct attempt ids: 3
distinct operation ids: 1 (concepts-1)The example shows four things.
- The operation id is the same on all three attempts. Each attempt has its own attempt id. A log line that names the operation id groups the attempts of one operation.
dispatchedAttemptNumbercounts physical dispatches. Put this number in your log lines.- Build the context right before the call, not at start-up. The deadline starts when
newContext()builds the context, so an old context carries an almost spent budget. - Never share one context between two operations. It names the operation.
The deadline
The timeout you give newContext() is the budget of the whole operation: all attempts, all waits between them and a fallback. It must be positive, because an already expired deadline can only fail.
Every snippet in the protection guides starts from the same two required options. This is the setup the deadline example uses:
/** 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));echo "-- 6. Deadline: one budget for the whole operation\n";
$pipeline = $base()->compileInMemory(ExceptionClassifier::create());
$slow = static function (): string {
usleep(30_000);
return 'finished late';
};
try {
$pipeline->execute($slow, $pipeline->newContext(Duration::milliseconds(5)));
} catch (DeadlineExceededException $e) {
echo "late result replaced by {$e->reason()->value}\n";
}-- 6. Deadline: one budget for the whole operation
late result replaced by deadline_exceededThe slow callable above ran to the end. Reqtide checks the deadline between steps and not inside your code, so a result that arrives late is replaced by deadline_exceeded. The value itself stays available in the attempt records of detailed mode. Reqtide checks the deadline before each admission and each dispatch. An attempt does not start after the deadline has passed, and a retry whose delay does not fit in the remaining time does not start either.
The deadline is not a timeout for your code
The deadline does not interrupt a running callable. When you declare the attempt timeout as Cooperative, you state that your code respects the time, for example through a transport timeout. The synchronous runtime cannot enforce it.
The partition
A partition names whose state a pipeline keeps: an application, an environment, a subject and, optionally, a tenant. The subject is what you protect, for example catalog-api, and never a credential. All the policies of a pipeline share its partition.
One pipeline serves exactly one partition. If you pass it a context built by a pipeline for another partition, Reqtide refuses before it dispatches anything:
$orders = PipelineBuilder::profile('concepts', 'v1')
->partition('shop', 'prod', 'orders-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
->rateLimit(PipelineBuilder::tokenBucket(5, 5, Duration::seconds(1)))
->compileInMemory(ExceptionClassifier::create());
try {
$catalog->execute(static fn(): string => 'never runs', $orders->newContext(Duration::seconds(30)));
} catch (InvalidConfigurationException $e) {
echo $e->getMessage(), "\n";
}The execution context partition must equal every selected policy partition.Pick the subject to match the thing whose capacity you want to protect: one subject per dependency, and one pipeline per tenant when limits are per tenant. With in-memory state, every compiled pipeline owns its backends, so two pipelines never share a quota, even for the same partition. The profile id and version name the configuration. Change the version when the settings change, because state is keyed by it.
What counts as a failure
Policies act on the classification of each attempt. Your classifier decides what an outcome means: retryable, a circuit failure, or replaceable by a fallback. ExceptionClassifier starts conservative. See Choosing policies.
Where to go next
- Choosing policies maps problems to protections.
- Retry and budget and Rate limiting cover the two protections that interact most.
- Error handling explains what comes out when a protection says no.
Was this page helpful?