Diagnostic LLM APIs
a batch reads completed while some of its rows failed
The nightly job submits fifty thousand rows, sleeps, polls until the status turns to completed, downloads the output file and loads it. It has done that every night for eight months. The table it fills is short — not empty, not obviously wrong, just a few hundred rows smaller than the input file, by a number that changes every night. No exception was ever raised. The batch object says completed in plain text, and three fields further down it says "failed": 869, which nothing has ever read.
GET /v1/batches?limit=100 with a project key set to Read Only, paginating on after. For every object, stop reading status and start reading the arithmetic in request_counts: a batch is clean only when failed == 0 and completed == total.
completed is a statement about the batch, not about the requests in it. It means the run finished. Individual rows inside a finished run can fail on rate limits, context-length overflow, content filtering or a transient server error, and the batch still lands in completed with no HTTP error anywhere.
Two disagreements are possible and they are not the same thing. failed > 0 is rows that ran and failed. completed + failed < total is rows that are neither, which is a hole in the accounting rather than a failure, and it wants a different question asked of it.
The problem in plain words
The output file has one line per successful row, keyed by custom_id, and it is shorter than the input file. That is the entire visible symptom. Code that zips the output back onto the input by position rather than by custom_id does something worse than lose rows: it misaligns every row after the first failure, so eight hundred failures at the front of a fifty thousand row file quietly shift the whole result set and every downstream number is wrong rather than missing.
What keeps it alive is that completed is the word everyone was waiting for. The polling loop is written once, early, against a status enum, and status == "completed" becomes the definition of success for the rest of the system's life. The counts sit in the same object the loop already fetched, one field away from the condition it tests, and are never looked at. There is no alert to configure, because nothing failed: the API did exactly what it said, and what it said was narrower than what was heard.
Why it happens
"Completed" describes the batch, not the requests. The status field tracks the lifecycle of the job object: validating, in_progress, finalizing, completed. A batch reaches completed when it has stopped running, whatever the outcome of the rows. There is no status that means "finished and every row succeeded", so no status check can express it.
Row-level failures are individually ordinary. A row can hit the model's context limit, trip a content filter, exhaust a rate limit or catch a 500. Each of those is a normal per-request outcome that the Batch API records against the row and moves on from. Aborting a fifty thousand row job because row 12,004 was too long would be worse behaviour, so it does not, and the price of that is that partial success is the API's ordinary state.
The signal is arithmetic, not a flag. There is no partial boolean and no warning field. request_counts carries total, completed and failed, and the finding is the comparison between them. That is why this check is a function rather than a condition: three numbers can disagree in more than one way.
There is no request log to fall back on. Neither provider exposes an endpoint that lists individual inference requests with their statuses. If you do not reconcile the counts on the batch object, there is nowhere else to go and ask which rows failed — only the error file, which expires after thirty days.
The other terminal states hide behind the same loop. A batch that never ran at all lands in failed, one that ran out of time lands in expired, and a polling loop that only tests for completed treats both as "still running" forever. Those are separate notes, and a reconciliation script should say so rather than fold them in here.
The fix, as a flow
The script asks for the batch list and then ignores the field everyone reads. The finding is arithmetic on three integers, and the reason it is a function rather than a condition is that they can disagree in two ways: rows that ran and failed, and rows that never ran at all.
How to fix it
List the batches, do not just re-fetch the one you remember
GET /v1/batches?limit=100 and follow after with the last id on the page while has_more is true. The audit has to be over every batch in the retention window, because the whole failure mode is a job that nobody went back to look at.
Read request_counts instead of status
Three fields: total, completed, failed. Assert both halves — failed == 0 and completed == total. Testing only the first misses rows that never ran; testing only the second misses nothing today but says less about what happened.
Separate failed rows from unaccounted rows
failed > 0 means rows ran and returned an error, and those errors are in the error file. completed + failed < total means rows are in neither column, which on a finished batch is a hole: cross-check it against the expiry note, because abandoned rows are how a window closing shows up in the counts.
Reconcile line counts, not just the status enum
Before your job marks itself done, count the lines in the downloaded output file and compare against the lines you uploaded. Join on custom_id and never on position: a missing row in a positional join corrupts every row after it instead of dropping one.
Print the repair, then decide whether to re-run it
Read the error file, bucket the lines by error.code, retry the transient ones (rate_limit_exceeded, server_error) in a follow-up batch of just those custom_ids, and fix the rest. Re-submitting spends money on inference, which is why this script prints the command and stops.
How to check it worked
Re-run the script after the follow-up batch has landed. Every completed batch in the window should reconcile.
python3 openai_batch_partial_failure_audit.py
# clean batch_68f2a1 all 50000 row(s) completed
# 14 completed batch(es) checked, 0 with rows missing
The full code
One paginated GET and no writes, so a project key set to Read Only is enough and is what this should hold. The classifier is pure and takes nothing but the batch object, because no clock is involved in this note: the question is whether three integers agree, and the reason it is a function with tests rather than an if is that they can disagree in two different ways and the two want different repairs.
"""Report OpenAI batches that read completed while rows inside them failed.
Read only. GET requests and nothing else: give this a project key set to Read
Only. The repair is printed, never performed, because re-submitting the failed
rows means spending money on inference and that is your decision to make.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_batch_partial_failure_audit")
API = "https://api.openai.com/v1"
# Still moving. None of these is a verdict about the rows, because the counts
# are not final until the batch stops.
IN_FLIGHT = ("validating", "in_progress", "finalizing", "cancelling")
# Terminal, and owned by the sibling notes rather than by this script: a failed
# batch never ran a single row, an expired one ran out of window, a cancelled
# one was stopped on purpose.
OTHER_TERMINAL = ("failed", "expired", "cancelled")
FINDINGS = ("partial", "unaccounted")
def counts_of(batch):
"""Read request_counts into three ints, or None when it cannot be read.
Pure. Missing members are read as zero because the API omits nothing here,
but a request_counts that is not an object at all returns None rather than
three zeros: three zeros would classify as an empty batch, which is a
completely different and much calmer finding than an unreadable one.
"""
counts = batch.get("request_counts")
if not isinstance(counts, dict):
return None
try:
total = int(counts.get("total") or 0)
done = int(counts.get("completed") or 0)
failed = int(counts.get("failed") or 0)
except (TypeError, ValueError):
return None
return (total, done, failed)
def verdict(batch):
"""Classify one object from GET /v1/batches. Pure.
Returns (state, detail). The two findings are kept apart on purpose:
"partial" is rows that ran and failed, which are in the error file, and
"unaccounted" is rows that are in neither column, which are not.
"""
status = str(batch.get("status") or "").strip().lower()
if status in IN_FLIGHT:
return ("running",
"status is %s, so the counts are not final and there is nothing "
"to reconcile yet" % status)
if status in OTHER_TERMINAL:
return ("other-terminal",
"status is %s. The batch did not finish running, which is a "
"different problem from finishing with failures inside it."
% status)
if status != "completed":
return ("unreadable",
"status is %r, which is not a lifecycle state this script "
"recognises. Read the object by hand." % (status or None,))
numbers = counts_of(batch)
if numbers is None:
return ("unreadable",
"the batch says completed and carries no readable "
"request_counts, so nothing here can be reconciled. That is not "
"the same as a clean batch and is not reported as one.")
total, done, failed = numbers
if total <= 0:
return ("empty",
"completed with a total of 0 request(s). The input file was "
"empty or never parsed into rows.")
if failed > 0:
return ("partial",
"%d of %d row(s) failed and the batch still reads completed. "
"The output file is %d line(s) shorter than the input file."
% (failed, total, total - done))
if done < total:
return ("unaccounted",
"%d of %d row(s) are neither completed nor failed. Rows in "
"neither column were abandoned rather than attempted, which is "
"what a closed completion window looks like in the counts."
% (total - done, total))
return ("clean", "all %d row(s) completed" % total)
def get(session, path, params=None):
r = session.get(API + path, params=params or {}, timeout=60)
if r.status_code == 401:
raise SystemExit("401 from OpenAI: the key is wrong, revoked, or belongs "
"to another project")
r.raise_for_status()
return r.json()
def batches(session, page_size, max_pages):
"""Walk GET /v1/batches, which paginates on the id of the last object."""
params = {"limit": page_size}
for _ in range(max_pages):
page = get(session, "/batches", params)
data = page.get("data") or []
for batch in data:
yield batch
if not page.get("has_more") or not data:
return
params = {"limit": page_size, "after": data[-1].get("id")}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--limit", type=int, default=100,
help="page size for GET /v1/batches (default 100)")
ap.add_argument("--pages", type=int, default=20,
help="stop after this many pages (default 20)")
ap.add_argument("--show-all", action="store_true",
help="also print batches that reconcile")
args = ap.parse_args()
key = os.environ.get("OPENAI_API_KEY")
if not key:
log.error("set OPENAI_API_KEY (a project key set to Read Only)")
return 2
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + key})
checked = 0
bad = 0
for batch in batches(session, args.limit, args.pages):
state, detail = verdict(batch)
batch_id = str(batch.get("id") or "?")
line = "%-15s %s %s" % (state, batch_id, detail)
if state in FINDINGS:
checked += 1
bad += 1
log.warning(line)
error_file = batch.get("error_file_id")
if error_file:
log.warning(" repair: read the failures with GET "
"/v1/files/%s/content, bucket the lines by "
"error.code, and re-submit the failed custom_ids as "
"a new batch", error_file)
else:
log.warning(" repair: no error_file_id on this batch, so the "
"missing rows were never attempted. Re-submit them "
"and reconcile output lines against input lines.")
log.warning(" repair: treat request_counts.failed > 0 as a job "
"failure in your orchestrator instead of trusting "
"status == completed")
elif state == "clean":
checked += 1
if args.show_all:
log.info(line)
elif state in ("unreadable", "empty"):
checked += 1
log.warning(line)
elif args.show_all:
log.info(line)
log.info("%d completed batch(es) checked, %d with rows missing", checked, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report OpenAI batches that read completed while rows inside them failed.
*
* Read only. GET requests and nothing else: give this a project key set to Read
* Only. The repair is printed, never performed.
*/
const API = 'https://api.openai.com/v1';
// Still moving. None of these is a verdict about the rows, because the counts
// are not final until the batch stops.
const IN_FLIGHT = ['validating', 'in_progress', 'finalizing', 'cancelling'];
// Terminal, and owned by the sibling notes rather than by this script.
const OTHER_TERMINAL = ['failed', 'expired', 'cancelled'];
const FINDINGS = ['partial', 'unaccounted'];
/**
* Read request_counts into three numbers, or null when it cannot be read. Pure.
* A request_counts that is not an object returns null rather than three zeros,
* because three zeros classify as an empty batch and that is a much calmer
* finding than an unreadable one.
*/
export function countsOf(batch) {
const counts = batch.request_counts;
if (counts === null || typeof counts !== 'object' || Array.isArray(counts)) return null;
const total = Number(counts.total ?? 0);
const done = Number(counts.completed ?? 0);
const failed = Number(counts.failed ?? 0);
if (!Number.isFinite(total) || !Number.isFinite(done) || !Number.isFinite(failed)) {
return null;
}
return [Math.trunc(total), Math.trunc(done), Math.trunc(failed)];
}
/**
* Classify one object from GET /v1/batches. Pure. Returns [state, detail].
* The two findings are kept apart on purpose: "partial" is rows that ran and
* failed, which are in the error file, and "unaccounted" is rows that are in
* neither column, which are not.
*/
export function verdict(batch) {
const status = String(batch.status ?? '').trim().toLowerCase();
if (IN_FLIGHT.includes(status)) {
return ['running',
`status is ${status}, so the counts are not final and there is nothing ` +
'to reconcile yet'];
}
if (OTHER_TERMINAL.includes(status)) {
return ['other-terminal',
`status is ${status}. The batch did not finish running, which is a ` +
'different problem from finishing with failures inside it.'];
}
if (status !== 'completed') {
return ['unreadable',
`status is ${JSON.stringify(status || null)}, which is not a lifecycle ` +
'state this script recognises. Read the object by hand.'];
}
const numbers = countsOf(batch);
if (numbers === null) {
return ['unreadable',
'the batch says completed and carries no readable request_counts, so ' +
'nothing here can be reconciled. That is not the same as a clean batch ' +
'and is not reported as one.'];
}
const [total, done, failed] = numbers;
if (total <= 0) {
return ['empty',
'completed with a total of 0 request(s). The input file was empty or ' +
'never parsed into rows.'];
}
if (failed > 0) {
return ['partial',
`${failed} of ${total} row(s) failed and the batch still reads ` +
`completed. The output file is ${total - done} line(s) shorter than the ` +
'input file.'];
}
if (done < total) {
return ['unaccounted',
`${total - done} of ${total} row(s) are neither completed nor failed. ` +
'Rows in neither column were abandoned rather than attempted, which is ' +
'what a closed completion window looks like in the counts.'];
}
return ['clean', `all ${total} row(s) completed`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from OpenAI: the key is wrong, revoked, or belongs to ' +
'another project');
}
if (!res.ok) throw new Error(`${res.status} from ${path}`);
return res.json();
}
async function* walk(key, pageSize, maxPages) {
let params = { limit: pageSize };
for (let i = 0; i < maxPages; i += 1) {
const page = await get(key, '/batches', params);
const data = page.data ?? [];
for (const batch of data) yield batch;
if (!page.has_more || data.length === 0) return;
params = { limit: pageSize, after: data[data.length - 1].id };
}
}
async function main() {
const key = process.env.OPENAI_API_KEY;
if (!key) {
console.error('set OPENAI_API_KEY (a project key set to Read Only)');
process.exitCode = 2;
return;
}
const pageSize = Number(process.env.LIMIT ?? 100);
const maxPages = Number(process.env.PAGES ?? 20);
const showAll = process.argv.includes('--show-all');
let checked = 0;
let bad = 0;
for await (const batch of walk(key, pageSize, maxPages)) {
const [state, detail] = verdict(batch);
const batchId = String(batch.id ?? '?');
const line = `${state.padEnd(15)} ${batchId} ${detail}`;
if (FINDINGS.includes(state)) {
checked += 1;
bad += 1;
console.warn(line);
console.warn(batch.error_file_id
? ` repair: read the failures with GET /v1/files/${batch.error_file_id}` +
'/content, bucket the lines by error.code, and re-submit the failed ' +
'custom_ids as a new batch'
: ' repair: no error_file_id on this batch, so the missing rows were ' +
'never attempted. Re-submit them and reconcile output lines against ' +
'input lines.');
console.warn(' repair: treat request_counts.failed > 0 as a job failure ' +
'in your orchestrator instead of trusting status == completed');
} else if (state === 'clean') {
checked += 1;
if (showAll) console.log(line);
} else if (state === 'unreadable' || state === 'empty') {
checked += 1;
console.warn(line);
} else if (showAll) {
console.log(line);
}
}
console.log(`${checked} completed batch(es) checked, ${bad} with rows missing`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The test that matters is the first one: a batch whose status is completed and whose failed count is not zero must classify as a finding, because the entire note is that those two facts are compatible. The rest hold the surrounding states apart — rows that failed against rows that were never attempted, a batch still running against one that reconciles, and a missing request_counts against a clean one, which is the failure mode a lenient parser produces.
from openai_batch_partial_failure_audit import counts_of, verdict
def batch(status="completed", total=100, completed=100, failed=0, **extra):
"""A batch object shaped like GET /v1/batches returns them."""
body = {"id": "batch_test", "status": status,
"request_counts": {"total": total, "completed": completed,
"failed": failed}}
body.update(extra)
return body
def test_completed_does_not_mean_every_row_succeeded():
# The whole note: these two facts are compatible and the status hides it.
state, detail = verdict(batch(total=50000, completed=49131, failed=869))
assert state == "partial"
assert "869 of 50000" in detail
assert "869 line(s) shorter" in detail
def test_a_clean_batch_needs_both_halves_of_the_arithmetic():
assert verdict(batch(total=100, completed=100, failed=0))[0] == "clean"
assert verdict(batch(total=100, completed=99, failed=1))[0] == "partial"
def test_rows_in_neither_column_are_their_own_finding():
# Not failures. Abandoned rows: attempted by nobody, absent from the error
# file, and the shape a closed completion window leaves behind.
state, detail = verdict(batch(total=100, completed=60, failed=0))
assert state == "unaccounted"
assert "40 of 100" in detail
assert "abandoned" in detail
def test_an_in_flight_batch_is_not_reconciled_yet():
for status in ("validating", "in_progress", "finalizing", "cancelling"):
state, detail = verdict(batch(status=status, total=100, completed=3))
assert state == "running"
assert "not final" in detail
def test_the_other_terminal_states_belong_to_the_sibling_notes():
for status in ("failed", "expired", "cancelled"):
assert verdict(batch(status=status, total=100, completed=4,
failed=0))[0] == "other-terminal"
def test_missing_counts_are_never_reported_as_clean():
assert verdict({"id": "b", "status": "completed"})[0] == "unreadable"
assert verdict({"id": "b", "status": "completed",
"request_counts": []})[0] == "unreadable"
assert verdict({"id": "b"})[0] == "unreadable"
assert verdict(batch(total=0, completed=0))[0] == "empty"
def test_counts_are_read_leniently_but_not_invented():
assert counts_of({"request_counts": {"total": 10}}) == (10, 0, 0)
assert counts_of({"request_counts": {"total": "10", "completed": "9",
"failed": "1"}}) == (10, 9, 1)
assert counts_of({"request_counts": {"total": "many"}}) is None
assert counts_of({}) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countsOf, verdict } from './openai-batch-partial-failure-audit.mjs';
/** A batch object shaped like GET /v1/batches returns them. */
function batch({ status = 'completed', total = 100, completed = 100,
failed = 0, ...extra } = {}) {
return {
id: 'batch_test',
status,
request_counts: { total, completed, failed },
...extra,
};
}
test('completed does not mean every row succeeded', () => {
const [state, detail] = verdict(batch({ total: 50000, completed: 49131, failed: 869 }));
assert.equal(state, 'partial');
assert.match(detail, /869 of 50000/);
assert.match(detail, /869 line\(s\) shorter/);
});
test('a clean batch needs both halves of the arithmetic', () => {
assert.equal(verdict(batch({ total: 100, completed: 100, failed: 0 }))[0], 'clean');
assert.equal(verdict(batch({ total: 100, completed: 99, failed: 1 }))[0], 'partial');
});
test('rows in neither column are their own finding', () => {
const [state, detail] = verdict(batch({ total: 100, completed: 60, failed: 0 }));
assert.equal(state, 'unaccounted');
assert.match(detail, /40 of 100/);
assert.match(detail, /abandoned/);
});
test('an in flight batch is not reconciled yet', () => {
for (const status of ['validating', 'in_progress', 'finalizing', 'cancelling']) {
const [state, detail] = verdict(batch({ status, total: 100, completed: 3 }));
assert.equal(state, 'running');
assert.match(detail, /not final/);
}
});
test('the other terminal states belong to the sibling notes', () => {
for (const status of ['failed', 'expired', 'cancelled']) {
assert.equal(verdict(batch({ status, total: 100, completed: 4 }))[0],
'other-terminal');
}
});
test('missing counts are never reported as clean', () => {
assert.equal(verdict({ id: 'b', status: 'completed' })[0], 'unreadable');
assert.equal(verdict({ id: 'b', status: 'completed', request_counts: [] })[0],
'unreadable');
assert.equal(verdict({ id: 'b' })[0], 'unreadable');
assert.equal(verdict(batch({ total: 0, completed: 0 }))[0], 'empty');
});
test('counts are read leniently but not invented', () => {
assert.deepEqual(countsOf({ request_counts: { total: 10 } }), [10, 0, 0]);
assert.deepEqual(countsOf({ request_counts: { total: '10', completed: '9', failed: '1' } }),
[10, 9, 1]);
assert.equal(countsOf({ request_counts: { total: 'many' } }), null);
assert.equal(countsOf({}), null);
});
FAQ
If the batch says completed, what does the word actually promise?
That the run reached a terminal state without being cancelled and without failing validation. It says nothing about the outcome of the requests inside it. Rows can fail individually on rate limits, context length, content filtering or transient server errors, and the batch still reads completed, so status is the wrong field to build a success condition on.
How do I know which rows failed?
The error file. A batch with failures carries a non-null error_file_id, and GET /v1/files/{id}/content returns one JSON line per failed row with its custom_id and an error object. That file expires after thirty days, which is the whole of the sibling note on error files never being fetched.
What does it mean when completed plus failed is less than total?
Rows that were neither run successfully nor run unsuccessfully. On a batch that reached completed this is unusual; on a batch that expired it is the normal shape, because the completion window closed on rows that had not been processed. Reconciling the two numbers separately is how you tell an abandoned row from a failed one.
Can I just retry the whole batch?
You can, and on a large job you will pay for every row again including the ones that already succeeded. Rebuilding a .jsonl of only the failed custom_ids is cheaper, and it is also what tells you whether the failures are systematic — five hundred context-length errors are a prompt problem, five hundred rate-limit errors are a scheduling one.
Does Anthropic's Message Batches API behave the same way?
Structurally yes. A Claude message batch ends with processing_status of ended and carries a request_counts object with succeeded, errored, canceled and expired members, so the same reconciliation applies: the batch ending is not a claim that every request in it succeeded. The result set is a .jsonl of per-request results rather than a split pair of output and error files, and it is retained for twenty-nine days.
Related field notes
- An error file that exists and was never fetched
- A batch the 24 hour window closed on
- Batch-eligible work paying synchronous prices
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Batch — OpenAI API reference
- Batch API guide — OpenAI developer docs
- Files — OpenAI API reference
- Batch processing — Claude Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.