Statamic rate limits its API. When you are over the limit it answers 429, usually with a Retry-After header. Laravel’s HTTP client can retry for you, and it lets you say exactly when and for how long:
// app/Services/License/StatamicDriver.php
private function http(): PendingRequest
{
return Http::statamic($this->token)->retry(
times: 3,
sleepMilliseconds: $this->backoff(...),
when: $this->wasThrottled(...),
throw: false,
);
}
private function wasThrottled(Exception $e): bool
{
return $e instanceof RequestException
&& $e->response->tooManyRequests();
}
private function backoff(int $attempt, Exception $e): int
{
$retryAfter = $e instanceof RequestException
? $e->response->header('Retry-After')
: null;
$delay = is_numeric($retryAfter)
? (int) $retryAfter * 1000
: 500 * 2 ** ($attempt - 1);
return min($delay, 5000);
}
It retries only on 429. A 429 means the provider refused the request before doing any work, so replaying it is safe whatever the method. That reasoning does not extend to a 500 or a timeout, where you have no idea how far the other side got. Those are not retried.
The backoff is clamped. A provider that returns Retry-After: 3600 is asking your worker to sleep for an hour inside a request that will be killed long before that. Honor the hint, but never past your own timeout.
And throw: false means the client hands back the last response instead of throwing after the final attempt. The driver decides what a failure means, which is how delete can treat a 404 as success. (Laravel documentation: HTTP Client › Retries.)
One Name for the Application to Call
The driver exists. The controller needs a way to reach it without naming it. Two small pieces do that.
First, tell the container which implementation stands behind the contract:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(
LicenseContract::class,
fn () => new StatamicDriver(
config('services.statamic.token'),
),
);
}
Then give it a facade:
// app/Facades/Licenses.php
/**
* @method static Collection<int, License> all()
* @method static License create(string $name, string $domain)
* @method static void delete(string $key)
*/
class Licenses extends Facade
{
protected static function getFacadeAccessor(): string
{
return LicenseContract::class;
}
}
Licenses::all() asks the container for whatever is bound to LicenseContract and calls all() on it. One hop, and the controller’s one line reaches Statamic.
A facade is named after the thing it gives you access to, the way Cache and Queue are. You don’t have to use one. Type-hinting LicenseContract in a controller method works just as well, and some teams prefer it. This book uses the facade because it reads the way the rest of Laravel reads. (Laravel documentation: Facades.)
In a test, replace what the facade points at:
$this->mock(LicenseContract::class)
->shouldReceive('all')
->once()
->andReturn(collect());
$this->mock() puts a test double in the container under the contract’s name, and Licenses::all() now reaches the double. Laravel’s own facades are usually replaced with Cache::shouldReceive(), and Licenses::shouldReceive() would work today, given a token in the test environment. It stops working in Chapter 4. To learn which class to imitate, shouldReceive() builds the real driver first, and from Chapter 4 on the real driver can’t be built without a consumer and its credentials. (Laravel documentation: Mocking › Mocking Objects.)
The configuration is ordinary:
// config/services.php
'statamic' => [
'url' => env('STATAMIC_API_URL'),
'token' => env('STATAMIC_API_TOKEN'),
],