Ir al contenido principal
Laravel, shipping fast.
Capitulo 5 · Manejo de errores y registro

Codigos de error sobre los que ramificar

Julian Beaujardin

Mira otra vez ApiExceptionRenderer. Elige un código de estado y un mensaje legible por humanos. Ya está. Un 404 por una licencia inexistente y un 404 por un dominio inexistente son idénticos para un cliente: mismo estado, y una cadena de mensaje como única diferencia.

La verdad cruda: un código de estado le dice a quien consume qué clase de cosa salió mal. No le dice qué salió mal en concreto. Y una cadena de mensaje es peor que inútil para ramificar, porque el capítulo 4 ya te enseñó que esta API traduce las respuestas mediante SetRequestLocaleMiddleware. Un cliente que hace if (error.message === 'License not found') se rompe en cuanto ese mensaje llega en español.

Mira de cerca qué hace ErrorResponse con el mensaje que recibe:

// api-infrastructure/src/Responses/ErrorResponse.php
protected function getData(): array
{
    return [
        'errors' => [
            json_decode($this->message, true) ?? $this->message,
        ],
    ];
}

Si $message es JSON válido, se decodifica en datos estructurados antes de envolverse. Si no, se queda como cadena. CreateLicenseRequest::failedValidation() ya explota exactamente esto: pasa json_encode($validator->errors()->toArray()) como mensaje, así que los fallos de validación llegan como un objeto estructurado por campo en lugar de una frase. Es un precedente real y funcionando de enviar estructura por un campo que parece texto plano.

Extiende el mismo truco al tipo de excepción, no sólo a la validación, y cada closure de representación podrá adjuntar un identificador estable e independiente del idioma junto al mensaje humano:

// api-license/app/Enums/LicenseErrorCode.php
enum LicenseErrorCode: string
{
    case NotFound = 'license_not_found';
    case ValidationFailed = 'validation_failed';
    case ProviderUnavailable = 'provider_unavailable';
    case RateLimited = 'rate_limited';
}
// api-license/bootstrap/app.php
$exceptions->render(function (LicenseNotFound $exception) {
    return (new ErrorResponse(
        message: json_encode([
            'code' => LicenseErrorCode::NotFound->value,
            'message' => $exception->getMessage(),
        ]),
        status: HttpResponse::HTTP_NOT_FOUND,
    ))->toResponse(request());
});

Un consumidor ramifica sobre error.code === 'license_not_found', que no cambia con el idioma en que se represente el mensaje. El código de estado sigue diciéndole a su cliente HTTP si reintentar. El código de error le dice a su lógica de aplicación qué pasó realmente. Necesitas ambos, y hoy esta API sólo te da uno de forma fiable.

Contexto que hace que un registro merezca la pena

Una línea de registro que dice "error" => $e->getMessage() apenas es un registro. Te dice que algo falló, no por qué, ni para quién, ni qué hacer al respecto.

El flujo de compra de dominios de api-server muestra qué necesita una entrada para ser útil de verdad:

// api-server/app/Exceptions/DomainPurchaseException.php
final class DomainPurchaseException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly bool $outcomeUnknown = false,
    ) {
        parent::__construct($message);
    }
}

$outcomeUnknown no es decoración. Es el campo que gobierna dinero real: false significa que el registrador definitivamente no hizo nada, así que devolver el importe es seguro. true significa que el registrador podría haber cobrado igualmente el dominio, y devolver o reintentar a ciegas podría cobrar o registrar dos veces. Si sólo registraras $e->getMessage(), esa distinción desaparece en cuanto la traza se pierde de vista. La excepción lleva el contexto porque el mensaje por sí solo no puede.

LogApiRequestsMiddleware aplica la misma disciplina a nivel de petición, no por error sino en cada llamada:

// api-infrastructure/src/Middleware/LogApiRequestsMiddleware.php
defer(function () use ($request, $response, $startTime) {
    $bearer = $request->attributes->get('bearer');
    $sanitizedRequest = SensitiveDataSanitizer::sanitize($request->all());
    $sanitizedResponse = SensitiveDataSanitizer::sanitize(ResponseParser::parse($response));

    // ...payload-size and gzip-size calculations omitted here...

    SendToLogsJob::dispatch(
        user_id: $bearer?->id ?? 0,
        method: $request->method(),
        endpoint: $request->url(),
        request: $sanitizedRequest,
        response: $sanitizedResponse,
        status: $response->getStatusCode(),
        execution_time: microtime(true) - $startTime,
        bearer_token: $bearer?->token,
    );
});

Quién llamó, qué envió, qué volvió, cuánto tardó: todo, en cada petición, no sólo en las que fallaron. SensitiveDataSanitizer corre primero, redactando cualquier cosa que coincida con una lista de nombres de campo conocidos como sensibles —password, token, credit_card, ssn— antes de que nada llegue a un almacén de registros. El contexto sólo merece conservarse si conservarlo no convierte tu almacén de registros en la siguiente cosa que hay que notificar como brecha.