Ese throw merece su propia sección, porque saltártelo cuesta una tarde realmente miserable.
Si falta la credencial y nada lo comprueba, la petición sale con una clave vacía. El proveedor la rechaza con un error de autenticación cuyo mensaje viene a decir: no has proporcionado una clave de API.
Lo cual es cierto, y completamente engañoso, porque el código que lee la clave se ejecutó bien y la fila existe. Te pondrás a buscar una clave revocada, un entorno equivocado, un proxy que elimina cabeceras. La respuesta real es que una credencial se resolvió como una cadena vacía y nadie lo comprobó.
Así que la excepción nombra al inquilino y el lugar donde mirar:
namespace App\Exceptions;
use App\Models\Workspace;
use RuntimeException;
final class MissingAiCredentials extends RuntimeException
{
public static function for(Workspace $workspace): self
{
return new self(sprintf(
'Workspace %s has no AI credential. '
.'Set one with `php artisan '
.'credentials:set %s ai`.',
$workspace->id,
$workspace->id,
));
}
}
Y la API la renderiza una sola vez, en bootstrap/app.php, como cualquier otro fallo que no es culpa de quien llama:
// bootstrap/app.php, dentro de withExceptions()
$exceptions->render(function (MissingAiCredentials $e) {
report($e);
return response()->json([
'message' => __('errors.ai_not_configured'),
'code' => ErrorCode::AiNotConfigured,
], Response::HTTP_FAILED_DEPENDENCY);
});
424 Failed Dependency dice lo que es cierto: la petición estaba bien, y algo de lo que depende este servicio no está configurado. El mensaje que ve quien llama es una frase que escribiste tú. El detallado, con el id del espacio de trabajo y el comando para arreglarlo, va al log, porque ahí es donde mirará la próxima persona, y la próxima persona eres tú dentro de cuatro meses.
Esto se generaliza. Un error de configuración debe fallar donde se configura, no donde se usa. Cuanto más se separan esos dos puntos, más caro sale el fallo.
Reintenta lo que vale la pena reintentar
Los reintentos son donde las buenas intenciones producen malos sistemas. El instinto por defecto, «reintenta tres veces si falla», convierte en silencio una petición mal formada en tres, y un error de facturación en tres.
La política que sobrevive al contacto con un proveedor real distingue entre los fallos que podrían resolverse solos y los que seguro que no, y un modelo añade una categoría que las API corrientes no tienen.
Los límites de peticiones con un Retry-After corto merecen una espera. El proveedor te ha dicho exactamente cuándo volver. El SDK lo hace por ti: reintenta una vez un 429 o una respuesta de sobrecarga, si la cabecera pide diez segundos o menos.
Los límites de peticiones sin Retry-After, no. Algunos proveedores devuelven un 429 sin cabecera cuando has llegado a un tope de gasto, y sigue fallando hasta que alguien sube el tope. Reintentarlo es un bucle que espera a un humano.
Los errores 4xx no se reintentan nunca. Una petición mal formada también lo estará en el segundo intento. Reintentarla malgasta latencia y límite de peticiones, y esconde el fallo: un error que reintentas tres veces es un error que notas tres veces más despacio.
Un timeout no se reintenta nunca. Esta regla es la propia de los modelos, y la aprendimos de la factura. Cuando una petición agota el tiempo de tu lado, el proveedor normalmente no se ha detenido. Sigue generando la respuesta, y te la va a cobrar. Reintenta, y pagarás una segunda respuesta mientras la primera todavía se está escribiendo. Nuestra primera política reintentaba todos los fallos de conexión, timeouts incluidos. La cambiamos en octubre para que una petición pueda facturar una respuesta completa, y no más. Ahora los únicos fallos de conexión que vale la pena reintentar son aquellos en los que la petición demostrablemente nunca salió de la máquina: un fallo de DNS, una conexión rechazada.
El comportamiento integrado del SDK ya sigue estas reglas. El error que hay que evitar es envolverlo en un reintento propio:
// No: esto reintenta los timeouts, y cada reintento es una
// segunda factura.
$response = retry(
3,
fn () => (new SiteCopywriter($workspace))
->prompt($brief),
);
Si necesitas más resiliencia que una espera corta, la respuesta del SDK es la conmutación por error, y es la correcta:
$response = (new SiteCopywriter($workspace))
->prompt($brief, provider: [
$providers->for($workspace, Lane::Copy),
$providers->fallbackFor($workspace, Lane::Copy),
]);
La conmutación por error pasa al siguiente proveedor ante un límite de peticiones, un proveedor sobrecargado, el crédito agotado o una conexión fallida. Los errores corrientes, como una petición mal formada, fallan donde ocurrieron. Es casi la misma distinción de arriba, aplicada entre empresas en lugar de entre intentos, con una pega que conviene conocer: para el SDK, un timeout es una conexión fallida. Así que una petición que agotó el tiempo con el primer proveedor, que quizá siga generando y facturando, se vuelve a enviar al segundo. Para un carril barato es un precio aceptable por seguir en pie. Para uno caro, deja la conmutación desactivada y deja que el timeout falle. Si quieres o no un segundo proveedor es una decisión de producto con un costo, y el capítulo 18 la sopesa frente a opciones más sencillas.