enaxon / reqtide-guzzle
Guzzle integration for Reqtide: retries, deadlines, rate limits, circuit breaking and bulkheads around Guzzle 7 requests. Work in progress, not released.
Requires
- php: >=8.3
- ext-curl: *
- enaxon/reqtide: v0.1.0
- guzzlehttp/guzzle: ^7.15.5
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Retries, deadlines, rate limits and circuit breaking around Guzzle requests, with explicit rules for what may be sent twice.
This package is the Guzzle integration of Reqtide PHP. Install it next to the core: composer require enaxon/reqtide enaxon/reqtide-guzzle. It is a beta (version 0.x): the API can change between minor versions. It works with process-local state, or with the Redis backends of enaxon/reqtide-redis passed in (ReqtideClient::withBackends). It was tested on the command line with PHP 8.3 to 8.5 and Guzzle 7.15.5. PHP-FPM is not tested.
What exists today
| Item | State |
|---|---|
ReqtideClient: send, sendDetailed, sendAsync, explain, uri |
Works |
| Retry rules for reads and for mutations that you declare repeatable | Works |
Retry-After (seconds and HTTP dates) and a shared cooldown |
Works |
| Shared limits, circuit and cooldown across processes | Works with the Redis backends (tested with up to 6 processes) |
| Guzzle 7.15.5, PHP 8.3 to 8.5 command line, cURL handlers | Tested locally |
| Guzzle 8, the stream handler, PSR-18 | Not supported |
| The client under PHP-FPM | Not tested (the FPM lane in CI covers the Core and Redis packages) |
Use
$client = ReqtideClient::inMemory( PipelineBuilder::profile('orders-api', 'v1') ->partition('shop', 'prod', 'orders-api') ->attemptTimeout(Duration::seconds(2), AttemptTimeoutRequirement::TransportEnforced) ->retry(3, BackoffMode::Exponential, Duration::milliseconds(100), Duration::seconds(1), JitterMode::Full) ->retryBudget(10, 10, Duration::seconds(60), Duration::seconds(12)), new GuzzleConfiguration(baseUri: 'https://api.example.com'), ); $operation = HttpOperation::read( new Request('GET', $client->uri('/orders/42')), 'orders.read', Duration::seconds(60), // how long your "this is safe to repeat" statement holds HttpOutcomeClassifier::starter()->retryableOutcomeCodes(), ); $response = $client->send($operation, Duration::seconds(10)); // 10 s for the whole operation, waits between attempts included
The examples in examples/ run against a local test server and print what the server received. Run them with tools/run-guzzle-examples.sh in the development repository.
Rules
- What may be sent twice is your statement, not a guess.
HttpOperation::read()is for GET and HEAD.HttpOperation::idempotentMutation()is for a POST, PUT, PATCH or DELETE that the server deduplicates by an identity such as anIdempotency-Key; the key is checked before each attempt, and the body is checked by SHA-256 digest (BodyIntegrity::Verified, the default) so one key never carries two payloads; passBodyIntegrity::CallerAssertedfor a body that is too large or cannot be read twice.HttpOperation::single()is sent once. Core refuses asingle()operation in a pipeline that retries more than once, before anything is sent. 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. UseBodyIntegrity::CallerAssertedif such an upload must be sent again. - A body must be replayable. A seekable body is rewound and checked before each attempt. A body that is not seekable needs a body factory. Anything else is refused before the first request.
- A 4xx or 5xx status is a response, not an exception. After the last attempt you get the last response. A transport failure (refused connection, timeout) is the original Guzzle exception.
http_errorsandallow_redirectsare forced off for every transfer and cannot be set as client defaults. - Only some client defaults are accepted.
GuzzleConfiguration::$clientDefaultstakesheaders(they only fill in headers a request does not have, and may not setContent-LengthorTransfer-Encoding),auth,proxy,verify,cert,ssl_key,crypto_method,force_ip_resolve,versionanddecode_content. Any other option, for exampledelay,query,body,json,sink,curl,cookies,on_headers,on_stats,timeout,base_uriorhandler, is refused with anInvalidConfigurationExceptionthat names it. A client default applies to every transfer outside the replay checks and outside the deadline: a defaultdelaywould sleep beyond the deadline, and a defaultcurlcould replace the timeouts and the connection rules below. Put per-request values in the request. - A request you did not declare repeatable never reuses a connection. libcurl sends a request again on a new connection, whatever its method, when a reused keep-alive connection gives no bytes back (
Curl_retry_request). Reqtide would not see that second dispatch. An operation built withHttpOperation::single()therefore setsCURLOPT_FRESH_CONNECTandCURLOPT_FORBID_REUSE, and the option cannot be overridden. Reads and mutations the server deduplicates by an identity may reuse connections, because a repeat of them is safe by your own statement. The cost forsingle()is a new TCP and TLS handshake. Residual risk: a retry that your own proxy or load balancer makes behind the connection is not visible here. - Empty
Expectheader. A request with a body of known size gets an emptyExpectheader unless it already has one, so cURL does not wait for100 Continue. - Retryable outcomes are yours to name. The
$retryableOutcomeCodesargument ofread()andidempotentMutation()defaults to an empty list. With the default, a pipeline that retries never retries and only reports the reasonoutcome_not_retryable. PassHttpOutcomeClassifier::starter()->retryableOutcomeCodes()or your own list. - The base URI is not a security boundary.
ReqtideClient::uri()resolves a reference as RFC 3986 says: an absolute URI or one that starts with//replaces the base host, andordersagainsthttps://h/v1giveshttps://h/orders. A malformed reference throwsInvalidConfigurationException. Reqtide does not check where a request goes. - No cap on response size. The body is not limited; 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), so limit the size at your own layer: a proxy or gateway in front of the client, or the network rules of the host. - Redirects are not followed. A 3xx is a response; its target is never requested.
- Timeouts. The attempt timeout is the smaller of your attempt timeout and the time left in the operation. It is given to cURL as
timeoutandconnect_timeout.read_timeoutis never used, because the cURL handlers ignore it. Retry-After. Read for 429 and 503 (your classifier can change that), as seconds or as an HTTP date. A value that cannot be read, or one that is too large, starts no cooldown. With acooldownpolicy, the cooldown stops further calls from the same partition without a request.- Handlers. The client builds and owns its cURL handlers (
CurlHandlerforsend,CurlMultiHandlerforsendAsync). You cannot pass a handler. - Promises.
sendAsyncreturns a promise that settles only after the whole operation has ended. Guzzle has no event loop, so the operation runs when you callwait(), one operation at a time. While it waits, the driver runs the process-global Guzzle promise task queue, so unrelated promise callbacks of your program also run inside the deadline loop. There is no claim of concurrent operations, of detached completion or of a guaranteed stop of a running transfer. Cancelling asks the transport to stop; it does not prove it stopped. - Secrets. Events, summaries, diagnostics and problems carry descriptor fields only (the method, an origin fingerprint, your operation group and a fingerprint of the Authorization and Proxy-Authorization headers of the request you give, keyed with your credential key and resolved against the client defaults
headers,auth,proxy,certandssl_key, the request URI and anycredentialHeadersyou configure, so it covers what is really sent), never a URL, a query, a header or a body. Random secrets placed in all of them were searched for in everything Reqtide produces, and in Redis keys and values, without a hit. The exception that Guzzle throws for a transport failure holds the full URL: it is your result and is returned as it is. Stack traces can hold arguments; setzend.exception_ignore_args=Onin production. Avar_dumporprint_rof 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_exportand reflection are not covered, and these objects cannot be serialized. - Shared state.
ReqtideClient::inMemory()keeps limits in the process, so under PHP-FPM every request starts fresh.ReqtideClient::withBackends()takes backends such as the Redis ones; see the Redis package for its rules.
Cost
Measured on a laptop under Docker Desktop (large run-to-run noise, no claim): a call through the client with the in-memory state added about 30 to 130 microseconds to a bare Guzzle call with one policy set, and about 135 to 235 microseconds with five. The first request of a process cost 14 ms for bare Guzzle and 26 to 35 ms for 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, because the client loads about 100 to 150 more files than bare Guzzle. Use an optimized autoloader and OPcache in production.
Not planned for the first release
Guzzle 8, the stream handler, streaming bodies, protection against requests to internal addresses (that belongs in your network egress controls: Reqtide does not check where a request goes), and a PSR-18 adapter.
License
MIT.