Lararouter
Concepts

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_01JD8XK4M2

Log 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

StatusTypeMeaning
400invalid_request_errorMalformed request. Fix it; retrying won't help.
401authentication_errorMissing, invalid, revoked, or wrong-region key.
403permission_errorKey lacks the required scope, or the plan lacks the feature.
404invalid_request_errorNo such model or request ID.
409invalid_request_errorIdempotency conflict. See Idempotency.
422invalid_request_errorWell-formed but unprocessable, e.g. context length exceeded.
429rate_limit_errorRate limited or over budget. Check error.code to tell which.
500api_errorSomething broke on our side. Retry.
503api_errorUpstream 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}.

On this page