Latest in AI
OpenAI Batch API Partial Failures: Reconcile Results and Retry Safely
A practical recovery workflow for expired, cancelled and partially successful batches, with a local Python checker that keeps missing results out of automatic retries.
An overnight batch can leave you with a useful output file and an unfinished job, which is why I would reconcile the original requests with the output and error files before uploading anything again. Repeating the original input without checking those results can mean paying again for work that already finished.
This guide focuses on that recovery step. The Python example runs locally on downloaded JSONL data and makes no API calls. Its checks use synthetic fixtures, not a live Batch API run.
Check the batch state before rebuilding the input
Keep the original input file and retrieve the current batch object. Record its ID, status, input file ID, output and error file IDs, and request counts. The Batch API reference distinguishes successful outputs from request errors and exposes validation errors with a line number when available.
If the batch is still validating, running or finalizing, wait rather than treating absent output as failure. A batch in the failed state needs its input validation errors investigated. Fix the affected request format, model or parameters before resubmitting.
For a terminal batch, use its files to inspect individual requests. The Batch guide says output order can differ from input order, so join on custom_id. Never pair lines by position. Each request needs a unique ID within the input file.
An expired batch can still contain successful work. Completed requests remain available and incur charges; unfinished requests receive batch_expired errors. Preserve those successes before preparing another batch.
Cancellation also needs time to settle. The cancel endpoint can leave a batch in cancelling for up to ten minutes. Once it becomes cancelled, partial results may be available. Cancellation does not undo results your application has already consumed.
Build a small recovery record
For each run, keep these together in storage your team controls:
- The original JSONL and a checksum, so a changed local file cannot silently become the source for recovery.
- The batch ID, project context, endpoint, model and creation time.
- The final batch metadata and both downloaded result files when their IDs are present.
- A mapping from
custom_idto the business record and task version. - A separate record of results your application has actually validated and applied.
Download failures need their own handling. If a file ID exists but retrieval fails, do not substitute an empty list and continue. Stop, recover the file and check that the download is complete. A missing local file tells you nothing about whether the underlying requests ran.
Prefer opaque identifiers such as item-0042-summary-v3 over customer names or email addresses. Keep the private mapping in your application.
Classify results before deciding to retry
The following function produces four groups. http_success means the request returned a successful HTTP status, not that its generated content is usable. retry_candidate is deliberately narrow: an expired request or a server-side HTTP error still needs a retry policy. Everything else goes to review, while absent IDs remain missing.
It rejects unknown IDs, duplicate result IDs and duplicate input IDs. A repeated download or an accidental mix of two batches should stop the import rather than overwrite a result silently.
import json
def read_jsonl(path):
with open(path, encoding="utf-8") as file:
return [json.loads(line) for line in file if line.strip()]
def reconcile(input_rows, result_rows, batch_status):
if batch_status not in {"completed", "expired", "cancelled"}:
raise ValueError("Batch is not ready for result reconciliation")
expected = set()
for row in input_rows:
cid = row.get("custom_id")
if not isinstance(cid, str) or not cid or cid in expected:
raise ValueError("Missing or duplicate input custom_id")
expected.add(cid)
groups = {name: set() for name in (
"http_success", "retry_candidate", "review", "missing"
)}
seen = set()
for row in result_rows:
cid = row.get("custom_id")
if not isinstance(cid, str) or cid not in expected:
raise ValueError("Result does not match this input file")
if cid in seen:
raise ValueError("Duplicate result custom_id")
seen.add(cid)
response = row.get("response")
error = row.get("error")
if response is not None and not isinstance(response, dict):
raise ValueError("Malformed response envelope")
if error is not None and not isinstance(error, dict):
raise ValueError("Malformed error envelope")
status = (response or {}).get("status_code")
code = (error or {}).get("code")
if type(status) is int and 200 <= status < 300 and error is None:
group = "http_success"
elif response is None and code == "batch_expired":
group = "retry_candidate"
elif type(status) is int and 500 <= status < 600:
group = "retry_candidate"
else:
group = "review"
groups[group].add(cid)
groups["missing"] = expected - seen
return groups
Pass the concatenated rows from the output and error files as result_rows. Use an empty list for a file only when the batch has no corresponding file ID, not when its download failed. Keep the raw envelopes alongside this summary for diagnosis. This in-memory example is for manageable local files; a larger pipeline should stream records into a durable store.
For an illustrative five-request batch, suppose two IDs have HTTP 200 responses, one has batch_expired, one has HTTP 400 and one has no result. The plan contains two HTTP successes, one retry candidate, one review item and one missing item. Reordering those four returned rows should not change that plan.
Check this accounting rule before continuing: every input ID belongs to exactly one group, and the total equals the number of unique input IDs. Compare that total with the batch's request counts too. A mismatch should block automatic recovery while you check the manifest and downloads.
Validate the body before applying a success
HTTP success is only the first check. Inspect the endpoint's response body for incomplete generation, refusals, parsing failures and required fields. Then apply your business rules. A valid JSON object can still contain an unsupported category or refer to the wrong source record.
Keep these stages separate in your database: received, validated and applied. Otherwise a worker that crashes after downloading an answer can make the next worker believe the business task was finished.
For replay protection, use a stable business key such as record ID plus task version. Make applying the result and recording that key one database transaction. If the result triggers an email, webhook or other external action, use a durable queue or outbox and the destination's deduplication mechanism where available. A database flag alone cannot make an external side effect happen exactly once.
Treat custom_id as a correlation field. Do not assume that reusing it in a new batch prevents another charge or makes your application writes safe to repeat.
Create a retry file from reviewed candidates
Before another submission, remove tasks already applied successfully. Review remaining candidates against an attempt limit, a cost budget and the value of finishing the job now.
A malformed request needs a correction. An authentication, access or quota problem needs its cause resolved. The example intentionally sends HTTP 429 responses to review because the status alone does not tell you whether waiting will help.
Keep missing IDs out of automatic retries. First verify the final status, both file IDs, downloads and original input checksum. If the outcome remains uncertain, record that uncertainty and decide whether the task can safely be repeated. Do not turn an unexplained absence into a claim that the request never ran.
Build the new JSONL from the selected original requests, applying only the reviewed fixes. Keep the same business-task mapping and record a new attempt and batch ID. If you change the prompt or task definition, version that change so an old result cannot overwrite the revised task later.
There is no reason to resubmit the successful portion merely to produce one neat output file. Your application can assemble the final dataset from several attempts while preserving where each accepted result came from.
Download promptly and check retention requirements
File availability is not an archival plan. The Batch guide describes automatic deletion of output files after 30 days, while the creation reference also exposes output_expires_after for output and error files, anchored to each file's creation time. Inspect your job's policy and each file's expires_at, and download promptly. Do not assume every result remains downloadable for a month.
Input files have their own lifecycle. The Files upload reference says files uploaded with purpose=batch expire after 30 days by default and support an expires_after policy anchored to file creation. Preserve the evidence you need within your own approved retention policy, then clean up copies you no longer need.
Check the account and data constraints before putting sensitive workloads into this recovery design. OpenAI's data controls documentation currently marks /v1/batches and /v1/files as ineligible for Zero Data Retention. A store=false setting inside an individual request should not be treated as a promise that the surrounding batch files are never stored.
The useful end state is a record of every input: accepted and applied, awaiting a corrected attempt, deliberately stopped, or unresolved with an owner. That is more reliable than a green batch status and a folder of output files nobody has reconciled.
I'm Paras, a technical writer working with AI and developer-tool teams.
I help technical teams turn API behavior and engineering decisions into clear developer guides and product stories.