Collect a batch without losing individual failures
Batching is for asynchronous groups of requests. Claude Messages and Codex declare batch support; Managed Agents, AGY, OpenCode, and gateways do not.
Give every request a unique customId from your application's record identity. Submit bodies with provider-compatible models/tools, then persist the returned batch_id with its owner. Do not correlate responses by array position.
Resume collection in a later worker
Download batch-collector.ts. The function below takes a saved ID and performs one collection attempt. It distinguishes active work, terminal work without available results, and retrievable items. It does not submit, cancel, or poll in a loop. The application owns the client.
Authorize access to the saved batch before calling it; a provider credential does not establish which application user owns the batch.
import { ApiClientError, ApiClientErrorCode, ApiClientErrorType, type CapabilityClient, type RuntimeBatchResult } from "@cavi-ai/api-client";
export async function collectBatch(client: CapabilityClient, batchId: string) {
const status = await client.getBatch(batchId);
if (!status.ok) return status;
const batch = status.data;
if (!status.data.resultsAvailable) {
const kind = ["completed", "failed", "cancelled", "expired"].includes(batch.status)
? "terminal" as const : "pending" as const;
return { ok: true as const, source: status.source, data: { kind, batch } };
}
const results = await client.getBatchResults(batchId);
if (!results.ok) return results;
const items = new Map<string, RuntimeBatchResult>();
for (const item of results.data) {
if (!item.customId.trim() || items.has(item.customId)) {
throw new ApiClientError("Batch results have missing or duplicate correlation IDs.", {
type: ApiClientErrorType.Validation, code: ApiClientErrorCode.ProtocolMismatch,
cause: { batch, customId: item.customId },
});
}
items.set(item.customId, item);
}
return {
ok: true as const,
source: results.source,
data: {
kind: "results" as const,
batch,
items,
},
};
}| Result | Worker action |
|---|---|
ok: false | Retain the saved ID and handle the original facade gap; do not submit again |
pending | Save batch and schedule another authorized collection attempt |
terminal | Save batch and stop normal polling; no result artifact was advertised |
results | Record each item by its correlation ID, retaining failures |
Known terminal states are completed, failed, cancelled, and Codex's expired. Check resultsAvailable first: a cancelled or expired batch can still expose useful results. A completed batch without an advertised artifact does not become an empty successful result. Unknown provider states remain pending; bound retries and escalate them through your application's job policy.
For collected items, inspect each outcome. A succeeded item can carry a run result; inspect its run state and required output before calling it an answer. Text remains optional. Keep errored, canceled, expired, and unknown item outcomes in your reporting. The batch state uses cancelled; item outcomes use canceled.
This helper requires nonblank, unique result IDs. It throws ApiClientError with ProtocolMismatch if correlation is ambiguous, retaining the batch in cause rather than overwriting a prior item. Compare returned IDs with the request IDs you persisted: this check does not prove that every submitted request has a returned result. Keep missing IDs visible and reconcile them.
Retrieval gaps remain facade results; authentication and unclassified failures still reject. Store provider diagnostics in protected telemetry. A retry hint does not establish that resubmitting a failed item or batch is safe.
Run the collection tests
Download batch-tests.ts beside the collector in examples/. Use the ESM workspace and package installation from consumer testing, then run:
npx tsc --target ES2022 --module NodeNext --moduleResolution NodeNext \
--strict --skipLibCheck --types node --outDir .batch-tests \
examples/batch-tests.ts examples/batch-collector.ts
node --test .batch-tests/batch-tests.jsExpected result: eight passing tests without provider credentials or network calls. Native Claude and Codex HTTP fixtures exercise the installed adapters, facade, and collector. They check terminal states, out-of-order mixed outcomes, ambiguous IDs, retrieval gaps, authentication, and retrieval-only requests. Each test disposes its client through the test cleanup hook.
Submit and wait locally when appropriate
The submission and bounded-wait helper accepts your request array and returns every result. It preserves the batch ID in the error if its poll budget expires; stopping that wait does not cancel the backend batch. It borrows the client.
Codex uses uploaded JSONL and result files; Claude uses Message Batches. Provider limits and asynchronous timing differ. The normalized request/result contract does not remove those limits.
Facade callers inspect result.ok; raw callers must check both runtimeSupports(capabilities, "batch") and optional method presence. Raw guard · Batch methods · Errors
Referenced examples
Verified example · TypeScript
Batch collector
examples/batch-collector.ts
import { ApiClientError, ApiClientErrorCode, ApiClientErrorType, type CapabilityClient, type RuntimeBatchResult } from "@cavi-ai/api-client";
export async function collectBatch(client: CapabilityClient, batchId: string) {
const status = await client.getBatch(batchId);
if (!status.ok) return status;
const batch = status.data;
if (!status.data.resultsAvailable) {
const kind = ["completed", "failed", "cancelled", "expired"].includes(batch.status)
? "terminal" as const : "pending" as const;
return { ok: true as const, source: status.source, data: { kind, batch } };
}
const results = await client.getBatchResults(batchId);
if (!results.ok) return results;
const items = new Map<string, RuntimeBatchResult>();
for (const item of results.data) {
if (!item.customId.trim() || items.has(item.customId)) {
throw new ApiClientError("Batch results have missing or duplicate correlation IDs.", {
type: ApiClientErrorType.Validation, code: ApiClientErrorCode.ProtocolMismatch,
cause: { batch, customId: item.customId },
});
}
items.set(item.customId, item);
}
return {
ok: true as const,
source: results.source,
data: {
kind: "results" as const,
batch,
items,
},
};
}
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/batch-collector.ts- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48
Verified example · TypeScript
Batch tests
examples/batch-tests.ts
import assert from "node:assert/strict";
import { test, type TestContext } from "node:test";
import { createApiClient, createRuntimeProviderRegistry, isApiClientError, isAuthError } from "@cavi-ai/api-client";
import { createClaudeProviderModule } from "@cavi-ai/api-client/providers/claude/messages";
import { createCodexProviderModule } from "@cavi-ai/api-client/providers/codex/runtime";
import { collectBatch } from "./batch-collector.js";
function fixture(t: TestContext, provider: "claude" | "codex", reply: (url: string) => Response) {
const requests: string[] = [];
const client = createApiClient(provider, {
registry: createRuntimeProviderRegistry({ modules: [provider === "claude"
? createClaudeProviderModule({ apiKey: "fixture-key" })
: createCodexProviderModule({ apiKey: "fixture-key" })] }),
fetchImpl: async (url, init) => {
assert.equal(init?.method ?? "GET", "GET", "resuming must never submit or cancel work");
requests.push(String(url));
return reply(String(url));
},
});
t.after(() => client.dispose());
return { client, requests };
}
test("keeps an active batch pending without fetching result files", async (t) => {
const app = fixture(t, "codex", () => Response.json({ id: "saved-1", status: "in_progress" }));
const result = await collectBatch(app.client, "saved-1");
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.data.kind, "pending");
assert.equal(result.data.batch.batch_id, "saved-1");
assert.equal(app.requests.length, 1);
});
test("stops rescheduling known terminal states with no available results", async (t) => {
for (const status of ["failed", "cancelled", "expired", "completed"]) {
const app = fixture(t, "codex", () => Response.json({ id: "saved-1", status }));
const result = await collectBatch(app.client, "saved-1");
assert.equal(result.ok, true);
if (!result.ok) continue;
assert.equal(result.data.kind, "terminal", status);
assert.equal(result.data.batch.status, status);
assert.equal(app.requests.length, 1);
}
});
test("collects available results even when the batch was cancelled", async (t) => {
const app = fixture(t, "codex", (url) => url.endsWith("/content")
? new Response(JSON.stringify({ custom_id: "one", response: { status_code: 200,
body: { id: "answer-1", status: "completed", output_text: "Retained answer" },
} }))
: Response.json({ id: "saved-1", status: "cancelled", output_file_id: "output-1" }));
const result = await collectBatch(app.client, "saved-1");
assert.equal(result.ok, true);
if (!result.ok || result.data.kind !== "results") return assert.fail("Expected available results");
assert.equal(result.source, "live");
assert.equal(result.data.batch.status, "cancelled");
assert.equal(result.data.items.get("one")?.run?.output, "Retained answer");
assert.ok(app.requests.every((url) => url.endsWith("/batches/saved-1") || url.endsWith("/files/output-1/content")));
});
function claudeResults(items: unknown[]) {
return (url: string) => url.endsWith("/results")
? new Response(items.map((item) => JSON.stringify(item)).join("\n"))
: Response.json({ id: "saved-1", processing_status: "ended" });
}
test("correlates out-of-order items and retains every outcome", async (t) => {
const app = fixture(t, "claude", claudeResults([
{ custom_id: "two", result: { type: "errored", error: { message: "Private item diagnostic" } } },
{ custom_id: "one", result: { type: "succeeded", message: { id: "answer-1", content: [{ type: "text", text: "Answer" }], stop_reason: "end_turn" } } },
{ custom_id: "three", result: { type: "canceled" } },
{ custom_id: "four", result: { type: "expired" } },
{ custom_id: "five", result: { type: "future-outcome" } },
]));
const result = await collectBatch(app.client, "saved-1");
assert.equal(result.ok, true);
if (!result.ok || result.data.kind !== "results") return assert.fail("Expected results");
assert.equal(result.data.batch.batch_id, "saved-1");
assert.equal(result.data.items.size, 5);
assert.equal(result.data.items.get("one")?.run?.output, "Answer");
assert.equal(result.data.items.get("two")?.outcome, "errored");
assert.equal(result.data.items.get("three")?.outcome, "canceled");
assert.equal(result.data.items.get("four")?.outcome, "expired");
assert.equal(result.data.items.get("five")?.outcome, "future-outcome");
});
test("rejects missing or duplicated correlation IDs without overwriting items", async (t) => {
for (const ids of [["same", "same"], [""], [undefined]]) {
const app = fixture(t, "claude", claudeResults(ids.map((id) => ({ custom_id: id, result: { type: "canceled" } }))));
await assert.rejects(collectBatch(app.client, "saved-1"), (error) => {
assert.ok(isApiClientError(error));
assert.equal(error.code, "protocol_mismatch");
assert.equal((error.cause as { batch: { batch_id: string } }).batch.batch_id, "saved-1");
return true;
});
}
});
test("preserves a status retrieval gap", async (t) => {
const app = fixture(t, "codex", () => Response.json({}, { status: 503 }));
const result = await collectBatch(app.client, "saved-1");
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.gap.reason, "backend-unavailable");
assert.equal(app.requests.length, 1);
});
test("preserves a result retrieval gap without reporting empty success", async (t) => {
const app = fixture(t, "claude", (url) => url.endsWith("/results")
? Response.json({}, { status: 503 })
: Response.json({ id: "saved-1", processing_status: "ended" }));
const result = await collectBatch(app.client, "saved-1");
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.gap.reason, "backend-unavailable");
assert.equal(app.requests.length, 2);
});
test("keeps authentication failures as exceptions", async (t) => {
const app = fixture(t, "codex", () => Response.json({}, { status: 401 }));
await assert.rejects(collectBatch(app.client, "saved-1"), isAuthError);
assert.equal(app.requests.length, 1);
});
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/batch-tests.ts- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48
Verified example · TypeScript
Runtime batch
examples/runtime-batch.ts
import { ApiClientError, ApiClientErrorCode, ApiClientErrorType, type CapabilityClient, type RuntimeBatchRequest } from "@cavi-ai/api-client";
export async function submitBatchAndCollect(
client: CapabilityClient,
requests: RuntimeBatchRequest[],
options: { maxPolls?: number; pollIntervalMs?: number } = {},
) {
const { maxPolls = 60, pollIntervalMs = 60_000 } = options;
if (!Number.isInteger(maxPolls) || maxPolls < 0 || !Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) {
throw new ApiClientError("Use a non-negative poll count and interval.", {
type: ApiClientErrorType.Validation, code: ApiClientErrorCode.InvalidRequest,
});
}
const submitted = await client.submitBatch(requests);
if (!submitted.ok) throw new ApiClientError(submitted.gap.note, {
code: ApiClientErrorCode.RequestFailed, cause: submitted.gap,
});
let batch = submitted.data;
for (let poll = 0; !batch.resultsAvailable && poll < maxPolls; poll += 1) {
if (["failed", "cancelled"].includes(batch.status)) break;
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const next = await client.getBatch(batch.batch_id);
if (!next.ok) throw new ApiClientError(next.gap.note, {
code: ApiClientErrorCode.RequestFailed, cause: next.gap,
});
batch = next.data;
}
if (!batch.resultsAvailable) {
// Keep the ID: stopping this local wait does not cancel the backend batch.
throw new ApiClientError(`Batch ${batch.batch_id} is not ready (status: ${batch.status}); retrieve it later or cancel explicitly.`, {
code: ApiClientErrorCode.RequestFailed, cause: batch,
});
}
const results = await client.getBatchResults(batch.batch_id);
if (!results.ok) throw new ApiClientError(results.gap.note, {
code: ApiClientErrorCode.RequestFailed, cause: results.gap,
});
// Callers must inspect each item's outcome, including errored/canceled/expired.
return results.data;
}
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/runtime-batch.ts- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48
Verified example · TypeScript
Runtime capabilities
examples/runtime-capabilities.ts
import { runtimeSupports, type RuntimeClient, type RuntimeBatchRequest } from "@cavi-ai/api-client";
export async function submitWhenBatchIsAvailable(client: RuntimeClient, requests: RuntimeBatchRequest[]) {
const capabilities = await client.getRuntimeCapabilities();
if (!runtimeSupports(capabilities, "batch") || !client.submitBatch) return null;
return client.submitBatch(requests);
}
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/runtime-capabilities.ts- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48