Skip to the guide
All guides

Shared state with Redis

Move the rate limit, circuit breaker, bulkhead, retry budget and cooldown into one Redis server, so every process shares one quota and one open circuit.

app Reqtide PHP 0.1.0checked reading 18 minutes

On this page

The package enaxon/reqtide-redis is version 0.x (beta). It was tested on the command line, in Docker and in CI. Sentinel, Redis Cluster, Valkey and replicas are not supported. PHP-FPM has three CI lanes (PHP 8.3, 8.4 and 8.5, nginx, six workers): the state is shared by requests and workers. See Limits.

The code blocks come from packages/redis/examples (opens in a new tab). The output shown here is the recorded output of those scripts, run against a local Redis.

When you need shared state

The core package keeps its state in the memory of one PHP process. A rate limit there protects that process only. Ten workers each get the full limit, and each has its own circuit breaker.

The Redis package moves the state of five policies into one Redis server: the rate limit, the circuit breaker, the bulkhead, the retry budget and the cooldown. Every process that uses the same registry draws from the same quota and sees the same open circuit.

You need it when

  • Several workers or servers call the same API, and the API limit applies to all of them together.
  • One slow dependency should open the circuit for every process at once.
  • A server asked you to wait, and every process should wait.

You do not need it when

  • One long-lived process makes all the calls, such as a single worker, a command line job or a daemon. The in-memory pipeline is enough.
  • You cannot accept losing the protection when Redis loses its data. This package does not survive data loss. See What is lost.

Shared state costs a network call to Redis for each policy step of each protected call. It also adds a new failure: Redis can be down. When it is, the protected call does not run. See When something goes wrong.

Install

composer require enaxon/reqtide enaxon/reqtide-redis. Both packages must have the same version. See Install.

Register the clone once for the core package and once for the folder of this package. The option symlink: false makes Composer copy the packages into vendor/:

JSON
{
    "repositories": [
        { "type": "path", "url": "../reqtide", "options": { "symlink": false } },
        { "type": "path", "url": "../reqtide/packages/redis", "options": { "symlink": false } }
    ]
}
Bash
composer require "enaxon/reqtide:@dev" "enaxon/reqtide-redis:@dev"

The vendor name is enaxon. Copy the package name exactly. The package needs PHP 8.3 or newer, the openssl extension and predis/predis 3.7 or newer in the 3.x line. It requires the same version of enaxon/reqtide, so the core package comes from the first repository.

Name the core package in the command too. The adapter requires the same dev version of it, and a project with the default minimum stability refuses it otherwise. This command was checked on 2026-10-11 with Composer 2 on PHP 8.3 in Docker, from a clone, with these path repositories.

Set up in four steps

The host does steps 1, 2 and 4. The application does step 3.

Step 1: Create the registry

A registry is the unit of isolation. It has a key prefix, an identity and two ceilings. Create one for each deployment. The application never creates it. If the registry is missing, every protected call is rejected and nothing creates it for you.

PHP
$registry = new RedisRegistry('example-' . bin2hex(random_bytes(6)), 'example-shared-limit', random_bytes(32));
$admin = new RedisRegistryAdmin(connection(), $registry);
$admin->initialize();

$policy = $admin->inspectServer();
check($policy->protectsState(), 'the server must use maxmemory-policy noeviction');
  • The first argument is the key prefix. Every key of the registry starts with it.
  • The second is the identity of the registry: 1 to 64 letters, digits, dots, underscores or hyphens. initialize() refuses a registry that exists with a different identity or different ceilings.
  • The third is a secret of 32 bytes or more. Reqtide uses it to hash tenant and subject names inside keys. Keep it for the life of the registry, because a new secret changes every hashed key. Do not use a value that is also a credential elsewhere. The example uses random_bytes(32) because it throws its registry away at the end.
  • The two optional ceilings are maxPartitions (default 10,000) and maxRecords (default 20,000). Each is between 1 and 1,000,000. See Limits.

You can call initialize() again with the same values. connection() and check() are helpers of the example, in support.php. connection() builds the connection options from the environment of the example. In your project you build RedisConnectionOptions yourself, with the administration user. See Server settings and TLS.

Step 2: Check the server and create the users

inspectServer() reads maxmemory-policy and maxmemory. protectsState() returns true when the policy is noeviction or the server has no memory limit. It returns false when the server may delete keys. It returns null when the policy could not be read, for example because the user may not run CONFIG GET. The example stops unless the answer is true. See Server settings for the reason.

Then give the application a Redis user with only the rules in RedisAcl::execution($registry). Give the host a user with RedisAcl::administration($registry). Print the exact tokens for your registry:

PHP
echo implode(' ', RedisAcl::execution($registry));

The result has this shape. The list of commands is short and comes from the library, so print it instead of copying it from this page:

Text
ACL SETUSER app on ><password> resetkeys resetchannels -@all +evalsha +script|load +hset ... ~billing-prod:*

The user may run the shipped scripts and the commands they use, only on keys under the registry prefix. The administration user has the same rules and four more commands: SCAN and UNLINK for the purge of a retired registry, CONFIG GET for inspectServer() and INFO for the check of the server version.

Step 3: Build the pipeline

Build the pipeline on the Redis environment instead of compileInMemory(). Creating the environment opens no connection. Nothing is read or written until the first operation. A backend exists only for the policies that the definition configures.

PHP
/** What each "process" does: build the same pipeline on the Redis environment. */
$build = static function () use ($registry) {
    $builder = PipelineBuilder::profile('shared-limit', 'v1')
        ->partition('example', 'local', 'reports-api')
        ->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
        // Three tokens, one refilled per hour: three calls for the life of this script, across both pipelines.
        ->rateLimit(PipelineBuilder::tokenBucket(3, 1, Duration::seconds(3600)));

    return $builder->compile(RedisEnvironment::for($builder->definition(), ExceptionClassifier::create(), connection(), $registry));
};

You pass the classifier yourself, as with compileInMemory(). The clock of every decision is the clock of the Redis server, so the clocks of your PHP hosts do not matter for the state.

The connection options take the host, the connect timeout and the read and write timeout in seconds, then the port, the user and the password. Both timeouts are required, greater than zero and at most 3,600 seconds. A user name needs a password. The password never shows in var_dump and never appears in an exception message. The options refuse serialization.

RedisEnvironment::explain() describes the compiled pipeline and its shared state. It makes no network call. It shows the state scope, whose clock decides, the I/O timeout, TLS, the registry identity and its ceilings. It never prints the host, the password, the key prefix or the secret.

PHP
$connection = new RedisConnectionOptions('127.0.0.1', 0.5, 1.5, 1, 'app', 'not-a-real-password');
$registry = new RedisRegistry('example-explain', 'example-explain', random_bytes(32), 500, 900);
PHP
$explanation = RedisEnvironment::explain($pipeline, $connection, $registry);
say("profile: {$explanation->profileId->value} {$explanation->profileVersion->value}");
say('enabled policies: ' . implode(', ', $explanation->enabledPolicies));
$text = '';
foreach ($explanation->capabilityNotes as $note) {
    say("  {$note}");
    $text .= $note . "\n";
}
Text
profile: explain-shared v1
enabled policies: attempt, retry(maxAttempts=1), rate_limit, concurrency
  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
  state_scope=shared_authority
  state_clock=redis_server
  state_io_timeout_seconds=1.5
  state_tls=false
  registry_identity=example-explain
  registry_schema=1
  registry_max_partitions=500
  registry_max_records=900
ok

Step 4: Run maintenance

Run (new RedisRegistryMaintenance($connection, $registry))->step() from a worker or a scheduler. Call it again while the result says more. Nothing runs it for you. See Maintenance.

A runnable example

The example builds two pipelines separately. Each has its own Redis connection, like two PHP processes. They share one registry and draw from one token bucket of three tokens. Whoever asks, only three calls are served.

These are the imports for the snippets in this guide:

PHP
use Enaxon\Reqtide\Error\ProblemCarrier;
use Enaxon\Reqtide\Exception\AdmissionDeferredException;
use Enaxon\Reqtide\Exception\AdmissionRejectedException;
use Enaxon\Reqtide\Execution\AttemptContext;
use Enaxon\Reqtide\Outcome\ExceptionClassifier;
use Enaxon\Reqtide\Pipeline\PipelineBuilder;
use Enaxon\Reqtide\Policy\AttemptTimeoutRequirement;
use Enaxon\Reqtide\Redis\RedisEnvironment;
use Enaxon\Reqtide\Redis\RedisRegistry;
use Enaxon\Reqtide\Redis\RedisRegistryAdmin;
use Enaxon\Reqtide\Value\Duration;

Steps 1 to 3 show the first part of the script. The rest builds both pipelines, makes five calls and removes the registry:

PHP
$served = ['A' => 0, 'B' => 0];
$callable = static function (string $who) use (&$served): \Closure {
    return static function (AttemptContext $attempt) use ($who, &$served): string {
        ++$served[$who];

        return 'served';
    };
};

$pipelines = ['A' => $build(), 'B' => $build()];
$sequence = ['A', 'B', 'A', 'B', 'A'];
foreach ($sequence as $i => $who) {
    $pipeline = $pipelines[$who];
    $number = $i + 1;
    try {
        $pipeline->execute($callable($who), $pipeline->newContext(Duration::seconds(30), operationId: 'shared-limit-' . $number));
        say("call {$number} via {$who}: served");
    } catch (AdmissionDeferredException|AdmissionRejectedException $e) {
        check($e instanceof ProblemCarrier, 'a refusal carries a Problem');
        say("call {$number} via {$who}: refused ({$e->problem()->code->value})");
    }
}
say("served by A: {$served['A']}, served by B: {$served['B']}");
check($served['A'] + $served['B'] === 3, 'exactly three calls were served across both pipelines');

$admin->retire();
$admin->purgeRetired();
say('ok');

From a clone of the repository, this command starts Redis 7.4.11 in Docker, runs the three example scripts and compares their output with the recorded files:

Bash
tools/run-redis-examples.sh

The output is:

Text
call 1 via A: served
call 2 via B: served
call 3 via A: served
call 4 via B: refused (rate.exhausted)
call 5 via A: refused (rate.exhausted)
served by A: 2, served by B: 1
ok

Calls 1 to 3 are served, two of them through pipeline A and one through B. Calls 4 and 5 are refused with the problem code rate.exhausted, and their callable never runs. The pipelines share nothing in PHP. The quota lives in Redis.

Server settings

  • Minimum version: Redis 7.0. The project tests Redis 7.4.11 and 8.2.10. The scripts use redis.acl_check_cmd, which Redis 7.0 added. initialize() and inspectServer() refuse an older server with InvalidConfigurationException. On an older server every script fails before its first command.
  • Set maxmemory-policy noeviction. With a policy that evicts keys, the server can delete a partition without any signal, and the protection of that partition starts again from nothing. With noeviction, a full server refuses writes and the call ends with a clear failure. Only the loss of the registry itself is reported.
  • The state lives in Redis memory. See What is lost for restarts.
  • Standalone server, RESP2, one direct connection. There is no retry option and no persistent connection. A command is never sent twice.
  • Scripts are sent by digest. The library loads the text with SCRIPT LOAD only when the server has lost its script cache.

TLS

Set tls: true in the connection options. Without it, the password and every command travel in clear text, so use TLS on any network that you do not fully control. The server certificate and the host name are always checked, and no option turns that off. Two more options exist:

  • caFile is the path of a CA file, for a private CA. The file must exist, or the options throw InvalidConfigurationException.
  • tlsServerName is the name to check when you connect by address.

The options refuse a caFile or tlsServerName without tls: true. Here is an example:

PHP
new RedisConnectionOptions('redis.internal', 1.0, 0.5, 6379, 'app', $password, tls: true, caFile: '/etc/ssl/redis-ca.pem')

TLS was tested locally against servers with a test CA made for each run. Refused TLS, a wrong password and a missing ACL rule all end as StateUnavailableException.

When something goes wrong

The library never answers a failed state call with fresh local state. It never sends a command twice. It maps each failure to one of the infrastructure exceptions of Core. The protected callable does not run unless the table says otherwise.

What happenedWhat you getWhat was written
The server is unreachable, TLS is refused, the password is wrong, a user lacks a needed rule, or the server is full (noeviction)StateUnavailableException. The callable is not run.Nothing
The connection was lost or timed out after a command was sentIndeterminateStateException for a write, StateUnavailableException for a read. The library never sends the command again.Unknown for a write: it may have been applied once
The registry is missingStateUnavailableExceptionNothing. The registry is not created.
Stored data is damagedCorruptStateExceptionNothing of the call. Cleanup of expired state that ran before the damage was found stays, and the registry counters stay in step with it.
A key under the prefix holds a value of the wrong typeCorruptStateExceptionNothing of the call
The registry ceiling is reachedStateCapacityExceededException, after the library reclaimed expired state and evicted the oldest finished recordsNothing
How each state failure ends and what the library writes.

What happens to your operation after an uncertain result depends on the step:

  • After an uncertain debit of the rate limit or the bulkhead, the operation is not run.
  • After an uncertain release or finalization, the result of your call is returned. The accounting is incomplete.
  • After an uncertain retry commit, no retry is sent.

You decide whether to run the work without protection when the state is down. Make that decision in your own catch block. The library does not make it for you. Error handling shows how to tell these exceptions from the failures of your own code.

Maintenance

RedisRegistryMaintenance::step() removes state that no longer protects anything. Nothing calls it for you. One step looks at no more than 128 finished records and no more than 128 candidates in one atomic script call. It removes:

  • finished records (finalization tombstones, finished budget and circuit records) after a retention of 120 seconds;
  • rate limit and retry budget partitions that equal fresh state after 120 seconds without a write;
  • expired leases of bulkhead partitions, and the partition when nothing is left in it;
  • cooldowns whose end and cleanup grace have passed;
  • closed circuits without history, permits or finished records after 120 seconds without a write.

It never touches an unexpired lease, a pending reservation, a retry budget balance, an open or half-open circuit, the count history of a closed circuit, or a cooldown that is still in force.

The queue head and the order of the index are the saved position. Each step continues where the last one stopped, on any machine and after a restart. A step that reports more found a full batch and removed something, so call it again until more is false. Records that are due by their second but not yet by their exact instant do not set more. So a loop never repeats the same work within one second, and the next scheduled step removes them. The field damaged counts the partitions that the step could not read. They are left as they are, and every request that reads them fails with CorruptStateException. A value above zero needs an operator: inspect the keys, and retire and purge the registry if the damage cannot be explained. One step never scans the whole registry. It writes no event, changes no decision and does not create a missing registry.

Operator actions

The host runs these with its own connection. The library never calls them.

ActionCall
Close a circuit and make its permits staleRedisCircuitAdmin::reset($policyIdentity)
Retire a registryRedisRegistryAdmin::retire(), then purgeRetired() to remove what it left
Check the registry without writingRedisRegistryAdmin::verify()
Check the server policyRedisRegistryAdmin::inspectServer() (needs CONFIG GET and INFO)
The operator actions and the call for each one.
  • reset() closes the circuit, discards its history and every active permit, and moves it to a new generation. It returns the new generation, or null when the partition does not exist. A lost reply is an indeterminate result, and the reset is never repeated. The identity is the one of the circuit policy, for example $builder->definition()->circuit->identity.
  • retire() deletes the registry metadata. Partition and record keys stay, and the returned status shows how many. purgeRetired() removes them in pages and returns how many keys it removed. It refuses to run while the registry exists. Run it again after an interruption.
  • initialize() refuses a prefix that still holds the index or the tombstone queue of a retired registry. Purge first.

What is lost on a restart or an eviction

The state lives in Redis memory.

  • A restart without persistence, a flush or a failover loses it. That includes quota balances, open circuits, leases and budgets.
  • A lost registry rejects every protected call with StateUnavailableException until you initialize it again. Reqtide never uses fresh state in place of lost state.
  • A lost partition inside a surviving registry is not reported. Protection of that partition starts again from nothing. This happens when the server evicts keys, which is why you set noeviction.
  • The registry counters and the saved cleanup position are part of the state, so they are lost with it.

Protection that survives data loss on restart or failover is not planned for the first release.

Limits

LimitWhat it means
Standalone server onlyOne Redis server, one direct connection. Sentinel, replicas and Valkey were not tested and are not supported.
Redis ClusterNot supported by design. The scripts also touch keys that they derive themselves, beyond the keys passed to them: members of the registry index and entries of the tombstone queue. The ACL pattern ~prefix:* covers those keys.
Redis 7.0 or newerOlder servers are refused. Tested on 7.4.11 and 8.2.10.
PHP-FPMThree CI lanes: PHP 8.3, 8.4 and 8.5, each with nginx and six workers. No claim is made beyond these lanes.
Cost of one callA script call is atomic and blocks the server while it runs. Most calls touch a few keys. Two paths cost time in proportion to the records of one partition: a circuit that changes state removes every permit it still holds, and a bulkhead partition removes the leases that expired.
maxRecordsBounds those two paths. It can be up to 1,000,000. Keep it as low as the load needs.
Five policiesRate limit, circuit breaker, bulkhead, retry budget and cooldown. The deadline, the retry delays and the attempt timeout stay local to each process.
The limits of the Redis package.

Run the failure demo with Redis down

The second example needs no server. It points at a port where nothing listens. Reqtide cannot read the state, so it cannot decide whether the call is allowed. The callable is not run, and you get a typed exception.

Bash
docker compose -f tools/docker/compose.yaml run --rm php83 php packages/redis/examples/02-redis-down-failure-demo.php
PHP
$unreachable = new RedisConnectionOptions('127.0.0.1', 0.5, 0.5, 1);
$registry = new RedisRegistry('example-down', 'example-down', random_bytes(32));

$builder = PipelineBuilder::profile('redis-down', 'v1')
    ->partition('example', 'local', 'reports-api')
    ->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::Cooperative)
    ->rateLimit(PipelineBuilder::tokenBucket(3, 1, Duration::seconds(3600)));
$pipeline = $builder->compile(RedisEnvironment::for($builder->definition(), ExceptionClassifier::create(), $unreachable, $registry));

The connection options point at port 1 of the local host, with timeouts of half a second. The pipeline has a rate limit, so every call needs the state:

PHP
$invocations = 0;
$work = static function (AttemptContext $attempt) use (&$invocations): string {
    ++$invocations;

    return 'done';
};

try {
    $pipeline->execute($work, $pipeline->newContext(Duration::seconds(30), operationId: 'redis-down-1'));
    check(false, 'a call with no reachable state must not succeed');
} catch (StateUnavailableException $e) {
    say('execute(): ' . $e::class);
}
say("callable invocations: {$invocations}");
check($invocations === 0, 'the callable was not run');

$completion = $pipeline->executeDetailed($work, $pipeline->newContext(Duration::seconds(30), operationId: 'redis-down-2'));
say('detailed disposition: ' . $completion->disposition->value);
check($completion->disposition !== CompletionDisposition::Succeeded, 'detailed mode does not report success');
check($invocations === 0, 'still not run');
Text
execute(): Enaxon\Reqtide\Exception\StateUnavailableException
callable invocations: 0
detailed disposition: failed
ok
  • A state failure is an infrastructure exception. It differs from a refusal by a policy.
  • The callable ran 0 times.
  • Nothing was written, because the failure happened before any command was sent.
  • executeDetailed() reports the disposition failed and never succeeded.