The PSR-18 client I wrote for one API

One supplier API, consumed from three places in the application, each of which had implemented signing, retrying, rate limiting and pagination independently. Three implementations of four concerns is twelve chances to be subtly different, and they were.

The symptom

the three call sites, compared:

                    signing  retry  rate limit  paging
  the sync job         yes    3×      sleep(1)   yes
  the admin lookup     yes    no      none       no
  the webhook reply    yes    5×      none       n/a

and the signing differed: two used a timestamp
generated at request construction, one at send time.
under a retry, the first two produced a stale signature
and a 401 that was retried three more times.

The signing difference is the bug this exercise found. A retried request with a signature generated before the first attempt is invalid, and the retry logic treated the resulting 401 as a transient failure — so a slow first request produced four rejections and a log line that said the supplier was down.

Why it happens

An HTTP client library handles transport and stops there. Everything above it — authentication schemes, rate limit semantics, pagination conventions — is the API’s and has nowhere to live except at each call site.

The fix

Middleware at the PSR-18 boundary

public function sendRequest(RequestInterface $request): ResponseInterface
{
    $timestamp = $this->clock->now()->getTimestamp();
    $body      = (string) $request->getBody();

    $signature = hash_hmac('sha256', $timestamp . '.' . $body, $this->secret);

    return $this->inner->sendRequest(
        $request->withHeader('X-Signature', "t={$timestamp},v1={$signature}")
    );
}

Signing inside the client rather than at construction is what makes a retry correct — every attempt gets a fresh timestamp because the signature is applied on the way out. That is the entire fix for the bug above and it is a consequence of where the concern was placed rather than of anything in the signing code.

The order, which is not obvious and is not recoverable

$client = new LoggingClient(          // 4. what was sent
    new RetryingClient(              // 3. retries, and
                                     //    each attempt
                                     //    is signed
        new SigningClient(           // 2. a fresh
                                     //    timestamp
            new RateLimitedClient(   // 1. waits first
                $psr18,
            ),
            $secret,
            $clock,
        ),
    ),
);
each layer is a bug if inverted:

  signing outside retry     a retried request carries
                            a stale signature
  logging inside retry      the log records one
                            attempt of four
  rate limiting outside
    retry                   the wait happens once for
                            four attempts, so a retry
                            storm ignores the limit

the order is documented in a comment, which is the only
place it can be documented.

Rate limiting on the supplier’s terms

public function sendRequest(RequestInterface $request): ResponseInterface
{
    $this->waitIfNecessary();

    $response = $this->inner->sendRequest($request);

    // the supplier tells us where we are. use it rather
    // than a fixed rate we invented.
    if ($response->hasHeader('X-RateLimit-Remaining')) {
        $this->remaining = (int) $response->getHeaderLine('X-RateLimit-Remaining');
        $this->resetAt   = (int) $response->getHeaderLine('X-RateLimit-Reset');
    }

    return $response;
}

A fixed sleep(1) is a guess about somebody else’s limit and is wrong in both directions — too slow when the budget is available and too fast when it is not. Reading the headers means the client adapts, and the supplier changing their limit does not require a deploy.

Retry, and the difference between a timeout and a rejection

private function shouldRetry(?ResponseInterface $r, ?Throwable $e, int $attempt): bool
{
    if ($attempt >= $this->maxAttempts) { return false; }

    // a transport failure: no response was produced
    if ($e instanceof NetworkExceptionInterface) { return true; }

    // a rejection: the supplier answered
    return match ($r?->getStatusCode()) {
        429, 502, 503, 504 => true,
        default => false,
    };
}

Retrying a 400 or a 401 is how a signing bug becomes four requests instead of one, and retrying a 500 is usually pointless because the supplier has already decided. The distinction that matters is whether the request was processed — a timeout may have been, which makes retrying a write dangerous and is why the sync job sends an idempotency key.

Pagination as an iterator

/** @return Generator<int, Consignment> */
public function consignments(DateRange $range): Generator
{
    $cursor = null;

    do {
        $page = $this->send(new ListConsignments($range, $cursor));

        foreach ($page->items() as $item) {
            yield $this->mapper->toConsignment($item);
        }

        $cursor = $page->nextCursor();
    } while ($cursor !== null);
}

// the caller writes a foreach and never sees a page.

A generator means the caller cannot accidentally load forty thousand consignments into an array, and the pagination convention — a cursor in a header on this API, a field in the body on another — is hidden entirely. The one thing it hides that matters is that iterating is making network requests, which is worth a comment at the call site.

Verifying it worked

$ vendor/bin/phpunit --group=supplier
  ✓ every attempt carries a fresh signature
  ✓ a 429 waits for the reset header
  ✓ a 400 is not retried
  ✓ a network exception is retried
  ✓ pagination yields across three pages

# a deliberate rate-limit response, in a fixture
$ ./bin/supplier-drill --respond=429 --reset=+5s
  waited 5.0s, then succeeded. 1 request, not 4.

# and the log line that started this
$ grep -c 'supplier unavailable' /var/log/app/*.log
0        # was ~40 a week

Forty spurious “supplier unavailable” log lines a week disappearing is the outcome, and none of them had ever been investigated because the supplier occasionally is unavailable. A recurring log line that has a plausible explanation is the hardest kind of bug to notice.

What this costs

A stack of decorators whose order matters and is not enforceable. The comment describing why each layer is where it is will be read by whoever changes it and not by whoever adds a fifth — and a caching layer added in the wrong position would cache a signed request, which is the next bug in this shape.

The rate limiter also holds state per instance, which means two workers each get the full budget and the supplier sees twice the rate. That is currently fine at six workers and a generous limit, and the correct fix is shared state in the cache — which is a distributed rate limiter, and is more machinery than the problem has yet earned.