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 weekForty 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.