Skip to content

Diagnostic LLM APIs

a batch expired when the 24 hour completion window closed

The submission returned 200 yesterday afternoon. The poller has been asking for the batch ever since, testing the status against completed, and it has never matched, so as far as the job is concerned the work is still running. It is not. Twenty-four hours after the batch started processing, everything OpenAI had not got to was abandoned, the status went to expired, and thirty thousand rows landed in the error file with the code batch_expired. Nothing raised, nothing retried, and the poller is still waiting.

Read-only key Python and Node.js Tests included
A basket with a target, a target, a target in it, and a
Photo by Growtika on Unsplash
The short answer

GET /v1/batches?limit=100, paginating on after. Flag every object with status == "expired" and compute the shortfall as request_counts.total - request_counts.completed. Those rows were never processed and never will be.

Then do the more useful half: flag the batches that are about to do this. Anything still in validating, in_progress or finalizing whose expires_at is a few hours away is the same outage, caught while there is still time to split the job.

The window is fixed. completion_window accepts one value, 24h, and the clock runs from when the batch starts processing rather than from when you created it. A batch is not late; at the end of that window it is over.

The problem in plain words

The damage is shaped by the polling loop. Code that waits for completed and treats everything else as "not yet" will wait forever on an expired batch, because expired is terminal and completed will never arrive. The job does not fail, it hangs; and a hung nightly job usually means the next night's run stacks on top of it, which puts more work into the same queue that could not drain the first batch.

The partial result is the second problem. An expired batch is not empty: the rows that ran are in the output file and are perfectly good. Downstream that looks like a batch that worked, at a size nobody checked. Deleting and re-running is expensive because you pay again for the rows that already succeeded, and re-running only the missing rows means reading the error file to find out which they were — a file that expires thirty days after it was written.

Batch created200, statusvalidatingQueue does notdrain50,000 rows, onewindow24h fromin_progress_atnot configurableStatus turnsexpired30,000 rowsabandonedPoller stillwaitingthe job hangs, itdoes not fail
The create call returned 200 a day earlier. Expired is terminal, so a loop waiting for completed waits for something that cannot arrive.

Why it happens

The window is a hard 24 hours and it is not configurable. completion_window takes the single value 24h. There is no extension, no priority flag and no way to ask for longer. What has not been processed when the window closes is abandoned rather than deferred.

Size and queue depth both eat the window. A batch can hold up to 50,000 requests or a 200 MB input file, and a batch that large has no obligation to fit in the window. Neither does a small one submitted behind a queue of your own earlier batches, since they all draw on the same per-model capacity.

The create call cannot warn you. POST /v1/batches returns 200 immediately with a status of validating. Whether the work will drain in time is a fact about the next twenty-four hours, so nothing in the response can carry it. The only forward-looking field is expires_at, and reading it is the entire pre-emptive half of this check.

Expiry is invisible unless you enumerate. There is no webhook, no email and no push of any kind for a batch reaching expired. It is a field on an object you have to ask for, and the code most likely to have stopped asking is the code that lost track of the batch id.

The clock starts at in_progress_at, not created_at. Time spent in validating is not the window. That is why this script reads expires_at when the object carries it, falls back to in_progress_at plus 24 hours, and only then falls back to created_at plus 24 hours — which it labels as an upper bound rather than pretending it is the deadline.

The fix, as a flow

Two questions from one list. The batches that already expired are a count of rows that will never run, and the batches still moving are the half worth automating: a subtraction against expires_at, while there is still time to submit the tail as a second job.

Deadline resolvedthen compared to nowAlready expiredcount the rows that never ranPast the deadline, still runningthe tail is not comingHours of window leftsplit it now, not tomorrowRoom left, or settlednothing to do yet
expires_at is the API's own answer. Falling back to created_at over states the time remaining, so the report says which one it used.

How to fix it

Enumerate every batch, not the ones you have ids for

GET /v1/batches?limit=100 and follow after. A job that lost track of its batch id is exactly the job that expired, so the audit cannot start from the ids your database remembers.

Measure the shortfall on the expired ones

request_counts.total - request_counts.completed is the number of rows that never ran. Read expired_at for when the window closed. Every one of those rows is a line in the error file carrying {"code": "batch_expired"}, which is what makes the re-submission list recoverable for thirty days.

Read expires_at on the batches still moving

This is the half worth automating. Anything in validating, in_progress or finalizing with only a few hours of window left is heading for the same outcome, and there is still time to submit the tail as a second batch rather than lose it.

Stop treating anything-but-completed as still running

expired, failed and cancelled are terminal. A poller that waits for completed hangs on all three. Test for a terminal set and branch, and give the poll loop a wall-clock ceiling of its own so it cannot outlive the window it is waiting on.

Split the work so it fits, and diary the deadline

Keep a single batch well under the 50,000 request and 200 MB ceilings, hold a small number in flight rather than submitting a loop of them, and store expires_at in your own job table with an alert at the twenty-hour mark. The window you cannot extend is one you have to plan inside.

How to check it worked

Re-run after splitting the job. Nothing should be expired, and nothing in flight should be near its deadline.

python3 openai_batch_expiry_audit.py --warn-hours 4
# in-flight       batch_68f3c2  21.4 hour(s) of window left (from expires_at); 8200 of 20000 row(s) done
# 9 batch(es) checked, 0 expired, 0 close to expiring

The full code

One paginated GET, no writes, and a project key set to Read Only. The classifier takes now as an argument for the obvious reason and one less obvious one: the pre-emptive half of this check is entirely a subtraction against the current time, so without an injected clock the only testable case is the one that has already gone wrong. The deadline itself is a second pure function, because the object offers three different timestamps to measure from and they are not equally good.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 LLM API fixes, free and open source.
openai_batch_expiry_audit.py
"""Report OpenAI batches that expired, and the ones about to.

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 rows
that never ran means spending money on inference.
"""
import argparse
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_batch_expiry_audit")

API = "https://api.openai.com/v1"

# completion_window accepts one value. This is not a default, it is the value.
WINDOW = 86400

IN_FLIGHT = ("validating", "in_progress", "finalizing", "cancelling")

# Terminal and not this note: a completed batch finished, a failed one never
# started, a cancelled one was stopped on purpose.
SETTLED = ("completed", "failed", "cancelled")

FINDINGS = ("expired", "overdue", "expiring-soon")


def counts_of(batch):
    """Read request_counts into (total, completed), or None. Pure."""
    counts = batch.get("request_counts")
    if not isinstance(counts, dict):
        return None
    try:
        return (int(counts.get("total") or 0), int(counts.get("completed") or 0))
    except (TypeError, ValueError):
        return None


def deadline(batch):
    """When this batch's window closes, and where the number came from. Pure.

    Returns (unix_seconds, source) or (None, reason). Three timestamps can
    answer this and they are not equally good, which is why the source is
    returned alongside the number rather than thrown away:

      expires_at      the API's own answer. Use it whenever it is there.
      in_progress_at  the window runs from when processing started, so this
                      plus 24h is the deadline whenever expires_at is absent.
      created_at      an upper bound only. Time spent in validating is not part
                      of the window, so this over-estimates the time left.
    """
    for field, offset, source in (
            ("expires_at", 0, "expires_at"),
            ("in_progress_at", WINDOW, "in_progress_at plus 24h"),
            ("created_at", WINDOW,
             "created_at plus 24h, an upper bound: the window starts when the "
             "batch starts processing, not when it was created")):
        raw = batch.get(field)
        if raw in (None, ""):
            continue
        try:
            value = int(raw)
        except (TypeError, ValueError):
            continue
        if value > 0:
            return (value + offset, source)
    return (None, "no usable timestamp on this object")


def verdict(batch, now, warn_hours=4):
    """Classify one object from GET /v1/batches against a clock you pass in.

    Pure. warn_hours is the headroom below which an in-flight batch is called
    out: 4 hours left of a 24 hour window is the 20 hour mark. Returns
    (state, detail).
    """
    status = str(batch.get("status") or "").strip().lower()
    numbers = counts_of(batch)
    total, done = numbers if numbers else (0, 0)
    rows = ("%d of %d row(s)" % (done, total)) if total else "an unreadable count of rows"

    if status == "expired":
        missing = max(0, total - done)
        return ("expired",
                "the 24 hour window closed with %d row(s) unfinished (%s done). "
                "Each one is a batch_expired line in the error file, and none of "
                "them will run." % (missing, rows))
    if status in SETTLED:
        return ("settled",
                "status is %s, so no window is running against it" % status)
    if status not in IN_FLIGHT:
        return ("unreadable",
                "status is %r, which is not a lifecycle state this script "
                "recognises" % (status or None,))

    when, source = deadline(batch)
    if when is None:
        return ("unreadable",
                "still %s and there is %s, so the window cannot be measured"
                % (status, source))

    left = when - int(now)
    hours = abs(left) / 3600.0
    if left <= 0:
        return ("overdue",
                "still %s, %.1f hour(s) past the close of its window (from %s). "
                "The rows that have not run are not going to." % (status, hours, source))
    if left <= warn_hours * 3600:
        return ("expiring-soon",
                "%.1f hour(s) of window left (from %s) with %s done. Submit the "
                "tail as a second batch while there is still time."
                % (hours, source, rows))
    return ("in-flight",
            "%.1f hour(s) of window left (from %s); %s done" % (hours, source, rows))


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("--warn-hours", type=float, default=4.0,
                    help="call out in-flight batches with less than this many "
                         "hours of window left (default 4, the 20 hour mark)")
    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 settled batches")
    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})

    now = int(time.time())
    checked = 0
    expired = 0
    closing = 0
    for batch in batches(session, args.limit, args.pages):
        state, detail = verdict(batch, now, args.warn_hours)
        batch_id = str(batch.get("id") or "?")
        line = "%-15s %s  %s" % (state, batch_id, detail)
        checked += 1

        if state == "expired":
            expired += 1
            log.warning(line)
            error_file = batch.get("error_file_id")
            log.warning("  repair: rebuild a .jsonl of the custom_ids whose "
                        "error.code is batch_expired%s and re-submit them, then "
                        "split future jobs so one batch stays well under 50,000 "
                        "requests",
                        (" from GET /v1/files/%s/content" % error_file)
                        if error_file else "")
        elif state in ("overdue", "expiring-soon"):
            closing += 1
            log.warning(line)
            log.warning("  repair: store expires_at in your own job table and "
                        "alert at the 20 hour mark; a poller that waits for "
                        "status == completed waits forever on an expired batch")
        elif state == "unreadable":
            log.warning(line)
        elif args.show_all or state == "in-flight":
            log.info(line)

    log.info("%d batch(es) checked, %d expired, %d close to expiring",
             checked, expired, closing)
    return 1 if (expired or closing) else 0


if __name__ == "__main__":
    sys.exit(main())
openai-batch-expiry-audit.mjs
/**
 * Report OpenAI batches that expired, and the ones about to.
 *
 * 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';

// completion_window accepts one value. This is not a default, it is the value.
const WINDOW = 86400;

const IN_FLIGHT = ['validating', 'in_progress', 'finalizing', 'cancelling'];

// Terminal and not this note.
const SETTLED = ['completed', 'failed', 'cancelled'];

/** Read request_counts into [total, completed], or null. Pure. */
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);
  if (!Number.isFinite(total) || !Number.isFinite(done)) return null;
  return [Math.trunc(total), Math.trunc(done)];
}

/**
 * When this batch's window closes, and where the number came from. Pure.
 * Returns [unixSeconds, source] or [null, reason]. Three timestamps can answer
 * this and they are not equally good, which is why the source is returned
 * alongside the number: expires_at is the API's own answer, in_progress_at plus
 * 24h is the deadline when it is absent, and created_at plus 24h is an upper
 * bound only, because time spent validating is not part of the window.
 */
export function deadline(batch) {
  const candidates = [
    ['expires_at', 0, 'expires_at'],
    ['in_progress_at', WINDOW, 'in_progress_at plus 24h'],
    ['created_at', WINDOW,
      'created_at plus 24h, an upper bound: the window starts when the batch ' +
      'starts processing, not when it was created'],
  ];
  for (const [field, offset, source] of candidates) {
    const raw = batch[field];
    if (raw === null || raw === undefined || raw === '') continue;
    const value = Number(raw);
    if (Number.isFinite(value) && value > 0) return [Math.trunc(value) + offset, source];
  }
  return [null, 'no usable timestamp on this object'];
}

/**
 * Classify one object from GET /v1/batches against a clock you pass in. Pure.
 * warnHours is the headroom below which an in-flight batch is called out: 4
 * hours left of a 24 hour window is the 20 hour mark. Returns [state, detail].
 */
export function verdict(batch, now, warnHours = 4) {
  const status = String(batch.status ?? '').trim().toLowerCase();
  const numbers = countsOf(batch);
  const [total, done] = numbers ?? [0, 0];
  const rows = total ? `${done} of ${total} row(s)` : 'an unreadable count of rows';

  if (status === 'expired') {
    const missing = Math.max(0, total - done);
    return ['expired',
      `the 24 hour window closed with ${missing} row(s) unfinished (${rows} ` +
      'done). Each one is a batch_expired line in the error file, and none of ' +
      'them will run.'];
  }
  if (SETTLED.includes(status)) {
    return ['settled', `status is ${status}, so no window is running against it`];
  }
  if (!IN_FLIGHT.includes(status)) {
    return ['unreadable',
      `status is ${JSON.stringify(status || null)}, which is not a lifecycle ` +
      'state this script recognises'];
  }

  const [when, source] = deadline(batch);
  if (when === null) {
    return ['unreadable',
      `still ${status} and there is ${source}, so the window cannot be measured`];
  }

  const left = when - Math.trunc(now);
  const hours = (Math.abs(left) / 3600).toFixed(1);
  if (left <= 0) {
    return ['overdue',
      `still ${status}, ${hours} hour(s) past the close of its window (from ` +
      `${source}). The rows that have not run are not going to.`];
  }
  if (left <= warnHours * 3600) {
    return ['expiring-soon',
      `${hours} hour(s) of window left (from ${source}) with ${rows} done. ` +
      'Submit the tail as a second batch while there is still time.'];
  }
  return ['in-flight',
    `${hours} hour(s) of window left (from ${source}); ${rows} done`];
}

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 warnHours = Number(process.env.WARN_HOURS ?? 4);
  const pageSize = Number(process.env.LIMIT ?? 100);
  const maxPages = Number(process.env.PAGES ?? 20);
  const showAll = process.argv.includes('--show-all');
  const now = Math.floor(Date.now() / 1000);

  let checked = 0;
  let expired = 0;
  let closing = 0;
  for await (const batch of walk(key, pageSize, maxPages)) {
    const [state, detail] = verdict(batch, now, warnHours);
    const line = `${state.padEnd(15)} ${String(batch.id ?? '?')}  ${detail}`;
    checked += 1;

    if (state === 'expired') {
      expired += 1;
      console.warn(line);
      const errorFile = batch.error_file_id;
      console.warn('  repair: rebuild a .jsonl of the custom_ids whose ' +
        'error.code is batch_expired' +
        (errorFile ? ` from GET /v1/files/${errorFile}/content` : '') +
        ' and re-submit them, then split future jobs so one batch stays well ' +
        'under 50,000 requests');
    } else if (state === 'overdue' || state === 'expiring-soon') {
      closing += 1;
      console.warn(line);
      console.warn('  repair: store expires_at in your own job table and alert ' +
        'at the 20 hour mark; a poller that waits for status == completed waits ' +
        'forever on an expired batch');
    } else if (state === 'unreadable') {
      console.warn(line);
    } else if (showAll || state === 'in-flight') {
      console.log(line);
    }
  }

  console.log(`${checked} batch(es) checked, ${expired} expired, ${closing} ` +
              'close to expiring');
  process.exitCode = (expired || closing) ? 1 : 0;
}

// Only run when invoked directly, so importing this module from the test file
// does not fire main() and fail on the missing key.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The clock is an argument, so the two states that only exist for a few hours can be tested at all: a batch with two hours of window left, and one whose window closed while it was still reporting in_progress. The other tests pin down which timestamp the deadline came from, because a fallback to created_at over-states the time remaining and a report that quietly does that is worse than no report.

test_openai_batch_expiry_audit.py
from openai_batch_expiry_audit import counts_of, deadline, verdict

# 2026-08-30T00:00:00Z. Fixed, because every state here is a subtraction from it.
NOW = 1788048000
HOUR = 3600


def batch(status="in_progress", total=20000, completed=8000, **extra):
    body = {"id": "batch_test", "status": status,
            "request_counts": {"total": total, "completed": completed,
                               "failed": 0}}
    body.update(extra)
    return body


def test_an_expired_batch_reports_the_rows_that_never_ran():
    state, detail = verdict(
        batch(status="expired", total=50000, completed=20000,
              expired_at=NOW - HOUR), NOW)
    assert state == "expired"
    assert "30000 row(s) unfinished" in detail
    assert "batch_expired" in detail


def test_a_batch_close_to_its_deadline_is_the_useful_finding():
    state, detail = verdict(batch(expires_at=NOW + 2 * HOUR), NOW, warn_hours=4)
    assert state == "expiring-soon"
    assert "2.0 hour(s) of window left" in detail
    assert "second batch" in detail


def test_a_batch_with_room_left_is_left_alone():
    state, detail = verdict(batch(expires_at=NOW + 23 * HOUR), NOW, warn_hours=4)
    assert state == "in-flight"
    assert "23.0 hour(s)" in detail


def test_a_window_that_closed_while_the_status_still_says_running():
    state, detail = verdict(batch(expires_at=NOW - HOUR), NOW)
    assert state == "overdue"
    assert "1.0 hour(s) past" in detail


def test_the_deadline_says_which_timestamp_it_came_from():
    assert deadline({"expires_at": NOW}) == (NOW, "expires_at")
    when, source = deadline({"in_progress_at": NOW - HOUR})
    assert when == NOW - HOUR + 86400
    assert source == "in_progress_at plus 24h"
    when, source = deadline({"created_at": NOW - HOUR})
    assert when == NOW - HOUR + 86400
    assert "upper bound" in source
    assert deadline({"id": "b"})[0] is None


def test_expires_at_wins_over_the_fallbacks():
    # A long validating queue makes created_at plus 24h too generous, so the
    # API's own answer is preferred whenever the object carries it.
    when, source = deadline({"created_at": NOW - 6 * HOUR,
                             "in_progress_at": NOW - HOUR,
                             "expires_at": NOW + 2 * HOUR})
    assert when == NOW + 2 * HOUR
    assert source == "expires_at"


def test_settled_and_unreadable_batches_are_not_findings():
    for status in ("completed", "failed", "cancelled"):
        assert verdict(batch(status=status), NOW)[0] == "settled"
    assert verdict(batch(status="teleporting"), NOW)[0] == "unreadable"
    assert verdict(batch(), NOW)[0] == "unreadable"  # in flight, no timestamps
    assert counts_of({"request_counts": {"total": 5, "completed": 5}}) == (5, 5)
    assert counts_of({}) is None
openai-batch-expiry-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countsOf, deadline, verdict } from './openai-batch-expiry-audit.mjs';

// 2026-08-30T00:00:00Z. Fixed, because every state here is a subtraction from it.
const NOW = 1788048000;
const HOUR = 3600;

function batch({ status = 'in_progress', total = 20000, completed = 8000,
                 ...extra } = {}) {
  return {
    id: 'batch_test',
    status,
    request_counts: { total, completed, failed: 0 },
    ...extra,
  };
}

test('an expired batch reports the rows that never ran', () => {
  const [state, detail] = verdict(
    batch({ status: 'expired', total: 50000, completed: 20000,
            expired_at: NOW - HOUR }), NOW);
  assert.equal(state, 'expired');
  assert.match(detail, /30000 row\(s\) unfinished/);
  assert.match(detail, /batch_expired/);
});

test('a batch close to its deadline is the useful finding', () => {
  const [state, detail] = verdict(batch({ expires_at: NOW + 2 * HOUR }), NOW, 4);
  assert.equal(state, 'expiring-soon');
  assert.match(detail, /2\.0 hour\(s\) of window left/);
  assert.match(detail, /second batch/);
});

test('a batch with room left is left alone', () => {
  const [state, detail] = verdict(batch({ expires_at: NOW + 23 * HOUR }), NOW, 4);
  assert.equal(state, 'in-flight');
  assert.match(detail, /23\.0 hour\(s\)/);
});

test('a window that closed while the status still says running', () => {
  const [state, detail] = verdict(batch({ expires_at: NOW - HOUR }), NOW);
  assert.equal(state, 'overdue');
  assert.match(detail, /1\.0 hour\(s\) past/);
});

test('the deadline says which timestamp it came from', () => {
  assert.deepEqual(deadline({ expires_at: NOW }), [NOW, 'expires_at']);
  const [started, startedSource] = deadline({ in_progress_at: NOW - HOUR });
  assert.equal(started, NOW - HOUR + 86400);
  assert.equal(startedSource, 'in_progress_at plus 24h');
  const [created, createdSource] = deadline({ created_at: NOW - HOUR });
  assert.equal(created, NOW - HOUR + 86400);
  assert.match(createdSource, /upper bound/);
  assert.equal(deadline({ id: 'b' })[0], null);
});

test('expires_at wins over the fallbacks', () => {
  const [when, source] = deadline({
    created_at: NOW - 6 * HOUR,
    in_progress_at: NOW - HOUR,
    expires_at: NOW + 2 * HOUR,
  });
  assert.equal(when, NOW + 2 * HOUR);
  assert.equal(source, 'expires_at');
});

test('settled and unreadable batches are not findings', () => {
  for (const status of ['completed', 'failed', 'cancelled']) {
    assert.equal(verdict(batch({ status }), NOW)[0], 'settled');
  }
  assert.equal(verdict(batch({ status: 'teleporting' }), NOW)[0], 'unreadable');
  assert.equal(verdict(batch(), NOW)[0], 'unreadable');
  assert.deepEqual(countsOf({ request_counts: { total: 5, completed: 5 } }), [5, 5]);
  assert.equal(countsOf({}), null);
});

FAQ

Can I ask for a completion window longer than 24 hours?

No. completion_window takes the single value 24h, there is no priority tier for batch, and there is no extension request. The window is a constraint to plan inside rather than a setting to tune, which is why the repair is to split the job rather than to ask for more time.

When does the clock actually start?

When the batch starts processing, which the object records as in_progress_at, not when you created it. Time spent in validating is not charged against the window. In practice you should read expires_at, which the API sets for you; the fallbacks matter only when an object does not carry it, and a fallback to created_at over-states the time you have left.

What happened to the rows that did finish?

They are in the output file and they are good. An expired batch is a partial result, not a failed one, which is why it is dangerous downstream: the output parses cleanly at a size nobody checks. Reconcile the line count against the input file before loading it.

How do I find out which rows to re-submit?

The error file. Every abandoned row is written there with an error code of batch_expired, so selecting those lines and taking their custom_ids gives you exactly the re-submission set. That file expires thirty days after it was written, and after that the only way to reconstruct the list is to re-run the whole batch and diff it.

Does Anthropic's Message Batches API expire the same way?

It has the same idea with different numbers and a different vocabulary. A Claude message batch is also expected to complete within 24 hours, requests that do not finish come back with a result type of expired, and the batch itself is cancellable mid-flight. There is no completion_window parameter to set at all, and results are retained for twenty-nine days rather than thirty.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.