Skip to content

fix(httpcache): attempt every purge request and report all failures - #8592

Open
silverbackdan wants to merge 2 commits into
api-platform:4.4from
silverbackdan:fix/purger-attempt-all-requests
Open

silverbackdan wants to merge 2 commits into
api-platform:4.4from
silverbackdan:fix/purger-attempt-all-requests

Conversation

@silverbackdan

@silverbackdan silverbackdan commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Branch? 4.4
Tickets Fixes #8591
License MIT
Doc PR n/a

Disclosure: this PR was written by Claude Code (AI) from the approach discussed in #8591. I haven't been able to review it in depth yet and will add my own review comments here. Please treat it as a basis for discussing the approach rather than a finished patch.

What changes

SurrogateKeysPurger (the base of SouinPurger and VarnishXKeyPurger) and VarnishPurger used to discard each request() result. Symfony HttpClient's response destructor then ran immediately, waited for the response and threw on any error. As a result, requests were sequential, the first failure abandoned every remaining client and chunk, and the "IRI too long" check could fire after earlier chunks had already been purged.

Now:

  1. Validate first. SurrogateKeysPurger checks every chunk's length before sending anything. The "too long" RuntimeException keeps its type and message, but now nothing has been purged when it's thrown.
  2. Send everything, then collect. Every chunk × client request is sent and its response kept, so they run concurrently. Each response is then checked with getHeaders(), which throws on transport errors and on statuses ≥ 300, as the destructor did.
  3. Report every failure. If any request failed, a new ApiPlatform\HttpCache\Exception\PurgeFailedException is thrown after all requests have been attempted.
    • It extends ApiPlatform\Metadata\Exception\RuntimeException and implements Symfony\Contracts\HttpClient\Exception\ExceptionInterface, so existing catch (ExceptionInterface …) blocks still catch it.
    • getFailures() returns a PurgeFailure for each failed request: the URL, the header name and value (the tag chunk or ban regex), and the cause.
    • The first failure is previous, and the message reads "N of M HTTP cache purge requests failed."
    • The header name and value travel with each request in HttpClient's user_data option, so the response list stays a plain list of responses.

Method, headers, chunking, getResponseHeaders() and constructor signatures are unchanged.

Tests

New tests for both purgers use MockHttpClient, with no Prophecy in new tests per CONTRIBUTING. Each was seen failing on 5.0 first:

  • the first client returns 500 and the second is still purged;
  • the middle of three chunks fails and the third is still sent to every client;
  • a transport error counts as a failure;
  • several failures are all reported, with the first as previous;
  • the exception is both HttpClient's ExceptionInterface and the API Platform RuntimeException;
  • an oversized chunk sends no request at all.

A no-failure guard test passes before and after. cd src/HttpCache && vendor/bin/phpunit: 48 tests pass.

Existing tests edited: the Prophecy expectations in SouinPurgerTest, VarnishPurgerTest and VarnishXKeyPurgerTest now include the user_data option. Some also gained ->willReturn(new MockResponse()), because a null response can no longer be checked. CONTRIBUTING asks for existing tests to be left alone where possible; these are mechanical updates to the exact request options, with no behavioural change.

Open questions

  1. Branch: this targets 4.4 as a bug fix, so it reaches 5.0 and main through the usual up-merge. The code in src/HttpCache is identical on 4.4 and 5.0, and the 48 component tests pass on both. If you would rather treat the new exception class as a feature for 5.0 or main, the two commits rebase onto either branch without changes.
  2. Single failure: throw PurgeFailedException even when exactly one request failed (consistent), or rethrow the original exception in that case (so narrower catches such as ServerExceptionInterface still match)?
  3. BC edge: code catching a narrower HttpClient interface than ExceptionInterface no longer matches. Is that acceptable in a patch release?
  4. Guzzle clients: VarnishXKeyPurgerTest builds the purger with Guzzle PSR clients, although the constructor documents HttpClientInterface[]. The success path still works with them. The failure path relies on HttpClient's getInfo(), and Guzzle raises its own errors from request() instead. Is Guzzle still meant to be supported here?
  5. Dependency: api-platform/http-cache doesn't require symfony/http-client-contracts, although the new exception implements one of its interfaces. Should it be declared?
  6. Out of scope: profiler integration and retry support, both mentioned in HTTP cache purgers error handling and performance issues #8591, are left for follow-ups.

🤖 Generated with Claude Code

@silverbackdan

Copy link
Copy Markdown
Contributor Author

I'll be honest with this and say I'm too tired right now to properly review and am about to go on a holiday. But on a brief look over it all seemed pretty plausible and effective solutions to the issue - I'm not entirely sure or opinionated on the user_data approach. Otherwise doesn't seem like an overly complex solution to a reasonably problematic issue when there are large cache clearing HTTP requests to be made.

SurrogateKeysPurger (and so SouinPurger and VarnishXKeyPurger) and
VarnishPurger discarded each purge response, so its destructor waited for
it and threw on the first transport error or 3xx+ status. That abandoned
every remaining client and chunk, sent requests one at a time, and, for
surrogate keys, could purge earlier chunks before rejecting a later one
as too long.

Chunks are now validated before anything is sent. Every request is sent
first, so they run concurrently, and the responses are checked
afterwards. If any failed, a PurgeFailedException lists each failure
(URL, header, cause) with the first one as previous. It extends
ApiPlatform\Metadata\Exception\RuntimeException and implements HttpClient's
ExceptionInterface, so existing catch blocks still catch it.
Each response now travels alone in the list, and the header name and
value it was sent with are read back from user_data when a request
fails, instead of being carried in a tuple beside the response.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant