Errors & retries
The error envelope, which codes are worth retrying, and the canonical retry loop.
Errors use the same shape as the OpenAI API, so existing error handling mostly works unchanged. What's
added is a code you can branch on and a request ID on every response.
They arrive in two places. An HTTP call (creating a batch, uploading a file, or reading usage) fails
with the envelope below. An individual line inside a running batch fails with the same envelope,
written to the job's error_file_id hours later. Handle both.
{
"error": {
"type": "invalid_request_error",
"code": "model_not_found",
"message": "Model 'llama-4-500b' does not exist.",
"param": "model"
}
}POST /v1/batches HTTP/1.1
Host: REGION.api.lararouter.com
Authorization: Bearer $LARAROUTER_API_KEY
Content-Type: application/json
{
"input_file_id": "file_9Hs4TnQ2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h"
}
HTTP/1.1 404 Not Found
X-Lararouter-Request-Id: req_01JD8XK4M2Log the request ID
Every response carries X-Lararouter-Request-Id, including failures. Logging it turns "a request
failed last Tuesday" into a single lookup in GET /v1/requests/{request_id}.
One envelope
Errors use the OpenAI-shaped envelope above, on HTTP calls and on failed batch lines. Branch on
error.code.
Status codes
| Status | Type | Meaning |
|---|---|---|
400 | invalid_request_error | Malformed request. Fix it; retrying won't help. |
401 | authentication_error | Missing, invalid, revoked, or wrong-region key. |
403 | permission_error | Key lacks the required scope, or the plan lacks the feature. |
404 | invalid_request_error | No such model or request ID. |
409 | invalid_request_error | Idempotency conflict. See Idempotency. |
422 | invalid_request_error | Well-formed but unprocessable, e.g. context length exceeded. |
429 | rate_limit_error | Rate limited or over budget. Check error.code to tell which. |
500 | api_error | Something broke on our side. Retry. |
503 | api_error | Upstream model unavailable. Retry, ideally on another model. |
Codes worth branching on
Prop
Type
What to retry
Retry 429 (after Retry-After), 500, 502, 503, and 504, plus connection failures and
timeouts. Use exponential backoff with jitter.
Do not retry 400, 401, 403, 404, or 422. The request is wrong and will stay wrong.
429 usage_limit_exceeded is the exception that catches people out: it looks like a rate limit but
retrying is pointless, because nothing frees up until the budget window rolls over.
Line-level failures aren't retried by re-sending anything. You collect the failed custom_ids out of
error_file_id, rebuild a file from just those, and submit a new batch. batch_expired lines are the
common case and are always worth resubmitting; context_length_exceeded lines need fixing first.
The canonical retry loop
Because Idempotency-Mode defaults to implicit, a retry of a call
that already succeeded returns 409 idempotency_cache_not_requested rather than the body. That's
deliberate (you never resubmit a job by accident), but it means the retry loop has two steps: send,
and on that specific 409, fetch the recorded result.
Write it once and reuse it.
namespace App\Actions\Lararouter;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Throwable;
class SubmitBatch
{
/** @param array<string, mixed> $payload */
public function handle(string $idempotencyKey, array $payload): array
{
try {
$response = Http::withToken(config('services.lararouter.key'))
->retry(3, 200, function (Throwable|Response $result) {
$status = $result instanceof Response ? $result->status() : 0;
// Budget exhaustion is a 429 that never resolves on its own.
if ($status === 429 && $result->json('error.code') === 'usage_limit_exceeded') {
return false;
}
return $status === 0 || $status === 429 || $status >= 500;
}, throw: false)
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->post('https://REGION.api.lararouter.com/v1/batches', $payload);
if ($this->alreadyRan($response)) {
return $this->refetch($response->json('error.original_request_id'));
}
return $response->throw()->json();
} catch (Throwable $e) {
report($e);
throw $e;
}
}
private function alreadyRan(Response $response): bool
{
return $response->status() === 409
&& $response->json('error.code') === 'idempotency_cache_not_requested';
}
private function refetch(string $requestId): array
{
return Http::withToken(config('services.lararouter.key'))
->get("https://REGION.api.lararouter.com/v1/requests/{$requestId}")
->throw()
->json('response');
}
}Failures are recorded too
A failed line still produces a request ID and still lands in the audit log, so you can go back and see
exactly what was sent long after the result file is gone. Failures that never reached a model aren't
billed. Refetch the record with GET /v1/requests/{request_id}.