Check Batch Status
Following a Batch from Submission to Completion
After a batch is submitted, it moves through a small set of states, and it does not tell you anything about its progress in real time. You learn where it stands by asking. The API provides a retrieve call for a single batch, which returns its current state, and the answer you get back is what your application uses to decide what to do next.
This lesson explains each state, what the counts mean, how to check status without wasting requests, and how to build a waiting function that stops at a time you choose. The examples work offline, using a fake client that behaves like the real one, so you can see the full lifecycle without any cost.
The Three Processing States
Every batch has a processing_status, which takes one of three values:
| Status | Meaning | What you can do |
|---|---|---|
in_progress | The batch is being processed | Keep waiting, or check the counts for progress |
canceling | A cancellation was requested, and the batch is winding down | Wait for it to reach ended |
ended | Processing is complete, and results are ready | Read the results file |
The status changes only in one direction: in_progress may move to canceling or ended, and canceling moves to ended. A batch never goes back. That one-way movement makes polling simple: once you see ended, you can stop checking.
Notice that ended does not mean every request succeeded. It means every request has reached a final outcome, and the outcomes are recorded in the counts and in the results file.
Reading the Request Counts
The request_counts object breaks the batch down by outcome:
| Count | Meaning |
|---|---|
processing | Requests still being worked on |
succeeded | Requests that produced a message |
errored | Requests that failed with an error, and produced no message |
canceled | Requests that were canceled before reaching the model |
expired | Requests that reached the 24-hour expiry before being sent to the model |
Two properties follow from the documentation. First, the counts always add up to the total number of requests in the batch. Second, all counts except processing stay at zero until the batch has ended. So during processing, processing tells you how much work is left, but succeeded and the others tell you nothing yet.
The last two outcomes are not billed. Requests that errored, were canceled, or expired were never processed by the model, so they cost nothing. Only succeeded requests carry token charges.
The Timestamps
A batch object includes several times, and each one answers a different question:
| Field | Meaning |
|---|---|
created_at | When the batch was submitted |
expires_at | When processing stops, 24 hours after creation |
ended_at | When processing finished, or null while it is still running |
cancel_initiated_at | When a cancellation was requested, or null |
results_url | The location of the results file, present only after the batch has ended |
expires_at is a deadline, not a prediction. A batch that is still in_progress at that moment is cut off, and its unfinished requests become expired. Reading expires_at when you submit a batch lets you set your own deadline earlier, which gives you time to react.
Polling Without Wasting Requests
The simplest approach is a loop that retrieves the batch and sleeps, as the documentation's example does. That works, but the interval matters. Polling every second produces many requests for a process that takes an hour. Polling only once a day misses results for a day.
A sensible approach waits longer between checks as time passes, and never waits longer than a fixed maximum. Short early intervals catch quick batches, and longer later intervals keep the request count low for slow ones:
def next_interval(elapsed: float, first: float = 30, cap: float = 300) -> float:
"""Wait longer as the batch runs longer, but never longer than the cap."""
if elapsed < 300:
return first
if elapsed < 1800:
return 120
return cap
With these values, the checks happen at roughly 30-second intervals for the first five minutes, every two minutes until the thirtieth minute, and every five minutes after that. A batch that finishes in ten minutes is checked about twenty times, while a batch that takes an hour is checked fewer than twenty times.
Practical Example: A Waiting Function
The function below waits for a batch to end. It accepts a callback so the caller can report progress, a time limit so the wait cannot run forever, and an injectable sleep function so the logic can be tested without real delays.
"""batch_status.py: wait for a batch to end, with progress reports and a time limit."""
import time
from dataclasses import dataclass
@dataclass
class Snapshot:
status: str
processing: int
succeeded: int
errored: int
canceled: int
expired: int
elapsed: float
def snapshot_of(batch, elapsed: float) -> Snapshot:
counts = batch.request_counts
return Snapshot(batch.processing_status, counts.processing, counts.succeeded,
counts.errored, counts.canceled, counts.expired, elapsed)
def next_interval(elapsed: float, first: float = 30, cap: float = 300) -> float:
if elapsed < 300:
return first
if elapsed < 1800:
return 120
return cap
def wait_for_batch(client, batch_id: str, *, max_seconds: float = 24 * 3600,
on_progress=None, sleep=time.sleep, clock=time.monotonic):
"""Return the batch once it has ended, or raise TimeoutError if max_seconds passes first."""
start = clock()
while True:
elapsed = clock() - start
batch = client.messages.batches.retrieve(batch_id)
snap = snapshot_of(batch, elapsed)
if on_progress:
on_progress(snap)
if batch.processing_status == "ended":
return batch
if elapsed >= max_seconds:
raise TimeoutError(f"batch {batch_id} is still {batch.processing_status} "
f"after {elapsed:.0f} seconds")
sleep(min(next_interval(elapsed), max_seconds - elapsed))
How It Works
snapshot_of()copies the fields the caller cares about into a plain object. Reporting code never has to know the SDK's classes.wait_for_batch()retrieves first, then decides. The first call happens immediately, so an already-finished batch returns at once.- The progress callback runs on every check, so a dashboard or log always shows the latest counts.
- The loop returns only on
ended. Canceling batches are not yet finished, so the loop keeps waiting forended, which matches the documented sequence. - The time limit is checked before sleeping. The sleep never runs past the limit, so the timeout is accurate to within one check.
sleepandclockare parameters, which is what makes the demonstration below possible without waiting.
Demonstration with a Fake Client
The fake client below returns a fixed sequence of states, so you can watch the lifecycle without submitting anything. It is not a substitute for real tests, but it shows exactly how the function responds to each state.
"""batch_status_demo.py: watch a batch move through its states, using a fake client."""
from types import SimpleNamespace
from batch_status import wait_for_batch
def fake_batch(status, processing, succeeded=0, errored=0):
return SimpleNamespace(
id="msgbatch_demo",
processing_status=status,
request_counts=SimpleNamespace(processing=processing, succeeded=succeeded,
errored=errored, canceled=0, expired=0),
)
class FakeBatches:
def __init__(self, sequence):
self.sequence = list(sequence)
def retrieve(self, batch_id):
return self.sequence.pop(0) if len(self.sequence) > 1 else self.sequence[0]
client = SimpleNamespace(messages=SimpleNamespace(batches=FakeBatches([
fake_batch("in_progress", processing=3),
fake_batch("in_progress", processing=1, succeeded=2),
fake_batch("ended", processing=0, succeeded=2, errored=1),
])))
ticks = iter(range(0, 1000, 60)) # Each check is 60 seconds later
seen = []
batch = wait_for_batch(client, "msgbatch_demo",
on_progress=lambda s: seen.append((s.status, s.processing, s.succeeded)),
sleep=lambda seconds: None,
clock=lambda: next(ticks))
for status, processing, succeeded in seen:
print(f"{status:<12} processing={processing} succeeded={succeeded}")
print("final:", batch.processing_status)
Output:
in_progress processing=3 succeeded=0
in_progress processing=1 succeeded=2
ended processing=0 succeeded=2
final: ended
How It Works
- The fake client replays three states, the way a real batch moves from submission to completion.
- The fake clock advances by 60 seconds per check, so the time limit logic runs without real waiting.
- Each progress report records the state the function saw, so the printed table shows the sequence in order.
- The function returns the final batch object when it sees
ended, which is the object the next lesson reads results from.
Note: the fake client returns the last state repeatedly once the sequence is exhausted. This matches how a real batch behaves at the end: once
ended, it staysended.
Checking Without Waiting
Not every program needs to block. A dashboard or a scheduled job may only want to know the current state. For that, a single retrieve call is enough, and the function below turns the state into a plain message:
def describe(batch) -> str:
counts = batch.request_counts
if batch.processing_status == "ended":
return (f"ended: {counts.succeeded} succeeded, {counts.errored} errored, "
f"{counts.canceled} canceled, {counts.expired} expired")
return f"{batch.processing_status}: {counts.processing} still processing"
A scheduled job can call describe() every hour, and write the result to its log. The job never blocks, and the log shows progress over the batch's lifetime.
The Retrieve Call Is Idempotent
Retrieving a batch changes nothing on the server. Calling it ten times returns the same answer, so checking status is always safe to repeat, including after a crash or a restart. Your program can store the batch ID, exit, and check again later, without any risk of duplicating the work.
Summary
A batch is a long-running object with three states and a set of counts. Waiting for it means retrieving it repeatedly, with intervals that grow over time and a clear time limit. The counts reveal progress only while processing, and the timestamps set your deadlines. Once a batch has ended, its outcomes are final, and the next step is reading its results.