Idempotency
Retry safely without paying twice, and without getting a stale completion you didn't ask for.
Every mutating request accepts an Idempotency-Key. None require one, so a stock OpenAI client keeps
working exactly as it did. GET and HEAD are idempotent by definition and ignore the header.
It matters most on POST /v1/batches, where a retried create submits the whole
file a second time. One dropped connection can double a job of 50,000 lines.
curl https://REGION.api.lararouter.com/v1/batches \
-H "Authorization: Bearer $LARAROUTER_API_KEY" \
-H "Idempotency-Key: 0d5c1f7e-3b91-4a2c-9f2d-7c8e1b4a6d30" \
-H "Content-Type: application/json" \
-d '{
"input_file_id": "file_9Hs4TnQ2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h"
}'The contract
- Keys are yours to generate. A UUID v4 is the right shape; the limit is 255 characters.
- Keys are scoped to the API key that sent them, so two projects can never collide.
- Lararouter stores the response against the key plus a fingerprint of the method, path, and body for 24 hours.
- Sending the same key with a different body returns
409 idempotency_key_reuse. The fingerprint didn't match, so there's nothing coherent to return. - Sending the same key while the first call is still running returns
409 idempotency_key_in_progresswithRetry-After. 2xxand4xxresponses are stored.5xxresponses and dropped connections are not.
That last rule is the one that makes retries safe. A retry after a timeout can still succeed, because nothing was stored. A retry after a completed charge cannot double it, because the result was.
Modes
Idempotency-Mode says whether you actually want the stored response handed back. It defaults to
implicit, so you never receive a cached completion without asking for one.
Prop
Type
The reason both exist: silently receiving a completion generated minutes ago, for a prompt whose context has since moved on, is a bug that surfaces days later as "the model said something stale." Defaulting to loud means a caller who sent a key purely for retry safety never gets a surprise body, and nothing is lost, because the result is one deliberate call away.
Explicit: give me what you ran
curl https://REGION.api.lararouter.com/v1/batches \
-H "Authorization: Bearer $LARAROUTER_API_KEY" \
-H "Idempotency-Key: 0d5c1f7e-3b91-4a2c-9f2d-7c8e1b4a6d30" \
-H "Idempotency-Mode: explicit" \
-H "Content-Type: application/json" \
-d '{
"input_file_id": "file_9Hs4TnQ2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h"
}'POST /v1/batches HTTP/1.1
Host: REGION.api.lararouter.com
Authorization: Bearer $LARAROUTER_API_KEY
Idempotency-Key: 0d5c1f7e-3b91-4a2c-9f2d-7c8e1b4a6d30
Idempotency-Mode: explicit
Content-Type: application/json
{
"input_file_id": "file_9Hs4TnQ2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h"
}
HTTP/1.1 200 OK
Idempotency-Replayed: true
X-Lararouter-Request-Id: req_01JD8XKB7Q
X-Lararouter-Original-Request-Id: req_01JD8XK4M2The replayed body is the original batch object, so a retry hands you back the batch_ ID you already
have running rather than starting a second job.
Implicit: tell me it already ran
POST /v1/batches HTTP/1.1
Host: REGION.api.lararouter.com
Authorization: Bearer $LARAROUTER_API_KEY
Idempotency-Key: 0d5c1f7e-3b91-4a2c-9f2d-7c8e1b4a6d30
Content-Type: application/json
{
"input_file_id": "file_9Hs4TnQ2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h"
}
HTTP/1.1 409 Conflict
X-Lararouter-Request-Id: req_01JD8XKB7Q
{
"error": {
"type": "invalid_request_error",
"code": "idempotency_cache_not_requested",
"message": "This idempotency key has already been used. Retrieve the result at /v1/requests/req_01JD8XK4M2, or resend with Idempotency-Mode: explicit.",
"original_request_id": "req_01JD8XK4M2"
}
}Follow original_request_id to GET /v1/requests/{request_id} and you have the
result, deliberately rather than by accident.
A replay is never billed twice
A replayed call adds no usage and creates no second batch. It does get its own entry in the audit log,
because every request ID has to be fetchable. That entry carries cost_usd: 0, replayed: true, and a
pointer to the original.
So three retries of one submission produce three request IDs, one batch, and one bill. Per-user cost reporting stays honest when a queue worker retries.
Request IDs are not idempotency keys
A request ID identifies one HTTP exchange. Lararouter mints it, returns it on every response, and never reuses it. An idempotency key identifies one logical operation. You mint it, and you may send it on many calls. Replaying a key gives you the same body under a new request ID.
custom_id is the other half
Idempotency-Key stops you submitting the same file twice. custom_id stops you submitting the
same line twice. Duplicates within a file fail validation outright, and deriving it from a primary
key means a rebuilt file produces identical IDs. Together they make "regenerate the nightly batch and
resubmit" safe to run as many times as you like.
Note that the two are independent: a file rebuilt from a query that has since gained rows is a different file, and a new idempotency key is what you want for it.
In a Laravel queue
The natural key is one that survives a retry of the same job. A job ID or a date does; Str::uuid()
called inside handle() does not, because each attempt would generate a new one and defeat the whole
mechanism.
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use Throwable;
class SubmitNightlySummaries implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(public string $inputFileId, public string $date) {}
public function handle(): void
{
try {
$response = Http::withToken(config('services.lararouter.key'))
->withHeaders([
// Stable across every attempt of this job.
'Idempotency-Key' => "summaries-{$this->date}",
])
->post('https://REGION.api.lararouter.com/v1/batches', [
'input_file_id' => $this->inputFileId,
'endpoint' => '/v1/chat/completions',
'completion_window' => '24h',
]);
if ($response->status() === 409 && $response->json('error.code') === 'idempotency_cache_not_requested') {
$response = Http::withToken(config('services.lararouter.key'))
->get('https://REGION.api.lararouter.com/v1/requests/'.$response->json('error.original_request_id'));
}
PollBatch::dispatch($response->json('id'))->delay(now()->addMinutes(5));
} catch (Throwable $e) {
report($e);
throw $e;
}
}
}The 24-hour window matters here, and it lines up with the completion window: a retry within a day of the original submission replays it, while a job re-run the following week starts a fresh batch, which is usually what you want.