All guides
No guide matches your search.
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.
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/:
{
"repositories": [
{ "type": "path", "url": "../reqtide", "options": { "symlink": false } },
{ "type": "path", "url": "../reqtide/packages/redis", "options": { "symlink": false } }
]
}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.
$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) andmaxRecords(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:
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:
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.
/** 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.
$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);$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";
}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
okStep 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:
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:
$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:
tools/run-redis-examples.shThe output is:
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
okCalls 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()andinspectServer()refuse an older server withInvalidConfigurationException. 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. Withnoeviction, 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 LOADonly 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:
caFileis the path of a CA file, for a private CA. The file must exist, or the options throwInvalidConfigurationException.tlsServerNameis the name to check when you connect by address.
The options refuse a caFile or tlsServerName without tls: true. Here is an example:
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 happened | What you get | What 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 sent | IndeterminateStateException 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 missing | StateUnavailableException | Nothing. The registry is not created. |
| Stored data is damaged | CorruptStateException | Nothing 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 type | CorruptStateException | Nothing of the call |
| The registry ceiling is reached | StateCapacityExceededException, after the library reclaimed expired state and evicted the oldest finished records | Nothing |
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.
| Action | Call |
|---|---|
| Close a circuit and make its permits stale | RedisCircuitAdmin::reset($policyIdentity) |
| Retire a registry | RedisRegistryAdmin::retire(), then purgeRetired() to remove what it left |
| Check the registry without writing | RedisRegistryAdmin::verify() |
| Check the server policy | RedisRegistryAdmin::inspectServer() (needs CONFIG GET and INFO) |
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
StateUnavailableExceptionuntil 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
| Limit | What it means |
|---|---|
| Standalone server only | One Redis server, one direct connection. Sentinel, replicas and Valkey were not tested and are not supported. |
| Redis Cluster | Not 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 newer | Older servers are refused. Tested on 7.4.11 and 8.2.10. |
| PHP-FPM | Three 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 call | A 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. |
maxRecords | Bounds those two paths. It can be up to 1,000,000. Keep it as low as the load needs. |
| Five policies | Rate limit, circuit breaker, bulkhead, retry budget and cooldown. The deadline, the retry delays and the attempt timeout stay local to each process. |
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.
docker compose -f tools/docker/compose.yaml run --rm php83 php packages/redis/examples/02-redis-down-failure-demo.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:
$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');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 dispositionfailedand neversucceeded.
Related guides
- Rate limiting, Circuit breaker and Bulkhead and cooldown describe the policies that this package shares.
- Guzzle requests with Reqtide shows how to pass these backends to
ReqtideClient::withBackends().
Was this page helpful?