All guides
No guide matches your search.
Guzzle requests with Reqtide
Send Guzzle requests through a Reqtide pipeline. State what may be sent twice, choose retryable outcomes, honor Retry-After and see what the client does not do.
On this page
The package enaxon/reqtide-guzzle is version 0.x (beta). It was tested on the command line, in Docker and in CI, with the cURL handlers. PHP-FPM, Guzzle 8, the stream handler and PSR-18 are not supported. See Limits.
The code blocks come from packages/guzzle/examples (opens in a new tab). The output shown here is the recorded output of those scripts. The scripts start a small fixture server on the loopback interface and print what the server received. In your code the request goes to your API.
Install
composer require enaxon/reqtide enaxon/reqtide-guzzle. 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:
{
"repositories": [
{ "type": "path", "url": "../reqtide", "options": { "symlink": false } },
{ "type": "path", "url": "../reqtide/packages/guzzle", "options": { "symlink": false } }
]
}composer require "enaxon/reqtide:@dev" "enaxon/reqtide-guzzle:@dev"The package needs PHP 8.3 or newer, the curl extension and guzzlehttp/guzzle 7.15.5 or newer in the 7.x line. It requires the same version of enaxon/reqtide.
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.
Wrap a call
ReqtideClient is the entry point. It owns its cURL handlers and runs each logical operation, with all its attempts, through one compiled Core pipeline. You build the pipeline with PipelineBuilder, as in Getting started, and pass the builder to the client.
ReqtideClient::inMemory($builder, $configuration)keeps the state in this process. Under PHP-FPM every request would start with fresh state.ReqtideClient::withBackends($builder, rateLimit: ..., circuit: ..., ...)takes the state backends you give it, for example the Redis backends from Shared state with Redis. Pass a backend for every policy that the pipeline configures. The clock of the decisions is the clock of the backend.
Both take an optional GuzzleConfiguration and an optional EventSink. Each call takes an HttpOperation and a timeout for the whole operation. The timeout covers every attempt and every wait between attempts. Three methods run it:
send()returns the response, or throws the failure of the operation.sendDetailed()returns anExecutionCompletionwith the summary of attempts and decisions instead of throwing.sendAsync()returns a promise. See Promises.
These are the imports for the snippets in this guide:
use Enaxon\Reqtide\Guzzle\HttpOperation;
use Enaxon\Reqtide\Guzzle\HttpOutcomeClassifier;
use Enaxon\Reqtide\Guzzle\ReqtideClient;
use Enaxon\Reqtide\Guzzle\Tests\Support\FixtureServer;
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 GuzzleHttp\Psr7\Request;The first example sends a GET to a healthy server and to a server that always answers 503. The pipeline allows three attempts, 20 ms apart, with a retry budget:
$server = FixtureServer::start();
$client = ReqtideClient::inMemory(
PipelineBuilder::profile('orders-api', 'v1')
->partition('shop', 'prod', 'orders-api')
// The transport enforces this timeout on every attempt.
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::TransportEnforced)
// Up to three attempts, 20 ms apart, and a budget so that a failing server is not hammered.
->retry(3, BackoffMode::Constant, Duration::milliseconds(20), Duration::seconds(1), JitterMode::None)
->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(12)),
);
$read = static fn(string $path): HttpOperation => HttpOperation::read(
new Request('GET', $server->baseUri . $path),
'orders.read',
Duration::seconds(60),
HttpOutcomeClassifier::starter()->retryableOutcomeCodes(),
);The numbers in the output are the count of requests that the server itself received:
$response = $client->send($read('/status/200'), Duration::seconds(10));
echo 'healthy server: status ', $response->getStatusCode(), ', requests at the server: ', $server->count('/status/200'), "\n";
$response = $client->send($read('/status/503'), Duration::seconds(10));
echo 'failing server: status ', $response->getStatusCode(), ', requests at the server: ', $server->count('/status/503'), "\n";
$completion = $client->sendDetailed($read('/status/503'), Duration::seconds(10));
echo 'detailed: ', $completion->summary->dispatched, ' dispatched, ', $completion->summary->retries, ' retries, disposition ', $completion->disposition->value, "\n";healthy server: status 200, requests at the server: 1
failing server: status 503, requests at the server: 3
detailed: 3 dispatched, 2 retries, disposition failedThe 503 is not an exception. After the third attempt you get the last response back. sendDetailed() shows 3 dispatched attempts and 2 retries. The attempt timeout is declared as TransportEnforced because the cURL timeouts enforce it.
What may be sent twice
Reqtide does not guess from the method or from a header. Each operation states whether it may be sent again. Three constructors on HttpOperation make that statement:
| Constructor | Use it for | What you state |
|---|---|---|
HttpOperation::read() | GET and HEAD | Repeating the request has no side effect (an RFC 9110 safe method), for the time you give as validFor. |
HttpOperation::idempotentMutation() | POST, PUT, PATCH and DELETE | The server deduplicates the request by an identity, such as an Idempotency-Key. You give the identity, what it is unique within, and how the server treats a repeat. |
HttpOperation::single() | Everything else | Nothing. The request is sent once and never repeated. |
HttpOperation and the statement each one makes.validForis how long your statement holds, counted from now. It must be longer than the timeout that you pass tosend(), because your statement starts a moment before the deadline does. Add a margin, for example one second. With an equal or shorter value,send()on a retrying pipeline throwsInvalidConfigurationExceptionbefore anything is sent, because such a statement could never allow a retry.- For a mutation, the identity is checked before each attempt, and the body is checked too. Its SHA-256 digest must equal the digest of the first attempt, so one identity never carries two payloads. See Request bodies and replay. The default header is
Idempotency-Key. PassidentityHeader: nullwhen the identity travels elsewhere. You then guarantee that it stays the same. A header name has at most 64 letters, digits and hyphens. An identity value must not start or end with a space or a tab, because Guzzle trims header values. - Core refuses a
single()operation in a pipeline that retries more than once. The refusal comes before anything is sent.
The second example sends a POST that the server answers with 503 each time. It checks the log of the server:
$profile = static fn(int $attempts): PipelineBuilder => PipelineBuilder::profile('billing-api', 'v1')
->partition('shop', 'prod', 'billing-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::TransportEnforced)
->retry($attempts, BackoffMode::Constant, Duration::milliseconds(20), Duration::seconds(1), JitterMode::None);
$retrying = ReqtideClient::inMemory($profile(3)->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(12)));
$once = ReqtideClient::inMemory($profile(1));
$charge = HttpOperation::idempotentMutation(
new Request('POST', $server->baseUri . '/status/503', ['Idempotency-Key' => 'order-1042'], 'amount=1999¤cy=CAD'),
'billing.charge',
'order-1042',
'this account and endpoint',
'returns the stored response for 24 hours',
Duration::seconds(60),
retryableOutcomeCodes: HttpOutcomeClassifier::starter()->retryableOutcomeCodes(),
);
$response = $retrying->send($charge, Duration::seconds(10));$log = $server->log();
$bodies = array_unique(array_column($log, 'bodySha1'));
$keys = array_unique(array_map(static fn(array $entry): string => implode(',', $entry['headers']['idempotency-key'] ?? []), $log));
echo 'server answered ', $response->getStatusCode(), ' to ', count($log), ' attempts', "\n";
echo 'same body every time: ', count($bodies) === 1 ? 'yes' : 'no', "\n";
echo 'same key every time: ', $keys === ['order-1042'] ? 'yes' : 'no', "\n";
$server->reset();
$single = HttpOperation::single(new Request('POST', $server->baseUri . '/status/503', [], 'amount=1999'), 'billing.charge');
$once->send($single, Duration::seconds(10));
echo 'a single operation: ', count($server->log()), " request\n";
try {
$retrying->send($single, Duration::seconds(10));
} catch (InvalidConfigurationException) {
echo 'a single operation in a retrying pipeline is refused before anything is sent: ', count($server->log()), " request in all\n";
}server answered 503 to 3 attempts
same body every time: yes
same key every time: yes
a single operation: 1 request
a single operation in a retrying pipeline is refused before anything is sent: 1 request in allThe mutation was sent three times with the same body and the same key. The single() operation was sent once in a pipeline that allows one attempt. The pipeline that retries refused it with InvalidConfigurationException.
Your statement is the safety
If you call read() for a request that does change something, or idempotentMutation() for a server that does not deduplicate, Reqtide sends it again. It cannot check the claim. The server must make the repeat harmless.
Retryable outcomes
A retry needs the three things described in Retry and budget. The first is your statement that repeating is safe. The second is a retry budget. The third is a classifier that marks the outcome as eligible.
Here the statement is the operation you build, the budget is retryBudget() on the pipeline, and the classifier is HttpOutcomeClassifier. HTTP adds one more check: the reason code of the outcome must be in the list that you give to the operation. That list is the $retryableOutcomeCodes argument of read() and idempotentMutation().
An empty list never retries
The default of $retryableOutcomeCodes is an empty list. With the default, a pipeline that retries never retries. It only reports the reason outcome_not_retryable. Pass HttpOutcomeClassifier::starter()->retryableOutcomeCodes() or your own list. If you build your own classifier, take the codes from that classifier.
HttpOutcomeClassifier::starter() is a documented starting set. It is no recommendation for every API. HttpOutcomeClassifier::empty() retries and falls back on nothing and reads no Retry-After. Every with...() call returns a new classifier. Nothing is inferred from the status class. These are the starter rules:
| Outcome | Eligible for retry | Counts for the circuit | Reason code |
|---|---|---|---|
| Status below 400 | No | Success | http_status_<code> |
| 408, 502, 503, 504 | Yes | Failure | http_status_<code> |
| 429 | Yes | Neutral (handled through Retry-After and the cooldown) | http_status_429 |
| 500 | No | Failure | http_status_500 |
| Any other 4xx or 5xx | No | Success | http_status_<code> |
| Transport error 6, 7, 28, 52, 55 or 56 | Yes | Failure | http_transport_errno_<n> |
| TLS or certificate error | No | Failure | http_transport_errno_<n> |
| Malformed or unsupported request | No | Neutral | http_transport_errno_<n> |
| Any other transport error | No | Failure | http_transport_errno_<n> |
HttpOutcomeClassifier.A 4xx other than 408 and 429 shows that the server is alive and judged the request. It is never retried, and it closes a half-open circuit. A 500 is not retried by default because it often means a defect that repeats. Add it with withRetryableStatuses() if your API says otherwise.
TLS and certificate errors and malformed-request errors can never be made retryable. No status is eligible for a fallback in the starter set. An exception that carries a response is classified exactly like the response.
Request bodies and replay
A request that may be sent again needs a body that can be sent again:
- A seekable body is rewound to position 0 and checked before each attempt.
- A body that cannot be rewound needs a body factory. This is a closure that returns a fresh stream of known size each time. Pass it as
bodyFactory. - Anything else is refused before the first request.
The content of the body is protected as well. With the default BodyIntegrity::Verified, Reqtide computes the SHA-256 digest of the body while the first attempt sends it, so the first attempt reads the body only once. Before every retry the last check reads the body once more and compares its digest. The retry is then verified again while it is sent. A body that changes after the check is stopped: below 1 MB before anything is sent, above it before the last chunk. A body with another digest is refused before anything is sent, and so is a body with more or less data than its size says. No more than the size plus one byte is read, and the body is never held in memory. So a body must be seekable (a factory must return a seekable stream) and at most maxVerifiedBodyBytes long (16 MiB by default). Pass BodyIntegrity::CallerAsserted for a larger body or one that cannot be read twice. Then you guarantee that the bytes stay the same, and only the size and the start position are checked. The time spent reading a body is not part of the attempt timeout. If a transfer took only part of the body (an interrupted upload of 1 MB or more), the payload of the operation cannot be established and the operation is not sent again; a transfer that took no byte of the body is retried as usual. Use BodyIntegrity::CallerAsserted if such an upload must be sent again.
A single() operation may have a body that cannot be rewound, because it is sent once. A request with a body of known size gets an empty Expect header, unless it already has one. cURL then does not wait for 100 Continue.
Responses, failures, redirects and timeouts
- A 4xx or 5xx status is a response. After the last attempt you get the last response.
http_errorsis forced off for every transfer. - A transport failure such as a refused connection or a timeout is the original Guzzle exception, unless a policy ends the operation first. Its message holds the full URL. It is your result, and Reqtide returns it as it is.
- Policy refusals by the rate limit, circuit, bulkhead or cooldown are Reqtide exceptions. See Error handling.
- Redirects are not followed. A 3xx is a response, and its target is never requested.
allow_redirectsis forced off and cannot be set. - Timeouts. The timeout of an attempt is the smaller of your attempt timeout and the time left in the operation. It goes to cURL as
timeoutandconnect_timeout.read_timeoutis never used, because the cURL handlers ignore it.
Retry-After and the shared cooldown
The classifier reads Retry-After for 429 and 503 responses. Change the list with withRetryAfterStatuses(). The value can be a number of seconds or an HTTP date, in the IMF-fixdate form or the older RFC 850 and asctime forms.
A value that cannot be read gives no wait. The same holds for a value beyond the range of the shared-state duration, which is 9,007,199,254 seconds. Reqtide never cuts such a value down or invents one. With several header lines, the greatest valid delay wins. Clock skew between the server and your host remains a limit of date values.
The wait is used in two ways. It is a lower bound for the next attempt of this operation: Reqtide never sends sooner. A wait that does not fit the maximum delay of the retry policy or the deadline defers the operation and returns the wait to you.
With a cooldown() policy in the pipeline, a positive wait also starts a shared pause for the partition. The next calls are deferred without a request. The third example shows this:
$client = ReqtideClient::inMemory(
PipelineBuilder::profile('search-api', 'v1')
->partition('shop', 'prod', 'search-api')
->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::TransportEnforced)
->retry(1, BackoffMode::Constant, Duration::milliseconds(20), Duration::seconds(1), JitterMode::None)
// How long the cooldown record is kept after it has ended.
->cooldown(Duration::seconds(60)),
);
$read = static fn(string $path): HttpOperation => HttpOperation::read(
new Request('GET', $server->baseUri . $path),
'search.read',
Duration::seconds(60),
HttpOutcomeClassifier::starter()->retryableOutcomeCodes(),
);$explanation = $client->explain();
echo 'policies: ', implode(', ', $explanation->enabledPolicies), "\n";
echo 'requests after explain(): ', count($server->log()), "\n";
$response = $client->send($read('/status/429?ra=30'), Duration::seconds(5));
echo 'throttled: status ', $response->getStatusCode(), ', requests at the server: ', count($server->log()), "\n";
$completion = $client->sendDetailed($read('/status/200'), Duration::seconds(1));
echo 'next call during the cooldown: ', $completion->disposition->value, ', requests at the server: ', count($server->log()), "\n";
echo 'reason: ', $completion->problem()?->code->value ?? 'none', "\n";policies: attempt, retry(maxAttempts=1), cooldown
requests after explain(): 0
throttled: status 429, requests at the server: 1
next call during the cooldown: deferred, requests at the server: 1
reason: cooldown_exceeds_deadlineThe 429 carried Retry-After: 30. The next call had a deadline of 1 second, and Reqtide deferred it at once. The count at the server stayed at 1. The reason is cooldown_exceeds_deadline because 30 seconds do not fit in the deadline of 1 second. With a longer deadline the reason is cooldown_active. The call to explain() sent nothing.
The cooldown maximum
A server can send a very long Retry-After, by fault or on purpose. The cooldown policy of Core caps the shared pause that one reported wait can start. The default cap is 3,600 seconds, which is one hour.
A longer wait still governs the retry of the operation that received it. The pause that other operations see is cut to the cap. Set your own cap with the second argument of PipelineBuilder::cooldown($cleanupGrace, $maximumExtension). See Bulkhead and cooldown.
Client defaults
GuzzleConfiguration::$clientDefaults accepts only these Guzzle options: headers, auth, proxy, verify, cert, ssl_key, crypto_method, force_ip_resolve, version and decode_content.
headersonly fill in headers that a request does not have, so they cannot change the identity header that Reqtide checks on each attempt. They may not setContent-LengthorTransfer-Encoding: the transport derives the framing from the body that is sent.- Any other option is refused with an
InvalidConfigurationExceptionthat names it. Examples aredelay,query,body,json,sink,curl,cookies,on_headers,on_stats,timeout,base_uriandhandler. - The reason is that a client default applies to every transfer, outside the replay checks and outside the deadline. A default
delaywould sleep beyond the deadline. A defaultcurlcould replace the timeouts and the connection rules below.
Put per-request values in the request. The client builds and owns its handlers: CurlHandler for send() and CurlMultiHandler for sendAsync(). You cannot pass a handler.
Connection reuse
libcurl can send a request again on a new connection, whatever its method, when a reused keep-alive connection gives no bytes back. Reqtide cannot see that second dispatch. So the rule is:
- A
single()operation setsCURLOPT_FRESH_CONNECTandCURLOPT_FORBID_REUSE, and nothing can override it. The cost is a new TCP and TLS handshake for every such request. - A
read()and anidempotentMutation()may reuse connections, because a repeat of them is safe by your own statement.
A retry that your own proxy or load balancer makes behind the connection is not visible to Reqtide.
Promises
sendAsync() returns a promise that settles only after the whole operation has ended, finalization included. It is lazy. Guzzle has no event loop, so the operation runs when you call wait(), one operation at a time.
Several promises passed to GuzzleHttp\Promise\Utils::all() or settle() therefore run one after another. The total time is the sum of the operations, and each one has its own deadline counted from its sendAsync() call. Use sendAsync() for its promise interface and its cancellation, not to run several operations at once.
- While it waits, the driver runs the process-wide Guzzle promise task queue. Unrelated promise callbacks of your program also run inside the deadline loop.
- There is no claim of concurrent operations, of completion without a
wait(), or of a guaranteed stop of a running transfer. - Cancelling asks the transport to stop. It does not prove that it stopped. Pass a
Cancellationtosend(),sendDetailed()orsendAsync(). - The synchronous and the promise runtime share the same policy state.
Secrets and what events carry
Events, summaries, diagnostics and problems carry descriptor fields only. These are the method, a fingerprint of the origin, your operation group, and a fingerprint of the Authorization and Proxy-Authorization headers of the request that you give to the operation, keyed with the credentialKey that you pass. ReqtideClient resolves the fingerprint against the client defaults, so it covers the credentials that are really sent. These are default headers for the headers that the request lacks, a Basic auth default (which replaces the Authorization header), Digest auth, user information in the request URI (cURL sends it as Basic authorization when no Authorization header is set), and the user information of a proxy that applies to the request. Headers that you name in GuzzleConfiguration as credentialHeaders (for example X-Api-Key) and the cert and ssl_key defaults are part of it too. The fingerprint does not see credentials that Guzzle reads from the environment (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), or a Cookie header unless you name it in credentialHeaders. Without a credentialKey a random key of the process is used, so fingerprints compare only within one process. Pass the same key in every worker to compare them across processes. If you run an operation through your own pipeline, call $operation->forClient($configuration) before you read metadata(). They never carry a URL, a query, a header or a body. Random secrets were placed in all of those and searched for in everything Reqtide produces, and in Redis keys and values, with no hit. A var_dump or print_r of an operation, the configuration or the client shows only names, fingerprints and the origin of the base URI (never a path, a query, a header, a body or an identity); var_export and reflection are not covered, and these objects cannot be serialized.
Two things are outside that promise:
- The exception that Guzzle throws for a transport failure holds the full URL. It is your result, and Reqtide returns it as it is.
- Stack traces can hold arguments. Set
zend.exception_ignore_args=Onin production.
uri() and the base URI
Give GuzzleConfiguration an absolute http or https baseUri. $client->uri('/orders/42') resolves a reference against it as RFC 3986 says. The request of an HttpOperation needs that absolute result. A malformed reference throws InvalidConfigurationException.
The base URI is not a security boundary
An absolute reference, or one that starts with //, replaces the base host. The reference orders against https://h/v1 gives https://h/orders. Reqtide does not check where a request goes. Keep requests to internal addresses out with your network egress rules.
Limits and cost
- Response size. Reqtide sets no cap. A hostile or faulty server can fill temporary storage within the timeout window. A
sinkcannot be set here. The client refuses it as a default and an operation takes no request options. Limit the size in your own layer instead, with a proxy or gateway in front of the client or the network rules of the host. - Task queue. A promise wait runs unrelated Guzzle callbacks. See Promises.
- Not supported: PSR-18, Guzzle 8, the stream handler, streaming bodies and PHP-FPM. Only the cURL handlers are used, and you cannot pass a handler.
- Shared state needs backends such as the Redis ones.
inMemory()keeps limits in the process. Shared limits, circuit and cooldown across processes were tested with up to 6 processes. - First request. The client loads about 100 to 150 more files than bare Guzzle. Use an optimized autoloader and OPcache in production.
Cost
These figures were measured on one laptop under Docker Desktop. The run-to-run noise was large, so they are no claim about your system.
A call through the client with in-memory state added about 30 to 130 microseconds to a bare Guzzle call with one policy set. With five policies it added about 135 to 235 microseconds. The first request of a process took 14 ms with bare Guzzle and 26 to 35 ms with the client when the sources were on a local disk. With the sources on a slow bind mount, the first request took 0.6 to 1 second.
The body check of BodyIntegrity::Verified adds no read to the first attempt; the digest is computed from the bytes the transfer reads. Every retry reads the body once more at its last check. One such read took about 4 ms for 1 MiB, 30 to 35 ms for 8 MiB and 85 to 95 ms for 16 MiB, in memory or in a temporary file, with no growth in memory. So a 16 MiB body adds about 0.1 seconds per retry, and this time is not part of the attempt timeout. For large bodies and short deadlines, lower maxVerifiedBodyBytes or use BodyIntegrity::CallerAsserted. The script is benchmarks/body-digest.php.
Related guides
- Retry and budget explains the safety statement and the retry budget in Core.
- Shared state with Redis shows the backends for
withBackends().
Was this page helpful?