All guides
No guide matches your search.
Getting started
Build a pipeline that retries a flaky call, rate limits it, guards it with a circuit breaker and bounds it with a deadline. About ten minutes.
On this page
You need PHP 8.3 or newer (64-bit), Composer, and the package installed as described in Install. Every code block below is part of examples/06-getting-started.php (opens in a new tab), which a script runs and compares with the recorded output.
Reqtide keeps its state in the memory of one PHP process. See Limits.
Step 1: Load the classes
use Enaxon\Reqtide\Execution\AttemptContext;
use Enaxon\Reqtide\Execution\OperationSafety;
use Enaxon\Reqtide\Execution\ReplaySafetyContract;
use Enaxon\Reqtide\Outcome\ExceptionClassifier;
use Enaxon\Reqtide\Pipeline\PipelineBuilder;
use Enaxon\Reqtide\Policy\AttemptTimeoutRequirement;
use Enaxon\Reqtide\Policy\BackoffMode;
use Enaxon\Reqtide\Policy\JitterMode;
use Enaxon\Reqtide\Value\Duration;
use Enaxon\Reqtide\Value\Ratio;
require __DIR__ . '/vendor/autoload.php';In your project the last line loads the Composer autoloader. The example in the repository loads it through examples/autoload.php.
Step 2: Say which failure is transient
/** The failure we treat as transient. Anything else is a plain failure and is never retried. */
final class UpstreamTimeout extends \RuntimeException {}Reqtide never guesses which of your exceptions are safe to retry. You name the classes in the next step. An exception you do not name is a plain failure. It is not retried, a fallback does not replace it, and it counts against the circuit breaker.
Step 3: Build the pipeline once
$pipeline = PipelineBuilder::profile('payments', 'v1')
->partition('shop', 'prod', 'payments-api')
// One attempt may take at most 2 s (checked by you, not enforced inside your code: "Cooperative").
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
// At most 3 attempts in total (the first call counts), exponential backoff from 10 ms up to 100 ms.
->retry(3, BackoffMode::Exponential, Duration::milliseconds(10), Duration::milliseconds(100), JitterMode::Full)
// Retries draw from a budget so a failing dependency is not hammered: 10 tokens, 10 more per minute.
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(10))
// Token bucket: 5 tokens, refilled 5 per second. Every dispatch, retries included, costs 1 token.
->rateLimit(PipelineBuilder::tokenBucket(5, 5, Duration::seconds(1)))
// Judge the last 10 completed calls; from 4 samples on, open at >= 1/2 failures, for 30 s, then allow 1 probe.
->circuitBreaker(PipelineBuilder::lastCalls(10), 4, Ratio::of(1, 2), Duration::seconds(30), 1, 1, Duration::seconds(10))
// Only UpstreamTimeout may be retried.
->compileInMemory(ExceptionClassifier::create()->retryOn(UpstreamTimeout::class));Each line is one decision. Reqtide ships no defaults, so every number is yours.
| Call | What it decides |
|---|---|
profile('payments', 'v1') | The name and version of this configuration. The version appears in every error report. |
partition('shop', 'prod', 'payments-api') | Whose state this is: application, environment and the dependency you protect. State is keyed by it, so give each dependency its own subject. |
attemptTimeout(...) | The longest one attempt may take. Cooperative states that your code must respect the time, because the synchronous runtime cannot stop it. |
retry(3, ...) | At most 3 attempts including the first, with exponential backoff from 10 ms to at most 100 ms and full jitter. |
retryBudget(10, 10, 60 s, 10 s) | A shared pool of 10 retry tokens, refilled by 10 per minute. A dependency that is down does not get three times the traffic. It is required whenever retry() allows more than one attempt. |
rateLimit(tokenBucket(5, 5, 1 s)) | A token bucket of capacity 5, refilled by 5 tokens per second. Every dispatch costs one token, the first attempt and each retry alike. |
circuitBreaker(lastCalls(10), 4, 1/2, 30 s, 1, 1, 10 s) | Judge the last 10 completed calls once 4 exist. Open at a failure share of at least 1/2, stay open for 30 s, then allow 1 probe. One successful probe closes it. The last argument is how long a permit stays valid. It must exceed the attempt timeout by more than 1 s. |
compileInMemory(...) | Checks the combination and wires memory backends for this process. A mistake raises InvalidConfigurationException and names the option. |
Build the pipeline at start-up and reuse it. It holds the state that makes the limits work, so a pipeline you rebuild for every request forgets everything each time.
Step 4: Write the call you want to protect
$calls = 0;
$callApi = static function (AttemptContext $attempt) use (&$calls): string {
++$calls;
if ($calls < 3) {
throw new UpstreamTimeout('upstream timed out');
}
return "ok on attempt {$attempt->dispatchedAttemptNumber}";
};Your callable receives an AttemptContext with the operation id, the attempt number and the deadline. Return the value and throw your own exceptions. This one fails twice and then succeeds, so you can watch the retries.
Step 5: Run it with a context
// One context per operation. Its deadline covers all attempts and waits together. forReadOnlyCallable() is YOUR
// statement that running this callable again is safe; Reqtide cannot check it, and refuses to retry without it.
$context = $pipeline->newContext(
Duration::seconds(30),
OperationSafety::replayableRead(ReplaySafetyContract::forReadOnlyCallable('get-payment', Duration::seconds(3600))),
);
echo $pipeline->execute($callApi, $context), "\n";
echo "invocations: {$calls}\n";Run it. The output is:
ok on attempt 3
invocations: 3Here is what happened.
newContext()created the deadline: 30 seconds for the whole operation, retries and waits included. It also carries your statement that repeating this call is safe.forReadOnlyCallable()makes that statement. Reqtide cannot verify it, and it will not retry without it.- Attempts 1 and 2 threw
UpstreamTimeout, which you listed as retryable. Each time the pipeline waited a backoff delay, took one token from the retry budget and charged one rate-limit token. - Attempt 3 returned a value, and
execute()returned exactly that value. Static analysis knows its type:string. - The three dispatches used three rate-limit tokens. A charge is never refunded, so size quotas for the expected traffic multiplied by the attempts.
When something is wrong
You are likely to make two mistakes first. Reqtide reports both before it sends anything. See Error handling for the full model.
The first message comes while you build the pipeline:
retry(maxAttempts=3) requires retryBudget(): retries must be paid for so a failing dependency is not hammered. Every call also needs an explicit replay-safety assertion, for example ReplaySafetyContract::forReadOnlyCallable(); Reqtide never infers that repeating a callable is safe.The second comes when a retrying pipeline gets a context without the assertion:
maxAttempts greater than one requires an explicit replay-safety contract; the operation is Unsafe.Limits to remember
- State lives in the memory of this PHP process. Under PHP-FPM, a pipeline built inside a request starts every request with a full quota and a closed circuit. Build it once per long-lived worker, or accept limits per request. Shared state needs the Redis package (Shared state with Redis).
- Reqtide checks the deadline between steps. It does not interrupt your callable.
- The delays in the example really sleep, in slices of at most 5 ms. For tests that must not sleep, see Testing with the virtual clock.
More in Limits.
Next steps
- Concepts: the pipeline, the operation, the deadline and the partition.
- Choosing policies: what each protection is for.
- Error handling: catch refusals and read the reason code.
- Testing with the virtual clock: virtual time, scripted attempts and recorded events.
Was this page helpful?