Skip to the guide
All guides

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.

app Reqtide PHP 0.1.0checked reading 7 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

PHP
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

PHP
/** 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

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

CallWhat 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.
What each call in the pipeline decides.

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

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

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

Text
ok on attempt 3
invocations: 3

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

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

Text
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