The License API depends on Statamic. When Statamic doesn’t answer, the HTTP client throws a ConnectionException, and by default the consumer sees a 500.
That is the wrong answer. A 500 says “this API has a bug.” The truth is “the provider behind this API is unreachable,” which is a 504, and a consumer can reasonably retry it.
// bootstrap/app.php
$exceptions->render(function (ConnectionException $e) {
return response()->json([
'message' => __('errors.provider_unavailable'),
'code' => ErrorCode::ProviderUnavailable,
], Response::HTTP_GATEWAY_TIMEOUT);
});
ErrorCode is an enum this chapter introduces in a moment.
The type hint on the closure is the routing. Laravel calls it only for that exception and falls back to its defaults for everything else, so there is no match on exception classes to maintain. (Laravel documentation: Error Handling › Rendering Exceptions.)
The response does not contain $e->getMessage(). The exception’s message has the provider’s hostname in it. Whatever you render from an upstream failure, write the sentence yourself.
The same goes for a provider that answers with an error. The driver’s throw() raises a RequestException, and that becomes a 502. Not all of those are alike, though. A 5xx from the provider is the provider’s trouble and may clear up. A 4xx means the provider understood the request and refused it, and sending it again won’t change its mind. The one exception is a 429 that outlasted the driver’s retries, which belongs with the 5xx:
// bootstrap/app.php
$exceptions->render(function (RequestException $e) {
$temporary = $e->response->serverError()
|| $e->response->tooManyRequests();
$code = $temporary
? ErrorCode::ProviderFailed
: ErrorCode::ProviderRejected;
return response()->json([
'message' => __("errors.{$code->value}"),
'code' => $code,
], Response::HTTP_BAD_GATEWAY);
});
Chapter 3’s MalformedProviderResponse is the third member of this family, and the next section shows it.
Error Codes Consumers Can Branch On
A status code tells a consumer what class of thing went wrong. It doesn’t tell them what actually went wrong. A 504 because the provider timed out and a 504 from a proxy in front of you look identical.
A message is useless for branching, because Chapter 6 made this API answer in the consumer’s language. A client that does if (error.message === 'Provider unavailable') breaks the moment that message comes back in Spanish.
So domain failures carry a stable, language-independent code:
// app/Enums/ErrorCode.php
enum ErrorCode: string
{
case ProviderUnavailable = 'provider_unavailable';
case ProviderFailed = 'provider_failed';
case ProviderRejected = 'provider_rejected';
case LicenseLimitReached = 'license_limit_reached';
case RequestInProgress = 'request_in_progress';
}
{
"message": "The license provider is not responding.",
"code": "provider_unavailable"
}
A consumer branches on code, which never changes whatever language the message renders in. The first two are worth retrying. The third is not. The last two are both conflicts, and only the code tells a consumer that one of them will resolve itself in a second and the other never will. The status says what class of thing happened. The code says what to do about it.
Keep the list short. A code is a promise: once a consumer branches on it, you can’t rename it. Add one when a consumer has to tell two failures apart, not before.
When an exception belongs to your own domain, it can render itself. Chapter 3’s exception for a provider that sent nonsense does:
// app/Exceptions/MalformedProviderResponse.php
class MalformedProviderResponse extends RuntimeException
{
/** @param array<int, string> $fields */
public function __construct(public array $fields)
{
parent::__construct(
'Malformed provider response: '
.implode(', ', $fields),
);
}
public function render(): JsonResponse
{
return response()->json([
'message' => __('errors.provider_failed'),
'code' => ErrorCode::ProviderFailed,
], Response::HTTP_BAD_GATEWAY);
}
}
The message is for your log, and it holds field names, never values. The response is the 502 a failing provider gets, because that is what a malformed answer usually is: an error page where the JSON should have been.
Throw it from anywhere and the response is the same. (Laravel documentation: Error Handling › Reportable and Renderable Exceptions.)
LicenseLimitReached has the same shape with a different answer:
// app/Exceptions/LicenseLimitReached.php
public function render(): JsonResponse
{
return response()->json([
'message' => __('errors.license_limit_reached'),
'code' => ErrorCode::LicenseLimitReached,
], Response::HTTP_CONFLICT);
}
It is thrown in store, before the provider is asked to create anything. Each consumer has a license_limit, and a consumer at its limit is refused:
// app/Http/Controllers/LicenseController.php, in store()
$limit = $request->user()->license_limit;
if (Licenses::all()->count() >= $limit) {
throw new LicenseLimitReached;
}
Chapter 2 drew the line between validation and a business rule by asking whether the consumer could fix it by sending something different. Here it can’t. No field is wrong. So this is an exception with a code, and not a 422.