Skip to content

Improve Saloon correctness, caching and rate limiting - #55

Merged
binaryfire merged 28 commits into
0.4from
upstream-sync-saloon-reconciliation
Oct 7, 2026
Merged

binaryfire merged 28 commits into
0.4from
upstream-sync-saloon-reconciliation

Conversation

@binaryfire

@binaryfire binaryfire commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

This improves Saloon's request handling, authentication, caching, pagination and concurrent requests, and adds rate limits that reset on the clock.

Failed responses now stay out of the cache and reach pool failure handlers instead of counting as successes. Multipart requests keep their body, attached files and boundary while the request is prepared. OAuth requests and middleware order match Saloon, and mock URL patterns such as /user now match. A rate limit can reset at the start of each minute, hour, day or month, like a provider quota that resets at midnight. The Saloon guide now covers the whole package, including its cache, pagination and rate limit plugins.

A few shared framework classes change too: the HTTP client drops a multipart Content-Type that has no boundary and accepts a replaced event dispatcher, Telescope shows scalar JSON bodies as sent, and the rate limiter gains a CalendarWindow policy.

Requests, URLs and bodies

  • Relative endpoints with a colon in the first segment, such as Google's documents:batchGet, were read as a URL scheme and rejected, and //search was read as a host. Only scheme:// endpoints are absolute now. Merging query parameters into a URL with empty segments no longer produces && or a leading &.
  • Requests gain withoutHeader(), withoutHeaders(), withoutQueryParameters() and withoutOptions(). Removing an option lets the connector or HTTP connection value apply, unlike setting it to null.
  • withUrlParameters() fills URI template placeholders in the base URL, endpoint or URL override, as the HTTP client does.
  • Pending requests gain withMethod() and withUrl(), which change one operation without touching the request. Connectors, and requests that use HasConnector, gain createPendingRequest() to prepare a request without sending it.
  • HasApiVersion and VersionMode add Saloon's API versioning, HasTimeout its declared timeouts, and HasConnector its request-owned connector, which SoloRequest now uses.
  • Plugins now boot before authentication, as in Saloon. A cookie authenticator recorded an API version host template before the version plugin filled it in, so the cookie was never sent.
  • Attaching a file to a request whose default multipart body had not loaded yet discarded that body, and asMultipart() discarded attached files. Both keep the existing body now. attach() also accepts an array of argument tuples, like Http::attach().
  • A multipart body kept any earlier Content-Type, so a JSON default header sent the body without its boundary. A content type without a boundary is now replaced.
  • Guzzle streams close their resource when destroyed, so taking a PSR snapshot in middleware or a SendingSaloonRequest listener closed a resource body or attached file before it was sent. Stream bodies and multipart values now keep one wrapper.
  • List-valued multipart part headers, which generated SDK forms pass, failed in Guzzle. They are joined like repeated HTTP fields.
  • A header written as a list item, such as ['Accept: application/json'], caused a TypeError in JSON requests. It now fails with Saloon's message when the pending request is created.

Sending, retries and errors

  • Saloon's 14 status exceptions, such as NotFoundException and TooManyRequestsException, are added. They extend ClientException or ServerException, so existing catches still apply. Responses gain isJson(), isXml() and array().
  • A transfer that failed after the response headers arrived escaped Saloon's error handling as the HTTP client's exception. It now throws FatalRequestException, runs the fatal middleware and is retried.
  • Connectors and requests gain defaultRetryPolicy(), like defaultDelay(). Connectors could only set retries in boot(), which overrode a request's explicit retry(). A request's policy now replaces the connector's, so retry(1) turns connector retries off.
  • Middleware ran in the wrong order, request middleware first and global middleware last. Each send now builds its own pipeline in Saloon's order: global middleware, plugins, the connector's boot(), request middleware, then the request's boot(). Repeated sends and retries no longer add middleware twice.
  • Middleware pipelines formed a reference cycle through their wrapper closures and stayed in memory until the cycle collector ran. The wrappers are static now.
  • A mock response that threw FatalRequestException skipped the fatal middleware and retries, and mocked responses skipped the request delay. Both now behave like real sends. Cache hits still skip the delay.

Authentication and OAuth

  • Basic authentication was a transport option, so mocks, middleware and assertions could not see it. It now sets the Authorization header.
  • Token expiries were typed as CarbonInterface, so passing a plain DateTimeImmutable, as Saloon's examples do, failed. The contract, AccessTokenAuthenticator and the grant factories now take ?DateTimeImmutable.
  • The authenticators were readonly classes, so subclasses that add state failed to compile. They keep read-only properties instead. AccessTokenAuthenticator builds its header from getAccessToken(), so an overridden getter is used.
  • The client credentials grant shared the authorization code factory signature, so Saloon's two-argument override lost the expiry. Each grant now has Saloon's signatures.
  • A custom resolveAccessTokenRequest() dropped the PKCE verifier. The verifier is now added to whichever request is resolved.
  • Authorization URLs used a different parameter order, dropped falsy extra parameters such as max_age=0, and could not replace standard parameters. They now match Saloon. Empty scopes are dropped and a "0" scope is kept, in authorization URLs and client credentials token requests alike.

Mocking and fixtures

  • Mock response URL patterns, URL assertions and allowed stray requests needed the full URL, so Saloon's path patterns such as /user never matched. Patterns now match the end of the URL, with or without its query string.
  • Typed assertSent() closures with a mixed, object or intersection parameter threw. They now use Saloon's type matching.
  • Fixture header redaction ran on whole header values, so headers with several values, such as Set-Cookie, were stored unredacted. Rules now apply to each value.
  • Fixtures whose data is a JSON object failed to load, and a fixture's own context overrode the recorded response's. Both are fixed.
  • Saloon::fake() replaced the global mock client and discarded earlier fakes and recorded responses. It now adds to the existing client, as in Saloon; passing a MockClient still replaces it.
  • MockClient::withoutCache() keeps responses that reach the transport, such as a recorded fixture, out of the response cache.

Pools

  • Pooled requests treated a 4xx or 5xx response, or one a failure hook rejects, as a success. Every response that toException() treats as failed now reaches the exception handler, or PoolException::failures() when there is none.
  • PoolException had no previous exception unless scheduling failed. It now chains the failure that stopped the pool, otherwise the first request or callback failure.

Framework integration

  • Event::fake() and Event::fakeFor() did not reach Saloon or the HTTP client once they were resolved, because both keep the dispatcher they were built with. Both service providers now pass a rebound events service to the resolved service, so fakes apply and are removed afterwards. Both Saloon request events use Dispatchable.
  • A null integrations_namespace always produced App\Http\Integrations, whatever integrations_path said. It now follows the path beneath the app directory, after resolving . and .. segments, and a path outside it needs a namespace.
  • saloon:list garbled endpoints that used interpolation, concatenation, dots inside strings or escaped quotes, and did not read double-quoted base URLs.
  • Container-built requests ignored make() parameters and constructor injection and threw ArgumentCountError for any request with constructor arguments. Request now implements Transient, so it is fresh on every resolution and still gets its dependencies.

Caching

  • The cache was read after the mock client matched a response, so a cache hit still used up a mock response or fixture. It is now read first.
  • Responses that a request or connector failure hook rejected were cached and replayed to every retry. Only responses that aren't failed() are cached now.
  • No serializing store (file, database or Redis) could restore a cached response, because the cache's serializable-class policy rejected CachedResponse, so every read missed. The service provider now allows it.
  • clearCache() on the manager, connectors and HasCaching forgets a cached response without sending the request, using the same store, key and scope as send().
  • CacheKey is no longer final. It hashes with sha256 because the key includes credentials, so a forged collision would return another caller's response.

Pagination

  • The infinite-loop check was a response middleware closure bound to the paginator, so the paginator's own request kept it alive. A dropped paginator stayed in memory until the cycle collector ran. The check now runs in Paginator::current().
  • Saloon's protected getBodyChecksum() is back, so APIs that add a per-request value to each page can compare the page contents only.

Rate limiting

  • CalendarWindow is a new rate limiter policy that resets at the start of each minute, hour, day (at an optional time) or month, in the app's timezone or a given one. It follows the local clock across daylight saving changes. Saloon's end-of-minute, end-of-hour, midnight and end-of-month limits map to it. The Redis store computes the window from Redis's own clock and rejects a stale window, so a worker whose clock is in another period cannot charge the wrong one.
  • A 429 with a missing, malformed or out-of-range Retry-After header now records Saloon's 60-second cooldown. A zero or past value records none.
  • waitForRateLimits() receives the denied policy and its decision, so a resource can wait out a burst limit and still throw for a daily quota. Waiting out a 429 cooldown with a retry policy sends again through the normal retry loop.
  • resolveRateLimiterName() lets resources share a quota and its cooldown. Saloon::inspectRateLimit() checks a policy or cooldown without using it up, and rateLimitingEnabled() turns limits off for one operation. RateLimitReachedException names the limiter.

API cleanup

  • SaloonManager::sender(), cache() and rateLimiter(), and the pending request's cache() and rateLimiter(), are removed. They only returned the injected services, nothing used them, and the framework's own facades and contracts provide the same services.
  • PipeOrder's cases use Saloon's FIRST and LAST names again.
  • The package no longer requires hypervel/reflection, which it never used. It now requires guzzlehttp/uri-template directly, and symfony/filesystem for the generators' path handling.
  • The generator stubs have Saloon's placeholder comments again, and the authenticator stub its empty constructor.

Tests

tests/Saloon now includes the test suites of Saloon and its four plugins, alongside the Hypervel-specific cases. Tests that call Saloon's live test API use the engine test server in tests/Integration/Saloon. The rate limiter's shared store contract covers calendar windows on every store.

Documentation

The Saloon guide now covers the whole package: request bodies, authentication, OAuth, middleware, error handling, retries, debugging, pools, caching, pagination, rate limits, plugins, events and testing. A wrong shouldThrowRequestException example is fixed. The rate limiting guide documents calendar windows, and the Laravel porting guide gains a Saloon section. The package README lists the remaining differences from Saloon and its plugins, with the reason and the Hypervel equivalent for each. docs/upstream-sync/sync.yaml records the Saloon revisions the package matches and how each upstream repository maps onto it.

Verification

The full parallel suite, PHPStan and formatting pass, and the Saloon and engine integration tests pass on the engine test servers. The rate limiter's store contract passes on the worker-array, Swoole, SQLite, MySQL, MariaDB, PostgreSQL and Redis stores.


Summary by cubic

Brings the Saloon package up to date with Saloon v4 and its Laravel, cache, pagination, and rate-limit plugins, fixing bugs that surfaced when running Saloon's own test suites against Hypervel's port. Behavior now aligns with Saloon where it diverged: responses a failure hook rejects are no longer cached or counted as pool successes, OAuth and multipart requests match what Saloon sends, mock URL patterns match Saloon's docs, and rate limits can reset on the clock.

Behavior changes

  • Only scheme:// endpoints count as absolute URLs, so paths like documents:batchGet are accepted and query merging no longer produces && or a leading &.
  • Pools treat every throwable response as a failure, and responses rejected by a request or connector failure hook are not cached.
  • Added Saloon's status exceptions (NotFoundException, TooManyRequestsException, and more), defaultRetryPolicy(), API versioning, timeouts, request-owned connectors, and URL template parameters.
  • Mock patterns match path suffixes like /user; fixture redaction applies per header value; Saloon::fake() adds to the existing mock client instead of replacing it.
  • Container-built requests now receive constructor injection and make() parameters, and Event::fake() reaches resolved Saloon and HTTP client services.
  • Retrying a request rewinds a resource body to where the first attempt started; a non-seekable body throws BodyException instead of resending an empty body.

Framework and rate limiter changes

  • The HTTP client drops a multipart Content-Type without a boundary so Guzzle adds it, and accepts a replaced event dispatcher.
  • New CalendarWindow rate-limit policy resets at the start of each minute, hour, day (at an optional time), or month, handling daylight saving changes; the Redis store computes windows from Redis's own clock and rejects stale ones.
  • A missing, malformed, or out-of-range Retry-After header records Saloon's 60-second cooldown.
  • The generator commands canonicalize the integrations path, so dot segments resolve correctly and a path outside the app directory asks for a namespace.
  • Telescope records scalar JSON request bodies as sent.

Written for commit 380709f. Summary will update on new commits.

Review in cubic Turn on auto-fix

Note

Sync Saloon correctness, caching, and rate-limit improvements

  • Ports fixes and features from upstream Saloon into the unified Hypervel package, recorded in sync.yaml
  • SaloonManager.send now checks the response cache before mock matching, skips recording cache hits as mock sends, and applies a default RetryPolicy when the request has none (SaloonManager.php)
  • Adds the CalendarWindow rate-limit policy with Redis-time-based window validation and retry for stale boundaries (CalendarWindow.php, RedisStore.php)
  • Response::toException now maps common 4xx/5xx statuses to dedicated exception classes like TooManyRequestsException and InternalServerErrorException; pool workers call throw() so HTTP errors reach the pool exception handler
  • Other fixes: multipart Content-Type is replaced when it lacks a boundary, PoolException picks the right predecessor, and fixture path traversal is rejected
  • Behavioral Change: PendingRequest loses cache()/rateLimiter() accessors and constructor deps; SaloonManager loses sender(), cache(), rateLimiter() accessors; BasicAuthenticator uses an Authorization header instead of transport auth; AccessTokenAuthenticator requires DateTimeImmutable expiry; Request drops newInstance()

Macroscope summarized 380709f.

Ports Saloon's store, request property, query, config and timeout tests
(saloonphp/saloon v4 at 2f45e526f9d1) with their fixtures, merging the
existing Hypervel coverage into the upstream-named files under
tests/Saloon/{Unit,Feature,Fixtures}.

Requests keep the fluent API of Hypervel's HTTP client instead of
upstream's ArrayStore objects. The tests showed missing capabilities,
now added in that style:

- withoutHeader() and withoutHeaders() remove headers ignoring case.
- withoutQueryParameters() removes parameters added through the query
  parameter methods. On a pending request it also clears the finalized
  URI, so a removal after finalizeUri() takes effect.
- withoutOptions() removes options. Unlike a null option, removal lets
  the connector value or the HTTP connection's configuration apply.

Removing a value from a request leaves connector defaults in place,
because connectors are read-only and merged into the pending request;
remove those from the pending request.

The HasTimeout plugin is ported. Plugins boot after connector and
request options are merged, so declared timeouts only fill options that
neither the declaring class nor the request sets. A request's declared
timeout therefore beats connector options, where upstream lets the
connector silently override it. Undeclared getters return null instead
of upstream's Config defaults, so the HTTP connection's timeouts apply.

IntegerRepository only held the request delay. HasDelay now stores a
nullable int, preserving the default hook and an explicit zero.

The README records the fluent API, read-only connectors and the
HasTimeout getter difference. The guide documents the removal methods
and HasTimeout. The six Saloon sync.yaml notes now describe how each
upstream repository maps into the unified package; checkpoints stay
unset until the reconciliation finishes.

Two upstream QueryParameterTest cases used a connector without default
query parameters, so they could not test overwriting one; they now use
QueryParameterConnector.

Validation: Saloon suite (393 tests) with ParaTest,
tests/Integration/Saloon against the engine test servers,
tests/Data/Saloon, TelescopeTagTest and AfterEachTestSubscriberTest;
composer analyse; php-cs-fixer.
Ports Saloon's body repository, body trait, multipart value and
serialization tests (saloonphp/saloon v4 at 2f45e526f9d1) with their
fixtures, merging the existing Hypervel coverage into the upstream-named
files under tests/Saloon/{Unit,Feature,Fixtures}. The two cases that call
upstream's live test API run against a new /mixed-multipart route on the
engine test server under tests/Integration/Saloon. This includes the
current HasMultipartBodyTest from saloonphp/saloon#561; that PR's sender,
retry and dependency changes remain for later slices.

Every request has the body methods, so there is no HasBody contract or
ChecksForHasBody check. ArrayBodyRepository stays abstract, because
upstream's concrete class throws when converted to a stream, and
MultipartBodyRepository::boundary() is getBoundary() again, as upstream.

Defects fixed:

- attach() read the body property directly, so attaching to a request
  whose default multipart body was not yet loaded discarded it.
  asMultipart() also discarded attached values; both now keep an
  existing multipart body.
- A multipart body kept any existing Content-Type, so a JSON body
  trait's default, an earlier asJson() or a connector's JSON default
  header sent the body without its boundary. A content type without a
  boundary is now replaced, in prepared requests and pre-send PSR
  snapshots; one that declares a boundary is kept. The HTTP client had
  the same defect for Http::asJson()->attach() and connection default
  headers, and now removes such a header from multipart requests so
  Guzzle adds the boundary.
- Guzzle streams close their resource when destroyed. A PSR snapshot
  taken in request middleware or a SendingSaloonRequest listener
  therefore closed a resource body or attached file before sending.
  StreamBodyRepository now wraps a resource once and shares the wrapper
  across copies; MultipartValue caches its wrapper at first
  serialization.
- List-valued multipart part headers, which SDK-generated forms pass,
  failed in Guzzle; they are joined like repeated HTTP fields.
- Two upstream tests checked all() instead of get(), or never called the
  set() they described.

attach() also accepts an array of argument tuples, like Http::attach().
withData() on a multipart body appends the values as fields, like
Http::attach()->post($url, $data). For that, MultipartValue also accepts
booleans, null and arrays without a filename or headers, which Guzzle
sends as fields; upstream's invalid empty-array case is now valid.

The README records the body differences, and the guide documents the
multipart behavior, the array form of attach() and connector-owned
JSON flags.

Validation: Saloon suite (470 tests) with ParaTest, tests/Http
(1,018 tests), tests/Integration/Saloon and tests/Integration/Engine
against the engine test servers, tests/Data/Saloon; composer analyse;
php-cs-fixer.
Ports Saloon's request, connector, pending request, URL helper, clone,
conditionable, plugin and API version tests (saloonphp/saloon v4 at
2f45e526f9d1) with their fixtures, merging the existing Hypervel
coverage into the upstream-named files under tests/Saloon. Cases that
call upstream's live test API run against new /user and /error routes on
the engine test server under tests/Integration/Saloon. This completes
saloonphp/saloon#526, #549 and #564 and the QUERY method commit
538994a591, which Hypervel already supported, plus the request and URL
parts of #539 and #542.

New APIs:

- PendingRequest::withMethod() and withUrl() change one operation
  without touching the request. uri() replaces upstream's getUrl().
- withUrlParameters() fills URI template placeholders in the base URL,
  endpoint or URL override, as Hypervel's HTTP client does. The package
  now requires guzzlehttp/uri-template directly.
- HasApiVersion and VersionMode. Connectors are read-only, so there is
  no setApiVersion(); a class assigns its version in the constructor or
  overrides getApiVersion(). A request's version replaces its
  connector's in every mode.
- createPendingRequest() on the manager, connectors and requests
  prepares a request without sending it. Mock clients are still chosen
  when the request is sent, so it takes no mock client.
- HasConnector, which SoloRequest now uses.

Defects fixed:

- Authentication ran before plugins, so a cookie authenticator recorded
  an API-version host template before the version plugin filled it in,
  and the cookie was never sent. Plugins now boot first, as upstream.
  An authenticator a plugin selects is applied once after every plugin
  boots, and the authenticator it replaced is no longer applied.
- Relative endpoints with a colon in the first segment, such as
  documents:batchGet, were parsed as a scheme and rejected, and //search
  as a host. Only scheme:// endpoints are absolute now.
- Merging query parameters into a URL with empty query segments
  produced && or a leading &.
- A header written as a list item, such as ['Accept: application/json'],
  caused a TypeError in a JSON-body request. It now fails with
  upstream's message when the pending request is created.
- Upstream's Conditionable argument tests asserted inside the
  callbacks, so they passed if the callbacks never ran.

Upstream's Macroable tests are covered by the framework's
SupportMacroableTest, which gains private member cases. The README
records the kept differences: resolveResponseClass(Response),
allowsBaseUrlOverride() methods, no mock client on
createPendingRequest(), and pending withMethod() and withUrl(). The
guide documents the new APIs, endpoint path rules and the order of
plugins and authentication.

Validation: Saloon suite (574 tests) with ParaTest,
tests/Integration/Saloon and tests/Integration/Engine against the engine
test servers, tests/Data/Saloon, SupportMacroableTest and
FacadeDocblocksTest; composer analyse; php-cs-fixer.
Ports Saloon's config, sender, Guzzle sender, response, request
exception, PSR, AlwaysThrowOnErrors, CastsToDto, data object wrapper
and debug tests (saloonphp/saloon v4 at 2f45e526f9d1) with their
fixtures, merging the existing Hypervel response, sender and debug
tests into the upstream-named files. Cases that call upstream's live
test API run against the engine test server under
tests/Integration/Saloon. This completes saloonphp/saloon#522, #525,
#534, #535, #537, #552 and #554, plus the sender test changes of #561
and the config parts of #518 and #557. #475 (a default handler stack)
is excluded, since HTTP connections own the transport handler.

New APIs:

- Upstream's 14 status exceptions, such as NotFoundException and
  TooManyRequestsException, chosen by status in
  Response::newRequestException() after getRequestException(). They
  extend ClientException or ServerException, so existing catches still
  apply.
- Response::isJson(), isXml() and array().

Defects fixed:

- A transfer that failed after the response headers arrived escaped
  Saloon's error handling for a 4xx or 5xx status, as the HTTP client's
  RequestException. Sender now reports every incomplete transfer as a
  connection failure, so it throws FatalRequestException, runs the
  fatal middleware and is retried.
- dtoOrFail() uses upstream's message.

Adaptations, recorded in the README:

- Upstream's Config maps to Saloon::middleware(), the
  saloon.connection settings, the framework Date, Sleep::fake() and
  Http::preventStrayRequests(). Sender, clock and sleep resolver cases
  are removed.
- Responses keep the HTTP client's header(), headers(), json() and
  object(), and their getters drop upstream's get prefix.
- Request exceptions extend the HTTP client's, so
  getRequestException() receives no sender exception and custom
  messages override prepareMessage().
- Connector debuggers register from boot(). die: true throws Swoole's
  ExitException inside a coroutine, so there is no die handler.
- xmlReader() is excluded: it only wraps XML Wrangler, whose
  dependency tree and Laravel collection helpers do not fit Hypervel.
  The docs show XmlReader::fromPsrResponse($response->toPsrResponse()).

Also adds the missing setup and teardown docblocks in the Saloon tests.

Validation: Saloon suite (665 tests) with ParaTest,
tests/Integration/Saloon and tests/Integration/Engine against the engine
test servers, tests/Data/Saloon; composer analyse; php-cs-fixer.
Ports Saloon's middleware pipeline, pipeline, retry connector, retry
request and delay request tests (saloonphp/saloon v4 at 2f45e526f9d1),
merging the existing Hypervel pipeline and delay tests into the
upstream-named files. Live retry cases run against a new /header-error
route on the engine test server. This completes saloonphp/saloon#557
and the retry test changes of #561.

New APIs:

- defaultRetryPolicy() on connectors and requests, like defaultDelay().
  An explicit request policy wins, then the request default, then the
  connector default. A request's policy replaces the connector's
  entirely, so retry(1) turns off connector retries.

Defects fixed:

- Middleware pipelines formed a reference cycle through their wrapper
  closures, so they stayed alive until garbage collection. The
  wrappers are now static.
- A mock response that threw FatalRequestException skipped the fatal
  middleware and retries. It now runs the fatal pipeline once and
  follows the retry policy, keeping the thrown exception.
- The request's middleware ran first and global middleware last. Each
  send now builds its own pipeline in upstream's order: global,
  plugins, connector boot(), request middleware, request boot(). The
  global and request pipelines are never changed, so repeated sends
  and retries do not accumulate middleware.
- Connectors could only set retries from boot(), which overrode a
  request's explicit retry(). Connector defaults now come from
  defaultRetryPolicy().
- Mocked responses skipped the request delay. The delay now runs before
  every attempt, real or fake, but not for cache hits.

Adaptations, recorded in the README:

- Middleware contracts are typed, a returned pending request is
  ignored, and the pipeline getters drop the get prefix.
- Upstream's retry properties and handleRetry() map to retry() and
  defaultRetryPolicy(). Request changes move to the when callback
  through $pendingRequest->request(), and exponential backoff to a
  delay closure.
- Deprecated sendAndRetry() is not ported; its exponential backoff case
  moves to RetryRequestTest.
- Fewer than one attempt or a negative interval throws.

Also adds the missing method titles and closure return types in
SaloonManagerTest.

Validation: Saloon suite (728 tests) with ParaTest,
tests/Integration/Saloon and tests/Integration/Engine against the
engine test servers, tests/Data/Saloon; composer analyse;
php-cs-fixer.
Adds title docblocks to helper methods in Saloon test files changed by
the earlier reconciliation commits, including the fixture name data
provider's return type. Docblock-only; each file's tests pass.
Ports Saloon's authenticator, authenticates-requests, access-token
authenticator, OAuth config, auth-code flow, client-credentials flow
and absolute OAuth endpoint tests (saloonphp/saloon v4 at
2f45e526f9d1) with their fixtures, merging the existing Hypervel
authentication, OAuth config and OAuth grant tests into the
upstream-named files. The OAuth request body tests move to
Unit/Oauth2. This completes saloonphp/saloon#516, #518's OAuth expiry
cases, #539's access-token authenticator and #542's OAuth endpoint
cases.

Defects fixed:

- Token expiries were typed as CarbonInterface, so code passing a
  plain DateTimeImmutable, as upstream does, failed, and a mutable
  Date configuration produced mutable expiries. The contract,
  AccessTokenAuthenticator and the grant factories now take
  ?DateTimeImmutable, and framework expiries copy the Date clock into
  an exact CarbonImmutable.
- The authenticators were readonly classes, so subclasses that add
  state failed to compile. They keep read-only properties instead.
  AccessTokenAuthenticator now builds its header from getAccessToken(),
  so an overridden getter reaches the request.
- The client-credentials grant shared the auth-code factory signature,
  so upstream's documented two-argument override lost the expiry or
  failed. Each grant now owns upstream's factory signatures, and
  ParsesOAuthTokenResponses only parses and validates the response.
- A PKCE verifier was only added by the default token request, so a
  custom resolveAccessTokenRequest() dropped it. The verifier is now
  added to the resolved request before the modifiers run, and
  resolveAccessTokenRequest() takes upstream's two arguments.
- Basic authentication was a transport option, so mocks, middleware
  and assertions could not see it, and combining it with a token sent
  a different header from upstream. It now sets the Authorization
  header.
- Authorization URLs used a different parameter order, dropped falsy
  extra parameters such as max_age=0, and could not replace standard
  parameters such as response_type. They now follow upstream's order,
  keep falsy values and let extra parameters replace everything except
  state.
- Null and empty scopes are dropped from authorization URLs and
  client-credentials token requests alike, through
  OAuthConfig::scopes(), and a "0" scope is kept. Client-credentials
  requests previously sent stray separators.

Adaptations, recorded in the README:

- OAuthConfig::validate() returns true and the validation and refresh
  messages match upstream; getUser() takes the OAuth authenticator.
- Deprecated withTokenAuth(), withQueryAuth(), withHeaderAuth() and
  withCertificateAuth() are not ported, withDigestAuth() takes no
  digest type, and authenticator() replaces getAuthenticator().
- Authenticator properties stay read-only because an authenticator may
  be shared by concurrent requests.

Validation: Saloon suite (815 tests) with ParaTest,
tests/Integration/Saloon against the engine test servers,
tests/Data/Saloon; composer analyse; php-cs-fixer; the private SDK
generator suite against this branch.
Ports Saloon's mock client, mock client assertion, mock response,
mocks-requests, global mock client, mock config, fixture data, fixture
path traversal, simulated response payload and mock request tests
(saloonphp/saloon v4 at 2f45e526f9d1) with their fixtures, merging the
existing Hypervel mock client, fake response, fixture and recorded
response tests into the upstream-named files. This completes
saloonphp/saloon#539's fixture, storage and mock changes, #548
(MockClient::withoutCache()) and #563 (typed assertSent closures).

Defects fixed:

- URL patterns for mock responses, URL assertions and allowed stray
  requests needed the full URL, so upstream's path patterns such as
  '/user' or 'api.example.com/*' never matched. One matcher now
  prefixes the pattern with '*' and matches the URL with or without
  its query string; a pattern that includes a query still matches only
  that query.
- Typed assertion closures only understood class names and unions, so
  a mixed, object or intersection parameter threw. They now use
  upstream's recursive type check: mixed and object match any request,
  other built-in types none, and intersections need every part.
- Fixture header redaction rules were applied to whole header values,
  so upstream-style closures failed on list headers and headers with
  several values, such as Set-Cookie, were stored unredacted. Rules now
  apply to each value, case-insensitively, and only closures are
  called, so callable-looking strings stay literal.
- Fixtures whose data is a JSON object failed to load. Array data now
  survives loading, replay and merging; storing encodes it before
  redaction, and base64 applies only to binary strings.
- A fixture's own context overrode the recorded response's context,
  including beforeSave() changes. The recorded context now wins
  without changing the fixture.
- Nine request fixtures declared upstream's $connector property and
  DefaultPropertiesRequest a defaultData() method, though nothing reads
  either. Both are removed.

Upstream changes:

- MockClient::withoutCache() keeps requests that reach the transport,
  such as recording a missing fixture, out of the response cache.
- NoMockResponseFoundException uses upstream's message.

Adaptations, recorded in the README:

- Connectors take no mock client: attach one to the request, pass it to
  send() or use Saloon::fake(), which also backs MockClient::global().
- recorded(), lastRequest(), lastPendingRequest(), lastResponse() and
  match() replace upstream's getters; assertSentJson() is not ported.
- MockConfig maps to the saloon.fixtures.path configuration, fixtures
  are read and written through Filesystem instead of upstream's Storage
  helper, getFixturePath() is absolute, and recorded fixtures list
  every header value.

Validation: Saloon suite (895 tests) with ParaTest,
tests/Integration/Saloon against the engine test servers,
tests/Data/Saloon; composer analyse; php-cs-fixer. The private SDK
generator was checked by source: it uses only APIs this change keeps.
Ports Saloon's pool tests (saloonphp/saloon v4 at 2f45e526f9d1) and
maps its asynchronous request tests onto pools, merging the existing
Hypervel pool tests into the upstream-named files. Upstream's live pool
case runs against the engine test server and replaces
SaloonClientTest's pool case.

Defects fixed:

- Pooled requests ignored throwable responses: a 4xx or 5xx response,
  or one a request or connector failure hook marks as failed, went to
  the response handler and came back from send() as a success. Pool
  requests now call throw(), so every response toException() treats as
  failed reaches the exception handler, or PoolException::failures()
  when there is none. Upstream's real asynchronous path checks these
  hooks only after Guzzle rejects a transfer; Hypervel applies the rule
  to every response.
- PoolException had no previous exception unless scheduling failed, so
  reported pool failures hid their cause. It now chains the failure
  that stopped scheduling, otherwise the first request failure, or the
  first callback failure if no request failed.

Adaptations, recorded in the README with source comments where upstream
methods are omitted:

- Pools send through coroutines instead of promises: there is no
  sendAsync(), isAsynchronous(), setAsynchronous() or promise item.
  send() returns the successful responses keyed and ordered by input,
  and process() sends without keeping them.
- Concurrency is a positive integer; upstream's callable and unlimited
  forms are not supported.
- Handlers receive the response or exception and the key, without the
  aggregate promise.
- Unhandled request, callback and item failures throw PoolException
  once every started request has finished.
- Ending a request generator stops further requests, but one already
  waiting for a slot still starts.
- requests() replaces getRequests() and runs a request callback on each
  send.

The pool docs now cover failed responses, the exception cause, sending
a pool again and stopping early.

Validation: Saloon suite (910 tests) with ParaTest, tests/Integration/
Saloon against the engine test servers; composer analyse;
php-cs-fixer. The private SDK generator was checked by source: it uses
pools only through paginators, whose throw middleware already fails
pages.
Ports the Laravel plugin's events, mock request, simulated response
payload and mock client assertion tests (saloonphp/laravel-plugin at
47d06ef8621a) into tests/Saloon/Hypervel on the core fixtures, which
the plugin's fixtures copy. The plugin's pool and request tests repeat
core cases and are covered by the core tests, noted there.

Defects fixed:

- Event::fake() and Event::fakeFor() did not reach Saloon or the HTTP
  client once they were resolved, because SaloonManager and the HTTP
  client factory keep the dispatcher they were built with. Both
  providers now rebind the events service onto the resolved services
  (setEventDispatcher(), setDispatcher()), as Laravel's
  AuthServiceProvider does, so fakes apply and are removed afterwards.
  Both Saloon request events use Dispatchable.
- Saloon::fake() replaced the global mock client, discarding earlier
  fakes, recorded responses and settings. It now adds responses to the
  existing client, as upstream; passing a MockClient replaces it.
- A null integrations namespace always produced App\Http\Integrations,
  whatever integrations path was configured. It now follows the path
  beneath the application directory, and a path outside it requires
  the namespace unless --target-namespace is given.
- saloon:list garbled endpoints that used interpolation, concatenation,
  dots inside literals or escaped quotes, such as SharePoint's
  getbytitle('...'), and did not read double-quoted base URLs.

Not ported, recorded in the README with source and test notes: the
plugin's deprecated response recorder, its MockClient subclass and the
deprecated assertSentJson() facade method.

Validation: Saloon suite (ParaTest), tests/Http, the HTTP client
consumers in Integration/Http, Reverb webhooks and Inertia, Data's
Saloon tests, FacadeDocblocksTest, composer analyse and php-cs-fixer.
The private SDK generator was checked by source: it uses neither
global fakes nor the generators.
The Laravel plugin (saloonphp/laravel-plugin at 47d06ef8621a) adds
Telescope, Pulse and Nightwatch recording middleware to its own Guzzle
sender. Saloon here sends through the HTTP client, so Telescope's
ClientRequestWatcher already records its requests with the saloon,
connection and request tags, and the plugin's Telescope middleware
would duplicate it. Hypervel has no Pulse or Nightwatch package. The
README, service provider and sender test record these exclusions.

Coverage added for the plugin's cases the existing tests did not
assert:

- ClientRequestWatcherTest decodes hal+json (with and without a
  charset) and vnd.api+json request bodies, and decodes and masks a
  hal+json response.
- GuzzleSenderTest checks that Http::globalMiddleware() runs on Saloon
  requests, which OpenTelemetry's HTTP client instrumentation relies on.
- TelescopeTagTest asserts the Turbopuffer tag value.

Validation: ClientRequestWatcherTest, GuzzleSenderTest, SenderTest,
TelescopeTagTest, the Saloon suite (ParaTest) and php-cs-fixer.
The cache plugin (saloonphp/cache-plugin at 5a036bae401d) reads the
cache before mock responses are matched, lets MockClient::withoutCache()
skip the cache (#18), and adds clearCache() to forget a cached response
without sending the request (#17).

- SaloonManager::send() now reads or invalidates the cache before
  matching the mock client, so a hit consumes no mock response or
  fixture. Mock, fixture and transport responses share one write path,
  and withoutCache() bypasses reads, writes and invalidation. A fake
  supplied by request middleware still bypasses the cache.
- Responses are written unless failed(), as upstream, so request and
  connector failure hooks apply. Previously a hook-rejected 200 was
  cached and replayed to every retry.
- SaloonManager::clearCache() prepares the request as send() does and
  forgets the key from the same store, key and scope. Connector and
  HasCaching delegate to it; native parameter types replace upstream's
  wrong-counterpart exceptions.
- The service provider allows CachedResponse through the cache's
  serializable-class policy. Without it, every serializing store
  (file, database, Redis) missed silently.
- Cache hits are no longer recorded on the mock client, matching
  upstream, since nothing was sent.

Cache stores replace the plugin's drivers, so its PSR adapter test is
REMOVED and its assertions are covered by the native-store tests, and
the Laravel driver test is a file-store round trip. Upstream's
real-request case runs against the engine test server.

The laravel plugin (#76) fixed its Telescope middleware's return type
for scalar JSON response bodies; its body sanitizing still records them
as HTML or empty, as Telescope does. ClientRequestWatcher and
RequestWatcher now record valid scalar JSON under a JSON media type as
sent, within size limits, and label only an empty body as empty.

Validation: the Saloon and Telescope suites (ParaTest),
tests/Integration/Saloon on the engine test servers, the changed test
files after review corrections, FacadeDocblocksTest, composer analyse
and php-cs-fixer.
Ports the pagination plugin's feature tests (saloonphp/pagination-plugin
at a61822ca93c1) into tests/Saloon/Pagination/Feature, running against
an Http::fake of the public superhero endpoints they call. #17's
rewind case is ported in CollectTest; rewind() already cleared the
body checksums.

Defect fixed: the infinite-loop check was a response middleware
closure bound to the paginator, so the paginator's own request held
the paginator through its pipeline. A dropped paginator stayed in
memory until the cycle collector ran, and any kept response kept it
alive too. The check now runs in Paginator::current() after the send
and before item mapping, keyed by page so a mapping retry replaces
its checksum, and is skipped while pooling.

Upstream's protected getBodyChecksum() is restored (xxh128 of the
body), so APIs that add a per-request value can compare page contents
only, and so is upstream's exception message, naming pages instead of
requests. The guide shows the override.

Test adaptations: async() and promise iteration are REMOVED, with the
async cases traversing through pool(); the imported offset connectors
end at getOffset() >= total and the async total pages round up, so a
partial last page ends correctly. PaginatorTest drops cases now
covered upstream and adds the dropped-paginator, checksum override and
request-middleware cases. The README records the renamed and omitted
paginator methods, pool() in place of async pagination, unpoolable
cursors and zero-based keys counted from the start page.

Validation: each changed test file, the Saloon suite (ParaTest),
composer analyse and php-cs-fixer. A scratch run at 7 per page
confirmed the corrected fixtures fetch 3 pages with all 20 items.
Ports the rate-limit plugin's feature, unit and job middleware tests
(saloonphp/rate-limit-plugin at aed5ff488e4d) into
tests/Saloon/RateLimit/{Feature,Unit,Hypervel/Feature}, sending through
Http::fake() because Saloon fakes and cache hits make no provider call
and consume no capacity.

- A 429 whose Retry-After is missing, malformed or too large for the
  rate limiter records upstream's 60-second cooldown (#28). A valid
  zero or past delay records none.
- waitForRateLimits() now receives the denied policy and its decision,
  so a resource can wait out a burst limit and still throw for a daily
  quota. Nothing is resent from middleware: waiting for Cooldown with a
  429 retry policy waits out the cooldown and sends again through the
  manager's bounded attempt loop with the original connector (#27, #29).
- resolveRateLimiterName() (default: the class name) names the limiter
  saloon:{name} for admission, cooldowns and inspection, so resources
  returning the same name share a quota and its cooldown. The default
  cooldown key is empty, so cooldowns follow the name.
- Saloon::inspectRateLimit() checks a resource's policy or cooldown
  without consuming it, through the send path's store and name, in
  place of hasReachedRateLimit() and getExceededLimit().
- rateLimitingEnabled(PendingRequest) skips a resource's admission,
  cooldown checks and cooldown recording for one operation, in place of
  useRateLimitPlugin() and $rateLimitingEnabled.
- RateLimitReachedException names the limiter, with the policy key when
  set, so a log shows which integration was blocked.

Test adaptations: the Limit::custom() case is REMOVED (custom responses
override resolveRateLimitCooldown()), and so is the unconfigured-limit
case (rejected when the limit is created). TestConnector takes the
limits to wait for in place of sleep(). JobMiddlewareTest dispatches
real jobs through ReleaseOnRateLimit and absorbs ReleaseOnRateLimitTest.
RateLimitTest drops the cases now covered upstream. The README and the
guide cover sharing, inspecting and disabling limits and the 60-second
fallback; the sync note records the test mapping.

Validation: each changed test file, the Saloon and RateLimiter suites
(ParaTest), FacadeDocblocksTest, composer analyse and php-cs-fixer.
The rate-limit plugin (saloonphp/rate-limit-plugin at aed5ff488e4d)
offers limits that reset on the clock: untilEndOfMinute(),
untilEndOfHour(), untilMidnightTonight(), everyDayUntil() and
untilEndOfMonth(). Hypervel's rate limiter only had windows measured
from the first operation, so a provider quota that resets at midnight
could not be matched.

CalendarWindow is a new admission policy that resets at the start of
each minute, hour, day (at an optional HH:MM[:SS] time) or month, in
the application's timezone or one given with timezone(). It follows
the local clock across daylight saving changes:

- Hourly windows are clipped at offset changes, so a repeated hour is
  its own window and half-hour shifts (Lord Howe Island) do not overlap.
- A daily reset time that happens twice resets at its first occurrence,
  resolved explicitly because PHP's parser picks different occurrences
  in different zones. A skipped reset time moves forward that day.

Stores count a calendar window like a fixed window; new state expires
at the window's end. The Redis store passes the window's start and end
computed from Redis TIME, and the script rejects a stale window before
writing, so a PHP clock in another period cannot charge the wrong one.
A stale group discards its tentative charges and retries. Key identity
includes the period, reset time and timezone.

Upstream's Limit, Bucket, LimitStore and store tests map case by case
to the rate limiter's store contract and integration tests;
Unit/HelperTest is ported and absorbs RateLimitTest's Retry-After
parsing cases. There are no file, cache or Predis stores: a general
cache can't apply the policies atomically, and Redis uses PhpRedis.
Identical policies share state instead of throwing upstream's
duplicate-name exception. The README, rate-limiting guide and sync
note record the mapping.

Validation: CalendarWindowTest, the store contract on worker-array,
Swoole, database (SQLite, MySQL, MariaDB, PostgreSQL) and Redis stores,
KeyResolverTest, LimiterTest, the Saloon rate-limit tests, the
RateLimiter and Saloon suites (ParaTest), composer analyse and
php-cs-fixer.
Requests were SelfBuilding with a newInstance() that called
new static(), so app()->make() on a request ignored make() parameters
and constructor injection and threw ArgumentCountError for any request
with constructor arguments. Request now implements Transient, which
the container added after the request lifecycle: unbound resolutions
stay fresh while dependencies are injected and parameters apply.

A comparison of saloonphp/saloon's public surface (v4 at 2f45e526f9d1)
with the port found:

- PendingRequest took a cache factory and rate limiter it never used,
  through cache() and rateLimiter() getters with no callers. Both are
  removed from its constructor. The lifecycle methods only the manager
  calls are marked @internal, like createPsrRequest().
- PipeOrder had renamed upstream's FIRST and LAST cases without
  approval; upstream's names are restored.
- withUserAgent() and withoutVerifying() had no tests.
- Several renamed or omitted upstream members were missing from the
  README: the get-prefixed request and pending-request getters,
  setBody(), hasFakeResponse(), getResponseClass(), sender and PSR
  factory arguments, fromPsrResponse(), createException(),
  withBufferedBody(), getSenderException(), getStatusMessage(),
  send()'s $handleRetry argument, and request exceptions no longer
  being SaloonExceptions. Source comments mark the omitted members.

The Laravel porting guide gains a Saloon section covering the
namespaces, read-only connectors and fluent methods, the transport
and the exception catch.

Validation: RequestTest, HeadersTest, ConfigTest and each changed test
file, the Saloon suite (ParaTest), tests/Data/Saloon, the Saloon
integration tests on the engine servers, composer analyse and
php-cs-fixer.
SaloonManager exposed sender(), cache() and rateLimiter(), which
returned the injected HTTP sender, cache factory and rate limiter
unchanged. Nothing called them, and none carried Saloon-specific
configuration: the cache and rate-limiter services are available
through the framework's own facades and contracts, and Saloon's own
operations on them stay available through clearCache() and
inspectRateLimit(). The getters and their facade annotations are
removed.

The package no longer requires hypervel/reflection, which it never
used. The package metadata test now lists guzzlehttp/uri-template,
which the package already requires. Saloon's Guzzle 8 and PSR-7 3
constraints are unchanged; the suite runs on Guzzle 8.2.

The make:saloon stubs regain upstream's placeholder comments and the
authenticator's empty constructor. The HTTP connection config comment
now describes the connection plainly.

Three unit tests still built pending requests through a one-line
helper left from the previous constructor change; they now construct
PendingRequest directly.

A send benchmark without network access measured about 200 us per
Saloon request against about 130 us for the same HTTP client call,
spread over small steps; no change is needed.

Validation: each changed test file, the Saloon suite (ParaTest),
tests/Data/Saloon, FacadeDocblocksTest,
Composer/PackageManifestConsistencyTest, composer analyse and
php-cs-fixer.
Comparing the public and protected surfaces of upstream's cache,
pagination and rate-limit plugins with Hypervel's found no code gaps,
but the README did not name the Hypervel equivalents for several
upstream APIs. It now covers CacheKeyHelper, the cache middleware and
expiry methods, LimitException, Limit::allow()'s threshold,
Limit::custom(), RetryAfterHelper, $detectTooManyAttempts, the
exceeded-limit hooks and per-user limit names.

CacheKey is no longer final, since it protects no invariant. A short
comment explains why it hashes with sha256 instead of xxh128: the
request identity includes credentials, so a forged collision would
return another caller's cached response.

The private SDK generator and generated SDKs were verified against
this branch, with two test updates made in that repository.

Validation: the Saloon suite (ParaTest), tests/Saloon/Cache,
composer analyse and php-cs-fixer; the private SDK generator suite,
its analysis configurations, the seven generated SDK test suites and
generated-code analysis.
The guide was compared with Saloon v3's documentation from connectors
through concurrency and testing, and every claim and example was
checked against Hypervel's source.

Corrections: the shouldThrowRequestException example could never stop
a 404 from throwing, since a response throws when either the request's
or the connector's hook returns true. The guide now separates failure
classification from throwing, retries and caching. Request data
conversion applies to Hypervel's Stringable, not any stringable value.
The differences list no longer mentions OAuth 1, which upstream never
had.

Added coverage includes the HTTP connection defaults and rejected
request-shaping options, standalone request credentials, query string
encoding, built-in and combined authenticators, multipart bodies,
request-instance middleware and fake responses, data objects, the
status exception table, retry forms and exhaustion, debug output,
OAuth endpoint defaults, scopes, state and token storage, pool
concurrency, mock client assertions and fixture recording.

Fixture recording stays subject to Http::preventStrayRequests(); the
guide and README say to allow the API's URLs while recording.

Validation: documentation only; every in-page link resolves to an
anchor.
The rest of Saloon v3's documentation was compared with the guide:
pagination, caching, rate limits, the Laravel plugin, building
plugins, the how-to guides and the upgrade and known-issue pages.
Every claim and example was checked against Hypervel's source.

Added: plugin boot methods must be public, plugins change the pending
request, and a plugin on both a connector and its request boots twice.
Fetching a token from a connector's boot method, which can't be
combined with RequiresAuth because its check runs first. Choosing the
cacheable methods. Paginators yield one page response at a time, keep
only that page in memory and throw for a failed page; iterator_count
cannot find the last page; the default page size, offsets and custom
paginators. Saloon accepts fixed, calendar and sliding windows and
leaky buckets, per-user by keys, the exception's wait time, the
default store and disabling 429 cooldowns. Requests require TLS 1.2
unless the connection changes crypto_method.

The README's differences heading now names Saloon and lists all five
upstream repositories. The sync notes say where future upstream
changes, plugin test cases and documentation belong, and which
upstream pages have no Hypervel equivalent. A colon in one note made
sync.yaml invalid YAML; it parses again.

Validation: documentation only. A nested send from a connector's boot
method was confirmed with a temporary test. Every guide link and README
anchor resolves.
The Saloon tests that 0.4 updated for PHPUnit's exception message
assertions were consolidated into upstream-named tests on this branch,
so they stay deleted. The Telescope watcher tests keep this branch's
added cases with 0.4's void return types.
PHPUnit 13.3 deprecates expectExceptionMessage(). The Saloon and
calendar-window tests changed on this branch now use
expectExceptionMessageIs() for complete messages, which is stricter,
and expectExceptionMessageIsOrContains() where a test checks part of a
message: delay validation, the base-URL replacement message's first
sentence, and a missing fixture's environment-dependent path. The
fixture-name and duplicate-pipe expectations now include the messages'
final period.
Upstream Saloon's Laravel plugin, laravel-data and laravel-permission each
ship a root ide.json with code-generation templates for the Laravel Idea
PhpStorm plugin. Hypervel does not ship this metadata for any of them.

The Saloon README lists it with the other plugin features that are not
included and points to the saloon:* generator commands instead. The Data
and Permission sync notes record it, since neither README covers it.
Set the checkpoints for Saloon and its four plugins (Laravel, cache,
pagination and rate limiting), plus the shared Saloon docs:

- saloonphp/saloon v4 through 2f45e526f9d1 (#564)
- saloonphp/laravel-plugin v4 through 47d06ef8621a (#89)
- saloonphp/cache-plugin v3 through 5a036bae401d (#17)
- saloonphp/pagination-plugin v2 through a61822ca93c1 (#17)
- saloonphp/rate-limit-plugin v2 through aed5ff488e4d (#31)
- Sammyjo20/saloon-docs v3 through 8ea96847141f (#92)
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: hypervel/components-backup/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bf23b320-17f7-4682-85ed-e9b2b09e4237

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider

Great, no issues found!

Qodo reviewed your code and found no material issues that require review

Grey Divider

Tip of the day
💡 Did you know, you can route each severity your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Reconcile Saloon with upstream v4: correctness, caching and rate-limit fixes

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Ports Saloon v4 core and its Laravel, cache, pagination and rate-limit plugin test suites, merging
 in Hypervel's own tests to catch and fix long-standing bugs.
• Fixes correctness issues across URL resolution, multipart/stream bodies, OAuth, mocking/fixtures,
 pooling, middleware ordering, caching and pagination so Saloon-authored code behaves as documented.
• Adds CalendarWindow, a clock-aligned rate-limit policy (minute/hour/day/month resets), plus
 Redis store support and cooldown handling that matches Saloon's 429 semantics.
• Updates shared framework pieces: HTTP client drops boundary-less multipart Content-Type, accepts
 a rebound event dispatcher for Event::fake(), and Telescope now shows scalar JSON bodies as sent.
• Removes unused API surface (SaloonManager::sender()/cache()/rateLimiter()), restores
 Saloon-compatible enum/stub names, and documents every intentional Hypervel/Saloon difference in a
 new guide.
Diagram

graph TD
  A["Connector/Request"] --> B["SaloonManager.send()"]
  B --> C["PendingRequest pipeline"]
  C --> D{"Cache hit?"}
  D -->|yes| E["CachedResponse"]
  D -->|no| F{"Mock matched?"}
  F -->|yes| G["MockClient/Fixture"]
  F -->|no| H["RateLimiter"] --> I["Sender"] --> J["HTTP Client"]
  H --> K["CalendarWindow / RedisStore"]
  J --> L["Response"]
  G --> L
  E --> L
Loading
High-Level Assessment

The PR's approach—reconciling against upstream Saloon by porting its test suites verbatim and fixing the discrepancies they expose—is the right strategy for a vendored/reimplemented package. It catches real behavioral drift (caching, pooling, middleware order) that unit tests written independently would likely have missed, and documents remaining intentional deviations rather than silently diverging. No meaningfully different strategic approach was apparent for this kind of upstream-conformance effort.

Files changed (33) +2033 / -463

Enhancement (9) +779 / -118
PendingRequest.phpRework pending request to own method/URL, cache/rate-limiter deps, header validation +177/-45

Rework pending request to own method/URL, cache/rate-limiter deps, header validation

• PendingRequest no longer takes CacheFactory/RateLimiter via constructor; it now owns a mutable method and URL (withMethod/withUrl/withUrlParameters), validates headers via HeaderNormalizer at construction, and inherits the connector's retry policy when the request has none.

src/saloon/src/Http/PendingRequest.php

CalendarWindow.phpAdd CalendarWindow admission policy for clock-aligned rate limits +272/-0

Add CalendarWindow admission policy for clock-aligned rate limits

• New policy resetting at the start of each minute/hour/day(at time)/month in a given timezone, following local clock across DST changes, with per-minute/hour/day/month factory helpers.

src/rate-limiter/src/CalendarWindow.php

RedisStore.phpAdd calendar window Lua algorithm with stale-window retry loop +100/-33

Add calendar window Lua algorithm with stale-window retry loop

• Adds calculate_calendar Lua function and 'calendar' algorithm wired into consume/consumeMany scripts; computes windows from Redis's own clock and retries when a worker's precomputed window is stale.

src/rate-limiter/src/RedisStore.php

HasRateLimits.phpAdd rateLimitingEnabled, resolveRateLimiterName and policy-aware waitForRateLimits +55/-14

Add rateLimitingEnabled, resolveRateLimiterName and policy-aware waitForRateLimits

• usesRateLimits() now checks rateLimitingEnabled() per operation, adds resolveRateLimiterName() to share quotas, and waitForRateLimits() receives the denied policy/decision; resolveRateLimitCooldown() applies Saloon's 60s default for bad Retry-After values.

src/saloon/src/RateLimit/Traits/HasRateLimits.php

Factory.phpAdd setDispatcher() to replace the HTTP client's event dispatcher +11/-0

Add setDispatcher() to replace the HTTP client's event dispatcher

• Allows the HTTP client factory's event dispatcher to be swapped at runtime, enabling Event::fake() to reach the already-resolved client.

src/http/src/Client/Factory.php

Connector.phpAdd API version, timeout, retry-policy and pending-request creation support +68/-5

Add API version, timeout, retry-policy and pending-request creation support

• Connector gains createPendingRequest(), defaultRetryPolicy(), and wiring for the HasApiVersion/HasTimeout plugins.

src/saloon/src/Http/Connector.php

Request.phpAdd HasConnector support and createPendingRequest() +19/-16

Add HasConnector support and createPendingRequest()

• Requests can now own their connector via HasConnector and prepare a pending request without sending it.

src/saloon/src/Http/Request.php

Response.phpAdd isJson(), isXml(), array() and status exception mapping +68/-5

Add isJson(), isXml(), array() and status exception mapping

• Response gains content-type helpers and wires the new per-status exception classes into toException().

src/saloon/src/Http/Response.php

HasCaching.phpAdd clearCache() to forget a cached response without sending +9/-0

Add clearCache() to forget a cached response without sending

• Adds a clearCache() method using the same store, key and scope as send().

src/saloon/src/Cache/Traits/HasCaching.php

Bug fix (16) +497 / -164
SaloonManager.phpFix send pipeline order: cache before mock, correct caching/retry/middleware semantics +117/-55

Fix send pipeline order: cache before mock, correct caching/retry/middleware semantics

• Reads cache before matching mocks, only caches non-failed responses, applies delay to fake responses, retries on FatalRequestException thrown by fakes, and extracts request preparation into a reusable createPendingRequest().

src/saloon/src/SaloonManager.php

Sender.phpWrap mid-transfer failures as FatalRequestException +13/-4

Wrap mid-transfer failures as FatalRequestException

• Catches HTTP client RequestException during send and rethrows as ConnectionException so Saloon's fatal middleware and retry logic run instead of leaking the HTTP client's own exception.

src/saloon/src/Http/Sender.php

HttpServiceProvider.phpRebind HTTP client dispatcher when the events service is rebound +15/-0

Rebind HTTP client dispatcher when the events service is rebound

• Registers a rebinding handler for 'events' that pushes the new dispatcher into the resolved Factory, so Event::fake()/fakeFor() work after resolution.

src/http/src/HttpServiceProvider.php

ClientRequestWatcher.phpShow scalar JSON response bodies as sent in Telescope +7/-2

Show scalar JSON response bodies as sent in Telescope

• Scalar JSON bodies such as 0, false or null are now displayed verbatim instead of being misclassified as empty or HTML.

src/telescope/src/Watchers/ClientRequestWatcher.php

RequestWatcher.phpMirror scalar JSON body fix for incoming request watcher +7/-2

Mirror scalar JSON body fix for incoming request watcher

• Applies the same scalar JSON content-type detection fix used in ClientRequestWatcher.

src/telescope/src/Watchers/RequestWatcher.php

MockClient.phpFix fake() stacking, path-based URL matching, and response-cache bypass +81/-11

Fix fake() stacking, path-based URL matching, and response-cache bypass

• Saloon::fake() now adds to the existing mock client instead of replacing it, mock patterns match the end of the URL, and withoutCache() keeps transport-level responses out of the cache.

src/saloon/src/Http/Faking/MockClient.php

Fixture.phpFix fixture JSON object loading, context precedence and per-value header redaction +22/-9

Fix fixture JSON object loading, context precedence and per-value header redaction

• Fixes loading JSON-object fixture data, recorded-response context precedence over fixture context, and redaction rules applying per header value.

src/saloon/src/Http/Faking/Fixture.php

MultipartValue.phpPreserve one stream wrapper and join list-valued part headers +39/-8

Preserve one stream wrapper and join list-valued part headers

• Keeps a single stream wrapper so Guzzle doesn't prematurely close resources, and joins list-valued headers like repeated HTTP fields.

src/saloon/src/Data/MultipartValue.php

HasBody.phpFix attach()/asMultipart() discarding existing body or files +33/-8

Fix attach()/asMultipart() discarding existing body or files

• attach() no longer discards an unloaded default multipart body, asMultipart() keeps already-attached files, and attach() accepts array argument tuples.

src/saloon/src/Traits/Body/HasBody.php

UrlResolver.phpFix absolute-URL detection and URI template expansion +29/-2

Fix absolute-URL detection and URI template expansion

• Only scheme:// endpoints are treated as absolute (fixing colon-containing relative paths and //search), and adds URI template parameter expansion.

src/saloon/src/Http/UrlResolver.php

AuthorizationCodeGrant.phpFix authorization URL parameter order, PKCE verifier loss and scope handling +66/-23

Fix authorization URL parameter order, PKCE verifier loss and scope handling

• Matches Saloon's parameter order/replacement semantics, preserves the PKCE verifier on custom resolveAccessTokenRequest(), and keeps a '0' scope while dropping empty ones.

src/saloon/src/Traits/OAuth2/AuthorizationCodeGrant.php

Paginator.phpMove infinite-loop check out of a retained middleware closure +29/-21

Move infinite-loop check out of a retained middleware closure

• Moves the infinite-loop detection into Paginator::current() to avoid a reference cycle keeping dropped paginators alive, and restores getBodyChecksum().

src/saloon/src/Pagination/Paginator.php

MiddlewarePipeline.phpFix middleware ordering and remove reference cycle in wrappers +7/-3

Fix middleware ordering and remove reference cycle in wrappers

• Builds each send's pipeline in Saloon's documented order and makes wrapper closures static to avoid a memory-retaining reference cycle.

src/saloon/src/Http/MiddlewarePipeline.php

CacheKey.phpMake CacheKey non-final and hash with sha256 +3/-1

Make CacheKey non-final and hash with sha256

• Removes final modifier for extensibility and switches to sha256 hashing since the key includes credentials.

src/saloon/src/Cache/CacheKey.php

ListCommand.phpFix endpoint parsing for interpolation, concatenation and quoted base URLs +12/-14

Fix endpoint parsing for interpolation, concatenation and quoted base URLs

• saloon:list now correctly parses endpoints using string interpolation, concatenation, escaped quotes, and reads double-quoted base URLs.

src/saloon/src/Console/Commands/ListCommand.php

SaloonServiceProvider.phpAllow CachedResponse class in cache serialization policy +17/-1

Allow CachedResponse class in cache serialization policy

• Registers CachedResponse with the serializable-class allow list so file/database/Redis cache stores can restore cached responses.

src/saloon/src/SaloonServiceProvider.php

Refactor (2) +2 / -63
PipeOrder.phpRename enum cases back to Saloon's FIRST/LAST +2/-2

Rename enum cases back to Saloon's FIRST/LAST

• Restores Saloon-compatible case names for pipe ordering.

src/saloon/src/Enums/PipeOrder.php

IntegerRepository.phpRemove IntegerRepository, replaced by plain scalar delay field +0/-61

Remove IntegerRepository, replaced by plain scalar delay field

• Deletes the repository class now that delay is stored as a plain property on PendingRequest.

src/saloon/src/Repositories/IntegerRepository.php

Documentation (5) +754 / -117
saloon.mdRewrite Saloon guide covering core and all four plugins +639/-88

Rewrite Saloon guide covering core and all four plugins

• Massive documentation rewrite with checked examples for bodies, auth, OAuth, middleware, errors, retries, debugging, pools, caching, pagination, rate limits, plugins, events and testing.

src/docs/saloon.md

rate-limiting.mdDocument CalendarWindow policy +35/-2

Document CalendarWindow policy

• Adds documentation for the new calendar-window rate limit policy.

src/docs/rate-limiting.md

porting-from-laravel.mdAdd Saloon section to Laravel porting guide +8/-0

Add Saloon section to Laravel porting guide

• Documents how to port Saloon usage from Laravel to Hypervel.

src/docs/porting-from-laravel.md

sync.yamlRecord checked Saloon revisions and repository mapping +26/-25

Record checked Saloon revisions and repository mapping

• Updates the upstream sync metadata with reviewed Saloon and plugin revisions.

docs/upstream-sync/sync.yaml

README.mdDocument intentional differences from Saloon +46/-2

Document intentional differences from Saloon

• Lists every remaining intentional difference from Saloon and its plugins with reasoning and Hypervel equivalents.

src/saloon/README.md

Other (1) +1 / -1
composer.jsonDrop hypervel/reflection, require guzzlehttp/uri-template directly +1/-1

Drop hypervel/reflection, require guzzlehttp/uri-template directly

• Removes an unused dependency and adds a direct dependency used for URL parameter expansion.

src/saloon/composer.json

@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Refactors HTTP client and rate limiter internals across multiple packages.

The PR appears safe to merge; no new blocking issue was identified, and the previous coverage finding is addressed.

What we checked:

  • Paths outside the app: MakeCommand removes dot segments before comparing paths. A path outside the application directory requires an explicit namespace.

Summary

This PR improves Saloon request preparation, authentication, caching, retries, pools, pagination, and rate limits. It also adds clock-based CalendarWindow limits and expands the documentation and tests.

  • The latest changes normalize generator paths and declare the required symfony/filesystem dependency.
  • Resource upload retry tests address the previous coverage finding.
  • No new actionable issues were identified in the changes since the previous review.

Reviews (2) · Last reviewed commit: "Import request and connector classes in ..." · Reviewed by Greptile

Comment thread src/saloon/src/Repositories/Body/StreamBodyRepository.php
The Redis store test for calendar windows read the server clock with
time() without a node. PhpRedis's RedisCluster::time() needs a key or
address, so the test failed on the Redis Cluster job. It now reads the
clock of the node that holds the window's key. The store itself reads
TIME inside its Lua script and was not affected.

Retrying a request rewinds its body to where the first attempt started,
but nothing tested it. New retry tests send a resource body that starts
part way through, attach a resource file, and use a non-seekable body.
The first two fail if the rewind is removed. The third checks that a
non-seekable body throws BodyException with the failed response's
exception as its previous one, instead of resending an empty body.

Verified on a three-node Redis Cluster and a standalone Redis 8 server.
Without a configured integrations namespace, the generator commands map
the integrations path beneath the app directory to a namespace. They
compared the raw configured path, so app_path('Http/../Integrations')
produced a namespace containing "..", and app_path('../src/Integrations')
passed the inside-the-app check and generated an invalid namespace
instead of asking for one.

The commands now canonicalize the configured path with Symfony's
Path::canonicalize() and use it for the namespace, the destination file
and the integration suggestions. The app path is canonicalized the same
way before the comparison. Saloon now requires symfony/filesystem
directly; the root package already did.

The generator tests cover an in-app path with dot segments and one that
leaves the app directory.
The SDK connector, resource and HasConnector examples used GetUser,
GetRepository and GitHubConnector without importing them. Copied as
shown, the classes resolve in the wrong namespace: the connector method
fails with a missing class, and GitHubConnector::class silently names a
class that does not exist until the request sends.
@binaryfire
binaryfire merged commit 4cbecc2 into 0.4 Oct 7, 2026
51 checks passed
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