Expired OpenAI or Gemini batch job: partial results and retrying only what failed
OpenAI keeps an expired batch's finished responses and lists the rest in an error file. Gemini's guide says an expired job has no results. Retry script included.
In March, a developer posted Batch API degraded since March 4 on the OpenAI forum. They had sent 102 requests, 50 of them finished, and the batch expired when the 24-hour window ran out. There was no error message, only the expiry, and their next move was to submit all 102 requests again. On April 1, OpenAI Support said the problem was not reproducing in its tests.
In February, a Gemini user reached the same ending at a larger scale in a Gemini API forum thread. Their job held about 50,000 requests and was processing roughly 15,000 a day. After two days its state was expired, and although they counted more than 25,000 finished requests, the destination to download from was empty. They asked whether the finished results could be retrieved, whether the expired job could be resumed, and whether splitting the 50,000 requests into ten jobs would run faster than one large job.
The two providers document different outcomes for the same event. On OpenAI an expired batch splits into what finished and what did not, and the retry is a filtering job. On Gemini the guide says an expired job returns nothing, so the work happens before submission, when you size the job. On both, the join between your data and the results is an identifier you chose and attached to every request.
OpenAI splits an expired batch into an output file and an error file
The Batch API guide is explicit about expiry. A batch that does not finish inside its window moves to expired, and its unfinished requests are cancelled. The responses that did complete are available in the output file, and you are charged for the tokens they used. Each request that did not finish is written to the error file with the code batch_expired and the custom_id it was submitted with.
If the March batch behaved as the guide describes, 50 of the 102 requests had responses waiting in the output file and the other 52 were listed by ID in the error file. Resending all 102 pays a second time for the 50 that had finished. The guide also says output lines may not follow the input order and that every custom_id must be unique, which makes the ID the only reliable join between your input and either result file. Use the primary key of the row each request came from, and a retry file can be rebuilt from your database even when nobody kept the original input.
This script reads the error file, collects the IDs of the expired requests, and copies only those lines from the original input:
import json
from openai import OpenAI
client = OpenAI()
batch = client.batches.retrieve("batch_abc123")
errors = client.files.content(batch.error_file_id).text
expired = {
row["custom_id"]
for row in map(json.loads, errors.splitlines())
if (row.get("error") or {}).get("code") == "batch_expired"
}
with open("input.jsonl") as source, open("retry-input.jsonl", "w") as retry:
for line in source:
if json.loads(line)["custom_id"] in expired:
retry.write(line)
Submit retry-input.jsonl as a new batch. Download the first batch's output file the same way, through batch.output_file_id, and keep it: it holds the work you have already paid for. Error lines with any other code failed for their own reasons, so read them before you resend. The guide says the output file is deleted 30 days after the batch completes and gives no retention period for the error file, so download both together.
Poll request_counts on the retry batch soon after you submit it. A commenter in a September thread about background-mode requests stuck in queued says a gpt-5-mini workload sent through the Batch API expired on three consecutive submissions, so a second submission is not a guarantee.
Gemini documents no results for an expired job
Google's Batch API guide defines JOB_STATE_EXPIRED as a job that was running or pending for more than 48 hours, and says it will not have any results to retrieve. It suggests submitting the job again or splitting the requests into smaller batches. That settles the reporter's first question, since the empty destination is the documented outcome, and it implies the second: every remedy the guide lists starts a new job.
The reporter's own numbers show how the job got there. At about 15,000 requests a day, 50,000 requests need more than three days against a 48-hour limit. Google describes a 24-hour turnaround target and says many jobs finish faster, so measure the rate you actually get. I would submit a small job first, see how many requests clear in a day, and size each production job well under what two days of that rate can cover.
The guide also recommends breaking very large batches into smaller ones when intermediate results are needed sooner, which limits what one expiry strands. Whether ten small jobs together finish sooner than one large job is the reporter's third question, and neither the thread nor the guide answers it. Google's rate limits page caps the tokens enqueued for a model across all your active batch jobs, so ten jobs share one enqueue ceiling, and I found nothing that says splitting makes them faster.
A job that does succeed can still contain failed lines. The guide says to check failedRequestCount in batchStats and to parse each output line for a status object. It also says each response is annotated with the key you put on its request, which plays the part custom_id plays on OpenAI. Build the retry file from those keys.
AIVAX Batch pauses instead of expiring
AIVAX Batch runs each item as a normal inference call from a queue you start, pause and finish, so there is no provider window to run out. A job is Active, Paused or Finished, and an item is Pending, Finished, Refused, ExecutionError, ValidationError or Cancelled. New jobs start paused, so you can import and inspect the items before anything runs.
What stalls a job is a pause, and the job's event timeline records why: an operator paused it, the account ran out of balance, consecutive item errors crossed the workflow's error threshold, or repeated rate-limit and provider failures crossed a separate transient-failure threshold. Only the balance pause lifts itself, once the account has funds again; for the other three, someone has to start the job, or retry items, which starts it. Poll the job state and alert on Paused. The earlier post on batch admission explains how the thresholds work.
Each item keeps its input, output, validation result, confidence and cost. There is no field for your own identifier, so put the row's key inside the input; the export echoes the input back, and that is your join. Sample a few failures before you retry: an ExecutionError item stores a short user-facing message, which is generic when the cause was internal, and a ValidationError item stores the rejected output together with the reason it was rejected. Four retry modes select items by stored outcome, and the export endpoint takes a matching state filter:
execution-error, export withstate=ExecutionError: items whose processing failed.validation-error, export withstate=ValidationError: items whose answer failed the workflow's schema or validation instruction.errors, export withstate=errors: both of those plus refused items.low-confidence, export withstate=finished&confidence=low: finished items whose model-reported confidence is below 0.3.
If validation-error items trace back to the schema, invalid JSON from structured outputs covers what to rule out first. A refusal is the model's decision about that input and is stored with its reason, so read refused items before including them with errors.
A retry puts the selected items back to Pending, skips any item a worker is processing at that moment, clears each item's stored output, validation result and confidence, and starts the job if it was not active and at least one item matched. Export first. Reading the job, sampling its failures, saving them and retrying takes four calls:
JOB="https://inference.aivax.net/api/v1/batch/workflows/$WORKFLOW_ID/jobs/$JOB_ID"
AUTH="Authorization: Bearer $AIVAX_API_KEY"
curl -H "$AUTH" "$JOB"
curl -H "$AUTH" "$JOB/items?state=ExecutionError&limit=20"
curl -H "$AUTH" "$JOB/export.jsonl?state=ExecutionError" -o failed-before-retry.jsonl
curl -X POST -H "$AUTH" "$JOB/items/retry?mode=execution-error"
Provider batch costs half the synchronous price on both platforms (OpenAI, Google). AIVAX Batch documents no such discount; each item is priced like an ordinary call to its model, and the service has failure modes of its own: a job can pause and wait for you, and a retry clears the stored output of the items it selects. On OpenAI an expiry costs time and a second submission, and the finished responses stay in the output file, so I would take the discount and keep the recovery script above. On Gemini an expired job returns nothing, and the guide does not say whether requests that finished before the expiry are billed; that is where I would use a queue with per-item outcomes.
Read on 2026-10-23: the AIVAX Batch implementation (including its background jobs and cost calculator) and docs, OpenAI's and Google's batch guides, Google's rate limits page, and the three forum threads. I found no path that expires a job or removes its items by age, and no batch price multiplier. I did not run an AIVAX job, so the curl calls come from the endpoint definitions, and I ran the retry script only against synthetic files with a stub in place of the OpenAI client.