Skip to the guide
All guides

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.

app Reqtide PHP 0.1.0checked reading 8 minutes

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

ThingWhat it isLifetime
PipelineThe 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.
OperationOne 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().
AttemptOne physical call of your callable. An operation makes one attempt, or several when retries are allowed.One dispatch.
PartitionWhose state this is: application, environment, subject and, optionally, a tenant.As long as the pipeline lives.
The four things and their lifetimes.

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.

PHP
$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:

Text
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=false

Read 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:

OrderCheckIf it says no
1The deadline and the contextThe operation ends with deadline_exceeded or operation_cancelled.
2Retry eligibility (attempts after the first)Your last result is returned.
3The minimum remaining allowance for the attempt (attempts after the first)Your last result is returned.
4The retry budget, which reserves one token (attempts after the first)retry_budget_exhausted is recorded and your last result is returned.
5The circuit breakercircuit_open, and your callable does not run.
6The bulkheadconcurrency_capacity_exhausted.
7The shared cooldowncooldown_active or cooldown_exceeds_deadline.
8The rate limitrate.exhausted or a related code.
9DispatchYour callable runs.
The checks for one attempt, in order.

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.

PHP
$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";
Text
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.
  • dispatchedAttemptNumber counts 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:

PHP
/** 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));
PHP
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";
}
Text
-- 6. Deadline: one budget for the whole operation
late result replaced by deadline_exceeded

The 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:

PHP
$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";
}
Text
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