diff --git a/README.md b/README.md
index d7f038a..bf15f52 100644
--- a/README.md
+++ b/README.md
@@ -446,7 +446,8 @@ client.submit_regular(
Every non-2xx surfaces as `BatchAPIError`; the `code` attribute carries
the stable code from the RFC 7807 body (`file_input_not_found`,
-`idempotency_key_conflict`, etc.).
+`idempotency_key_conflict`, `api_key_cap_reached`, etc.) and `detail` the
+human-readable explanation (for a credit cap: which cap, and when it resets).
```python
from zenrows.batch import BatchAPIError
@@ -459,6 +460,27 @@ except BatchAPIError as exc:
raise
```
+### Forward-compatible enums
+
+Every enum the Batch API returns (`RunStatus`, `FailureReason`, ...) is open:
+a value the server adds after your SDK version was released parses as an
+`UNKNOWN` member whose `.value` is the raw string, instead of failing the
+whole response. Branch on the members you know and keep a fallback:
+
+```python
+from zenrows.batch.models import FailureReason
+
+run = client.get_run(job_id, run_id=run_id).data
+if run.failure_reason is FailureReason.API_KEY_CAP_REACHED:
+ print(run.failure_detail) # which cap was reached and when it resets
+elif run.failure_reason is not None:
+ print("failed:", run.failure_reason.value) # includes values unknown to this SDK
+```
+
+Runs that fail because an API key reached its credit cap carry
+`failure_reason = "api_key_cap_reached"`. Older SDK versions without open enums cannot
+parse such runs, so upgrade before enabling API key credit caps with Batch.
+
The full Batch surface (jobs, runs, tasks, results, content, history,
file_inputs, HMAC keys) is reachable via methods on `ZenRowsBatchClient`.
See `src/zenrows/batch/client.py` or `help(ZenRowsBatchClient)`.
diff --git a/docs/batch-client-reference.md b/docs/batch-client-reference.md
index 49cb58d..4f4ced6 100644
--- a/docs/batch-client-reference.md
+++ b/docs/batch-client-reference.md
@@ -410,6 +410,12 @@ def wait_for_run(job_id: str,
Block until a run reaches one of `target_statuses`, polling
with jittered exponential backoff.
+The default targets are every terminal status, `failed`
+included, so a run the API auto-failed is returned (read
+`failure_reason` / `failure_detail` on it) rather than polled
+until `timeout`. Pass `failure_statuses={"failed"}` to have the
+waiter raise `WaiterError` on it instead.
+
`progress=True` shows a tqdm bar with totals as they advance.
`None` inherits the client-level `progress` setting (which
itself defaults to off unless `ZENROWS_BATCH_PROGRESS=true`).
@@ -1219,7 +1225,8 @@ started (``status="failed,pending"``) — the usual move after a
``stop()`` left orphan ``pending`` rows.
Returns a :class:`RunHandle` for the new run. Requires the
-previous run to be terminal (``completed`` / ``stopped``); raises
+previous run to be terminal (``completed`` / ``stopped`` /
+``failed``); raises
``BatchAPIError`` (409 ``run_not_terminal``) otherwise, and
(409 ``no_matching_tasks``) when nothing matched the filter.
@@ -1871,7 +1878,9 @@ class BatchAPIError(Exception)
A non-2xx response from the Batch API.
`code` is the RFC 7807 `code` member (e.g. `file_input_not_found`,
-`idempotency_key_conflict`). Stable; safe to branch on.
+`idempotency_key_conflict`, `api_key_cap_reached`). Stable; safe to
+branch on. `detail` is the human-readable explanation (e.g. which
+credit cap was reached and when it resets): display it, don't parse it.
# Models
@@ -1906,6 +1915,8 @@ class JobStatus(Enum)
- `deleted` — async deletion in progress; the job disappears
once it finishes.
+Clients must accept values not listed here.
+
## ScheduleState Objects
@@ -1933,6 +1944,8 @@ What set this run in motion. Always set.
- `scheduled` — automatic, fired by the configured
schedule (recurring or one-shot).
+Clients must accept values not listed here.
+
## RunStatus Objects
@@ -1952,15 +1965,28 @@ Terminal:
Result bodies are kept. `stats.completed < stats.total`
signals "stopped early".
- `failed` — the run was auto-failed on an account-level error
- (insufficient credits / inactive subscription). No new tasks
- are picked up; `failure_reason` carries the cause. Re-runnable
- once the account is resolved. Result bodies already produced
+ (insufficient credits / inactive subscription / the API key's
+ credit cap reached). No new tasks are picked up;
+ `failure_reason` and `failure_detail` carry the cause.
+ Re-runnable once the account or cap is resolved. Result bodies already produced
are kept.
- `deleted` — caller called
`DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result
bodies and data are being deleted; once complete the run
disappears.
+Clients must accept values not listed here.
+
+
+
+## TaskStatus Objects
+
+```python
+class TaskStatus(Enum)
+```
+
+Clients must accept values not listed here.
+
## ResultType Objects
@@ -1971,6 +1997,8 @@ class ResultType(Enum)
Body format of a successful task result; matches the job's `format` 1:1.
+Clients must accept values not listed here.
+
## Format Objects
@@ -1988,6 +2016,8 @@ Precedence:
Stamped on every successful task result and used to set the
right `Content-Type` when you fetch the content.
+Clients must accept values not listed here.
+
## Method Objects
@@ -2196,6 +2226,8 @@ task row is visible. Omitted on runs whose tasks were
written on the request path (201 submissions and
reruns, `addTasks` batches).
+Clients must accept values not listed here.
+
## FailureReason Objects
@@ -2205,10 +2237,16 @@ class FailureReason(Enum)
```
Present only when `status == failed`: the account-level
-cause of the auto-fail. `insufficient_credits` (out of
-credits) or `subscription_inactive` (subscription not
-active). Omitted otherwise. Distinct from
+cause of the auto-fail. Omitted otherwise. Distinct from
`stats.failure_reasons` (the per-task rollup).
+- `insufficient_credits` — the account is out of credits.
+- `subscription_inactive` — the subscription is not active.
+- `api_key_cap_reached` — the job's API key reached one of
+ its credit caps (day, week, month or billing period).
+ Resolves on its own when that cap's window resets, or
+ when the cap is raised.
+
+Clients must accept values not listed here.
@@ -2247,15 +2285,33 @@ task row is visible. Omitted on runs whose tasks were
written on the request path (201 submissions and
reruns, `addTasks` batches).
+Clients must accept values not listed here.
+
#### failure\_reason
Present only when `status == failed`: the account-level
-cause of the auto-fail. `insufficient_credits` (out of
-credits) or `subscription_inactive` (subscription not
-active). Omitted otherwise. Distinct from
+cause of the auto-fail. Omitted otherwise. Distinct from
`stats.failure_reasons` (the per-task rollup).
+- `insufficient_credits` — the account is out of credits.
+- `subscription_inactive` — the subscription is not active.
+- `api_key_cap_reached` — the job's API key reached one of
+ its credit caps (day, week, month or billing period).
+ Resolves on its own when that cap's window resets, or
+ when the cap is raised.
+
+Clients must accept values not listed here.
+
+
+
+#### failure\_detail
+
+Human-readable explanation of `failure_reason`, e.g. which
+cap was reached and when it resets. Present only when
+`failure_reason` is. At most 500 characters (Unicode code
+points); longer upstream text is cut. Free text for display:
+branch on `failure_reason`, never on this string.
@@ -2330,6 +2386,58 @@ input is `t + "." + raw_body`, HMAC-SHA256. When `false`,
deliveries carry **no** `X-Signature` header — header
absence is the signal.
+
+
+## WebhookEventType Objects
+
+```python
+class WebhookEventType(Enum)
+```
+
+`event_type` of a webhook delivery (see `WebhookEvent`).
+- `run.completed` — the run finished naturally.
+- `run.failed` — the run auto-failed on an account-level
+ error; `failure_reason` and `failure_detail` carry the cause.
+- `webhook.test` — synthetic delivery from `POST /v1/webhook/test`.
+
+Clients must accept values not listed here.
+
+
+
+## FailureReason1 Objects
+
+```python
+class FailureReason1(Enum)
+```
+
+`run.failed` only. Same vocabulary as `Run.failure_reason`.
+Clients must accept values not listed here.
+
+
+
+## WebhookEvent Objects
+
+```python
+class WebhookEvent(BaseModel)
+```
+
+Body POSTed to the configured webhook URL (`WebhookConfig`).
+
+
+
+#### failure\_reason
+
+`run.failed` only. Same vocabulary as `Run.failure_reason`.
+Clients must accept values not listed here.
+
+
+
+#### failure\_detail
+
+`run.failed` only, present when `failure_reason` is. Same as
+`Run.failure_detail`: at most 500 characters (Unicode code
+points).
+
## TestWebhookRequest Objects
@@ -2612,6 +2720,16 @@ class HMACKeyFinalized(BaseModel)
Response to `/rotate/finalize`. No secret.
+
+
+## Reason Objects
+
+```python
+class Reason(Enum)
+```
+
+Clients must accept values not listed here.
+
## InvalidTask Objects
@@ -2620,6 +2738,12 @@ Response to `/rotate/finalize`. No secret.
class InvalidTask(BaseModel)
```
+
+
+#### reason
+
+Clients must accept values not listed here.
+
#### value
@@ -2639,6 +2763,14 @@ class Problem(BaseModel)
RFC 7807 Problem Details.
+
+
+#### code
+
+Stable machine-readable error code, e.g. `payment_required`
+or `api_key_cap_reached`. Branch on this, not on `detail`.
+Clients must accept values not listed here.
+
#### invalid\_tasks
@@ -2659,6 +2791,8 @@ Lifecycle state of a results export.
* `completed` — `download_url` will be present.
* `failed` — `error` carries the reason.
+Clients must accept values not listed here.
+
## StartExportResponse Objects
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 07f401e..13fa3fd 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -367,6 +367,7 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Run' }
'401': { $ref: '#/components/responses/Unauthenticated' }
+ '402': { $ref: '#/components/responses/PaymentRequired' }
'404': { $ref: '#/components/responses/NotFound' }
'409':
description: Latest run is terminal; resume does not apply.
@@ -460,6 +461,7 @@ paths:
schema: { $ref: '#/components/schemas/RerunJobResponse' }
'400': { $ref: '#/components/responses/InvalidArgument' }
'401': { $ref: '#/components/responses/Unauthenticated' }
+ '402': { $ref: '#/components/responses/PaymentRequired' }
'404': { $ref: '#/components/responses/NotFound' }
'409':
description: Latest run not terminal, no prior run available, no tasks match the filter, or idempotency-key reused with a different body.
@@ -1113,7 +1115,16 @@ components:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
PaymentRequired:
- description: Subscription has no credit available.
+ description: |
+ The run cannot start because of billing. The problem `code`
+ tells the cases apart:
+ - `payment_required` — the subscription has expired or has no
+ credit available.
+ - `api_key_cap_reached` — this API key reached one of its
+ credit caps. `detail` names the window, the cap and when it
+ resets (UTC). Other keys on the account are unaffected.
+
+ Clients must accept `code` values not listed here.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
@@ -1164,6 +1175,8 @@ components:
- `deleted` — async deletion in progress; the job disappears
once it finishes.
+ Clients must accept values not listed here.
+
ScheduleState:
type: string
enum: [active, paused]
@@ -1190,6 +1203,8 @@ components:
- `scheduled` — automatic, fired by the configured
schedule (recurring or one-shot).
+ Clients must accept values not listed here.
+
RunStatus:
type: string
x-extensible-enum: true
@@ -1206,25 +1221,32 @@ components:
Result bodies are kept. `stats.completed < stats.total`
signals "stopped early".
- `failed` — the run was auto-failed on an account-level error
- (insufficient credits / inactive subscription). No new tasks
- are picked up; `failure_reason` carries the cause. Re-runnable
- once the account is resolved. Result bodies already produced
+ (insufficient credits / inactive subscription / the API key's
+ credit cap reached). No new tasks are picked up;
+ `failure_reason` and `failure_detail` carry the cause.
+ Re-runnable once the account or cap is resolved. Result bodies already produced
are kept.
- `deleted` — caller called
`DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result
bodies and data are being deleted; once complete the run
disappears.
+ Clients must accept values not listed here.
+
TaskStatus:
type: string
x-extensible-enum: true
enum: [pending, processing, successful, failed]
+ description: Clients must accept values not listed here.
ResultType:
type: string
x-extensible-enum: true
enum: [html, json, markdown, plaintext, pdf]
- description: Body format of a successful task result; matches the job's `format` 1:1.
+ description: |
+ Body format of a successful task result; matches the job's `format` 1:1.
+
+ Clients must accept values not listed here.
Format:
type: string
@@ -1240,6 +1262,8 @@ components:
Stamped on every successful task result and used to set the
right `Content-Type` when you fetch the content.
+ Clients must accept values not listed here.
+
Metadata:
type: object
description: Opaque user-supplied key/value pairs. Stored on the task; not forwarded.
@@ -1583,18 +1607,36 @@ components:
task row is visible. Omitted on runs whose tasks were
written on the request path (201 submissions and
reruns, `addTasks` batches).
+
+ Clients must accept values not listed here.
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
failure_reason:
type: string
x-extensible-enum: true
- enum: [insufficient_credits, subscription_inactive]
+ enum: [insufficient_credits, subscription_inactive, api_key_cap_reached]
description: |
Present only when `status == failed`: the account-level
- cause of the auto-fail. `insufficient_credits` (out of
- credits) or `subscription_inactive` (subscription not
- active). Omitted otherwise. Distinct from
+ cause of the auto-fail. Omitted otherwise. Distinct from
`stats.failure_reasons` (the per-task rollup).
+ - `insufficient_credits` — the account is out of credits.
+ - `subscription_inactive` — the subscription is not active.
+ - `api_key_cap_reached` — the job's API key reached one of
+ its credit caps (day, week, month or billing period).
+ Resolves on its own when that cap's window resets, or
+ when the cap is raised.
+
+ Clients must accept values not listed here.
+ failure_detail:
+ type: string
+ nullable: true
+ description: |
+ Human-readable explanation of `failure_reason`, e.g. which
+ cap was reached and when it resets. Present only when
+ `failure_reason` is. At most 500 characters (Unicode code
+ points); longer upstream text is cut. Free text for display:
+ branch on `failure_reason`, never on this string.
+ example: Subscription has no credit available.
JobSchedule:
type: object
@@ -1907,6 +1949,50 @@ components:
deliveries carry **no** `X-Signature` header — header
absence is the signal.
+ WebhookEventType:
+ type: string
+ x-extensible-enum: true
+ enum: [run.completed, run.failed, webhook.test]
+ description: |
+ `event_type` of a webhook delivery (see `WebhookEvent`).
+ - `run.completed` — the run finished naturally.
+ - `run.failed` — the run auto-failed on an account-level
+ error; `failure_reason` and `failure_detail` carry the cause.
+ - `webhook.test` — synthetic delivery from `POST /v1/webhook/test`.
+
+ Clients must accept values not listed here.
+
+ WebhookEvent:
+ type: object
+ required: [schema, event_id, event_type, occurred_at, job_id, run_id, run_sequence]
+ description: |
+ Body POSTed to the configured webhook URL (`WebhookConfig`).
+ properties:
+ schema: { type: string }
+ event_id: { type: string }
+ event_type: { $ref: '#/components/schemas/WebhookEventType' }
+ occurred_at: { type: string, format: date-time }
+ job_id: { type: string }
+ external_id: { type: string }
+ metadata: { $ref: '#/components/schemas/Metadata' }
+ run_id: { type: string }
+ run_sequence: { type: integer, minimum: 1 }
+ stats: { $ref: '#/components/schemas/RunStats' }
+ failure_reason:
+ type: string
+ x-extensible-enum: true
+ enum: [insufficient_credits, subscription_inactive, api_key_cap_reached]
+ description: |
+ `run.failed` only. Same vocabulary as `Run.failure_reason`.
+ Clients must accept values not listed here.
+ failure_detail:
+ type: string
+ nullable: true
+ description: |
+ `run.failed` only, present when `failure_reason` is. Same as
+ `Run.failure_detail`: at most 500 characters (Unicode code
+ points).
+
TestWebhookRequest:
type: object
required: [url]
@@ -2108,7 +2194,12 @@ components:
title: { type: string }
status: { type: integer }
detail: { type: string }
- code: { type: string }
+ code:
+ type: string
+ description: |
+ Stable machine-readable error code, e.g. `payment_required`
+ or `api_key_cap_reached`. Branch on this, not on `detail`.
+ Clients must accept values not listed here.
instance: { type: string }
invalid_tasks:
type: array
@@ -2121,6 +2212,7 @@ components:
reason:
type: string
x-extensible-enum: true
+ description: Clients must accept values not listed here.
enum:
- malformed_url
- unsupported_scheme
@@ -2151,6 +2243,8 @@ components:
* `completed` — `download_url` will be present.
* `failed` — `error` carries the reason.
+ Clients must accept values not listed here.
+
StartExportResponse:
type: object
required: [export_id, status, created_at, expires_at]
diff --git a/examples/06_retry_failed.py b/examples/06_retry_failed.py
index b53d46b..6582127 100644
--- a/examples/06_retry_failed.py
+++ b/examples/06_retry_failed.py
@@ -8,7 +8,8 @@
(handy after a `stop()`). It's a thin shortcut for
`job.rerun(status="failed")`.
-Requires the previous run to be terminal (`completed` / `stopped`);
+Requires the previous run to be terminal (`completed` / `stopped` /
+`failed`);
otherwise the API returns `409 run_not_terminal` — call
`job.run.stop()` first if it's still live.
diff --git a/src/zenrows/batch/_resources.py b/src/zenrows/batch/_resources.py
index 748c008..00f852d 100644
--- a/src/zenrows/batch/_resources.py
+++ b/src/zenrows/batch/_resources.py
@@ -776,7 +776,8 @@ def retry_failed(
``stop()`` left orphan ``pending`` rows.
Returns a :class:`RunHandle` for the new run. Requires the
- previous run to be terminal (``completed`` / ``stopped``); raises
+ previous run to be terminal (``completed`` / ``stopped`` /
+ ``failed``); raises
``BatchAPIError`` (409 ``run_not_terminal``) otherwise, and
(409 ``no_matching_tasks``) when nothing matched the filter.
"""
diff --git a/src/zenrows/batch/client.py b/src/zenrows/batch/client.py
index 174525c..af09969 100644
--- a/src/zenrows/batch/client.py
+++ b/src/zenrows/batch/client.py
@@ -105,9 +105,15 @@
# Run states that mean "no further work will happen on this run".
# Used as the default target for waiters; a run that's `completed`,
-# `stopped`, or `deleted` never transitions again.
+# `stopped`, `failed` (account-level stop, e.g. `api_key_cap_reached`
+# or `insufficient_credits`), or `deleted` never transitions again.
TERMINAL_RUN_STATUSES: frozenset[str] = frozenset(
- {RunStatus.COMPLETED.value, RunStatus.STOPPED.value, RunStatus.DELETED.value}
+ {
+ RunStatus.COMPLETED.value,
+ RunStatus.STOPPED.value,
+ RunStatus.FAILED.value,
+ RunStatus.DELETED.value,
+ }
)
# Export states that don't transition again — `completed` (zip ready)
@@ -570,6 +576,12 @@ def wait_for_run(
"""Block until a run reaches one of `target_statuses`, polling
with jittered exponential backoff.
+ The default targets are every terminal status, `failed`
+ included, so a run the API auto-failed is returned (read
+ `failure_reason` / `failure_detail` on it) rather than polled
+ until `timeout`. Pass `failure_statuses={"failed"}` to have the
+ waiter raise `WaiterError` on it instead.
+
`progress=True` shows a tqdm bar with totals as they advance.
`None` inherits the client-level `progress` setting (which
itself defaults to off unless `ZENROWS_BATCH_PROGRESS=true`).
@@ -974,6 +986,11 @@ def _wait_for_run_raw(
progress: bool = False,
) -> Run:
target = target_statuses or TERMINAL_RUN_STATUSES
+ if failure_statuses:
+ # `poll_until` checks `is_done` first, so a status in both
+ # sets would return instead of raising. A caller naming a
+ # failure status means "raise on it" — that wins.
+ target = frozenset(target) - frozenset(failure_statuses)
def fetch() -> Run:
if not run_id:
diff --git a/src/zenrows/batch/errors.py b/src/zenrows/batch/errors.py
index a78dc51..940a2be 100644
--- a/src/zenrows/batch/errors.py
+++ b/src/zenrows/batch/errors.py
@@ -59,7 +59,9 @@ class BatchAPIError(Exception):
"""A non-2xx response from the Batch API.
`code` is the RFC 7807 `code` member (e.g. `file_input_not_found`,
- `idempotency_key_conflict`). Stable; safe to branch on.
+ `idempotency_key_conflict`, `api_key_cap_reached`). Stable; safe to
+ branch on. `detail` is the human-readable explanation (e.g. which
+ credit cap was reached and when it resets): display it, don't parse it.
"""
def __init__(self, status_code: int, problem: ProblemDetail | None, raw: bytes):
@@ -67,6 +69,7 @@ def __init__(self, status_code: int, problem: ProblemDetail | None, raw: bytes):
self.problem = problem
self.raw = raw
self.code: str = problem.code if problem else "internal"
+ self.detail: str | None = problem.detail if problem else None
msg = (
f"{status_code} {problem.title}: {problem.detail or problem.code}"
if problem
diff --git a/src/zenrows/batch/models.py b/src/zenrows/batch/models.py
index 6c78b03..78d2690 100644
--- a/src/zenrows/batch/models.py
+++ b/src/zenrows/batch/models.py
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
-# timestamp: 2026-09-30T11:15:03+00:00
+# timestamp: 2026-09-30T14:31:48+00:00
from __future__ import annotations
from zenrows.batch._open_enum import open_enum_missing
@@ -36,6 +36,8 @@ class JobStatus(Enum):
- `deleted` — async deletion in progress; the job disappears
once it finishes.
+ Clients must accept values not listed here.
+
"""
OPEN = "open"
@@ -71,6 +73,8 @@ class RunTrigger(Enum):
- `scheduled` — automatic, fired by the configured
schedule (recurring or one-shot).
+ Clients must accept values not listed here.
+
"""
MANUAL = "manual"
@@ -93,15 +97,18 @@ class RunStatus(Enum):
Result bodies are kept. `stats.completed < stats.total`
signals "stopped early".
- `failed` — the run was auto-failed on an account-level error
- (insufficient credits / inactive subscription). No new tasks
- are picked up; `failure_reason` carries the cause. Re-runnable
- once the account is resolved. Result bodies already produced
+ (insufficient credits / inactive subscription / the API key's
+ credit cap reached). No new tasks are picked up;
+ `failure_reason` and `failure_detail` carry the cause.
+ Re-runnable once the account or cap is resolved. Result bodies already produced
are kept.
- `deleted` — caller called
`DELETE /v1/jobs/{id}/runs/{run_id}`. The run's result
bodies and data are being deleted; once complete the run
disappears.
+ Clients must accept values not listed here.
+
"""
RUNNING = "running"
@@ -116,6 +123,10 @@ class RunStatus(Enum):
class TaskStatus(Enum):
+ """
+ Clients must accept values not listed here.
+ """
+
PENDING = "pending"
PROCESSING = "processing"
SUCCESSFUL = "successful"
@@ -128,6 +139,9 @@ class TaskStatus(Enum):
class ResultType(Enum):
"""
Body format of a successful task result; matches the job's `format` 1:1.
+
+ Clients must accept values not listed here.
+
"""
HTML = "html"
@@ -151,6 +165,8 @@ class Format(Enum):
Stamped on every successful task result and used to set the
right `Content-Type` when you fetch the content.
+ Clients must accept values not listed here.
+
"""
HTML = "html"
@@ -345,6 +361,8 @@ class IngestStatus(Enum):
written on the request path (201 submissions and
reruns, `addTasks` batches).
+ Clients must accept values not listed here.
+
"""
PENDING = "pending"
@@ -357,15 +375,22 @@ class IngestStatus(Enum):
class FailureReason(Enum):
"""
Present only when `status == failed`: the account-level
- cause of the auto-fail. `insufficient_credits` (out of
- credits) or `subscription_inactive` (subscription not
- active). Omitted otherwise. Distinct from
+ cause of the auto-fail. Omitted otherwise. Distinct from
`stats.failure_reasons` (the per-task rollup).
+ - `insufficient_credits` — the account is out of credits.
+ - `subscription_inactive` — the subscription is not active.
+ - `api_key_cap_reached` — the job's API key reached one of
+ its credit caps (day, week, month or billing period).
+ Resolves on its own when that cap's window resets, or
+ when the cap is raised.
+
+ Clients must accept values not listed here.
"""
INSUFFICIENT_CREDITS = "insufficient_credits"
SUBSCRIPTION_INACTIVE = "subscription_inactive"
+ API_KEY_CAP_REACHED = "api_key_cap_reached"
# Open enum (x-extensible-enum): unknown values parse as UNKNOWN.
_missing_ = classmethod(open_enum_missing)
@@ -402,16 +427,35 @@ class Run(BaseModel):
written on the request path (201 submissions and
reruns, `addTasks` batches).
+ Clients must accept values not listed here.
+
"""
created_at: AwareDatetime
updated_at: AwareDatetime
failure_reason: FailureReason | None = None
"""
Present only when `status == failed`: the account-level
- cause of the auto-fail. `insufficient_credits` (out of
- credits) or `subscription_inactive` (subscription not
- active). Omitted otherwise. Distinct from
+ cause of the auto-fail. Omitted otherwise. Distinct from
`stats.failure_reasons` (the per-task rollup).
+ - `insufficient_credits` — the account is out of credits.
+ - `subscription_inactive` — the subscription is not active.
+ - `api_key_cap_reached` — the job's API key reached one of
+ its credit caps (day, week, month or billing period).
+ Resolves on its own when that cap's window resets, or
+ when the cap is raised.
+
+ Clients must accept values not listed here.
+
+ """
+ failure_detail: Annotated[
+ str | None, Field(examples=["Subscription has no credit available."])
+ ] = None
+ """
+ Human-readable explanation of `failure_reason`, e.g. which
+ cap was reached and when it resets. Present only when
+ `failure_reason` is. At most 500 characters (Unicode code
+ points); longer upstream text is cut. Free text for display:
+ branch on `failure_reason`, never on this string.
"""
@@ -519,6 +563,72 @@ class WebhookConfig(BaseModel):
"""
+class WebhookEventType(Enum):
+ """
+ `event_type` of a webhook delivery (see `WebhookEvent`).
+ - `run.completed` — the run finished naturally.
+ - `run.failed` — the run auto-failed on an account-level
+ error; `failure_reason` and `failure_detail` carry the cause.
+ - `webhook.test` — synthetic delivery from `POST /v1/webhook/test`.
+
+ Clients must accept values not listed here.
+
+ """
+
+ RUN_COMPLETED = "run.completed"
+ RUN_FAILED = "run.failed"
+ WEBHOOK_TEST = "webhook.test"
+
+ # Open enum (x-extensible-enum): unknown values parse as UNKNOWN.
+ _missing_ = classmethod(open_enum_missing)
+
+
+class FailureReason1(Enum):
+ """
+ `run.failed` only. Same vocabulary as `Run.failure_reason`.
+ Clients must accept values not listed here.
+
+ """
+
+ INSUFFICIENT_CREDITS = "insufficient_credits"
+ SUBSCRIPTION_INACTIVE = "subscription_inactive"
+ API_KEY_CAP_REACHED = "api_key_cap_reached"
+
+ # Open enum (x-extensible-enum): unknown values parse as UNKNOWN.
+ _missing_ = classmethod(open_enum_missing)
+
+
+class WebhookEvent(BaseModel):
+ """
+ Body POSTed to the configured webhook URL (`WebhookConfig`).
+
+ """
+
+ schema_: Annotated[str, Field(alias="schema")]
+ event_id: str
+ event_type: WebhookEventType
+ occurred_at: AwareDatetime
+ job_id: str
+ external_id: str | None = None
+ metadata: Annotated[dict[str, str] | None, Field(max_length=20)] = None
+ run_id: str
+ run_sequence: Annotated[int, Field(ge=1)]
+ stats: RunStats | None = None
+ failure_reason: FailureReason1 | None = None
+ """
+ `run.failed` only. Same vocabulary as `Run.failure_reason`.
+ Clients must accept values not listed here.
+
+ """
+ failure_detail: str | None = None
+ """
+ `run.failed` only, present when `failure_reason` is. Same as
+ `Run.failure_detail`: at most 500 characters (Unicode code
+ points).
+
+ """
+
+
class TestWebhookRequest(BaseModel):
"""
Body for `POST /v1/webhook/test`. Same shape as the submit-time
@@ -745,6 +855,10 @@ class HMACKeyFinalized(BaseModel):
class Reason(Enum):
+ """
+ Clients must accept values not listed here.
+ """
+
MALFORMED_URL = "malformed_url"
UNSUPPORTED_SCHEME = "unsupported_scheme"
MISSING_HOST = "missing_host"
@@ -762,6 +876,9 @@ class Reason(Enum):
class InvalidTask(BaseModel):
index: int
reason: Reason
+ """
+ Clients must accept values not listed here.
+ """
value: str | None = None
"""
Offending input. For URL / metadata reasons this is
@@ -786,6 +903,12 @@ class Problem(BaseModel):
status: int
detail: str | None = None
code: str
+ """
+ Stable machine-readable error code, e.g. `payment_required`
+ or `api_key_cap_reached`. Branch on this, not on `detail`.
+ Clients must accept values not listed here.
+
+ """
instance: str | None = None
invalid_tasks: list[InvalidTask] | None = None
"""
@@ -801,6 +924,8 @@ class ExportStatus(Enum):
* `completed` — `download_url` will be present.
* `failed` — `error` carries the reason.
+ Clients must accept values not listed here.
+
"""
PENDING = "pending"
diff --git a/tests/test_batch_client.py b/tests/test_batch_client.py
index 6f08f5d..f539dcb 100644
--- a/tests/test_batch_client.py
+++ b/tests/test_batch_client.py
@@ -752,6 +752,70 @@ def test_wait_for_ingest_timeout_raises(client: ZenRowsBatchClient):
client.job("J").wait_for_ingest(timeout=0.05, poll_interval=0.01)
+def _failed_run_json() -> dict:
+ run = _ingest_run_json(None, status="failed")
+ run["failure_reason"] = "api_key_cap_reached"
+ run["failure_detail"] = "API key reached its credit cap"
+ return run
+
+
+@respx.mock
+def test_wait_for_run_returns_failed_run(client: ZenRowsBatchClient):
+ """A run the API auto-fails (here: the key's credit cap) is
+ terminal — the waiter returns it with its failure fields instead
+ of polling until `timeout`."""
+ route = respx.get(f"{BASE_URL}/jobs/J/runs/R").mock(
+ side_effect=[
+ Response(200, json=_ingest_run_json(None)),
+ Response(200, json=_failed_run_json()),
+ ]
+ )
+
+ run = client.wait_for_run("J", run_id="R", timeout=0.5, poll_interval=0.01)
+
+ assert route.call_count == 2
+ assert run.status.value == "failed"
+ assert run.failure_reason.value == "api_key_cap_reached"
+ assert run.failure_detail == "API key reached its credit cap"
+
+
+@respx.mock
+def test_run_handle_wait_returns_failed_run(client: ZenRowsBatchClient):
+ """Same contract through `run.wait()` on a handle."""
+ respx.get(f"{BASE_URL}/jobs/J/runs/R").mock(
+ side_effect=[
+ Response(200, json=_ingest_run_json(None)),
+ Response(200, json=_failed_run_json()),
+ ]
+ )
+
+ out = client.run("J", "R").wait(timeout=0.5, poll_interval=0.01)
+
+ assert out.data.status.value == "failed"
+ assert out.data.failure_reason.value == "api_key_cap_reached"
+ assert out.data.failure_detail == "API key reached its credit cap"
+
+
+@respx.mock
+def test_wait_for_run_failure_statuses_raises_on_failed(client: ZenRowsBatchClient):
+ """Naming `failed` in `failure_statuses` raises even though it is
+ also a default target — the caller's opt-in wins."""
+ from zenrows.batch import WaiterError
+
+ respx.get(f"{BASE_URL}/jobs/J/runs/R").mock(
+ side_effect=[
+ Response(200, json=_ingest_run_json(None)),
+ Response(200, json=_failed_run_json()),
+ ]
+ )
+
+ with pytest.raises(WaiterError) as exc:
+ client.wait_for_run(
+ "J", run_id="R", failure_statuses={"failed"}, timeout=0.5, poll_interval=0.01
+ )
+ assert not isinstance(exc.value, WaiterTimeout)
+
+
@respx.mock
def test_job_handle_is_get_free(client: ZenRowsBatchClient):
"""`client.job(id)` mints a handle with no network call; acting on it
diff --git a/tests/test_open_enums.py b/tests/test_open_enums.py
index e876bf7..eefb02f 100644
--- a/tests/test_open_enums.py
+++ b/tests/test_open_enums.py
@@ -14,13 +14,16 @@
import pickle
from pathlib import Path
+import httpx
import pytest
+import respx
import yaml
from pydantic import TypeAdapter
-from zenrows.batch import models
+from zenrows import ZenRowsBatchClient
+from zenrows.batch import BatchAPIError, models
from zenrows.batch._open_enum import is_unknown
-from zenrows.batch.models import Job, Run
+from zenrows.batch.models import FailureReason, Job, Run
FUTURE = "some_future_value"
@@ -54,7 +57,7 @@ def _extensible_value_sets(node, out: set) -> set:
def test_module_has_enums():
# Guard against the iteration silently finding nothing.
assert len(GENERATED_ENUMS) >= 17
- assert len(EXTENSIBLE) >= len(EXTENSIBLE_SETS) >= 9
+ assert len(EXTENSIBLE) >= len(EXTENSIBLE_SETS) >= 10
assert STRICT, "request-side enums must stay strict"
assert models.FailureReason in EXTENSIBLE
assert models.JobType in STRICT
@@ -102,6 +105,10 @@ def test_known_values_still_map_to_members(enum_cls):
assert not is_unknown(parsed)
+def test_failure_reason_has_api_key_cap_reached():
+ assert FailureReason("api_key_cap_reached") is FailureReason.API_KEY_CAP_REACHED
+
+
RUN = {
"run_id": "01R000000000000000000A",
"job_id": "01J000000000000000000A",
@@ -111,6 +118,7 @@ def test_known_values_still_map_to_members(enum_cls):
"created_at": "2026-09-30T10:00:00Z",
"updated_at": "2026-09-30T10:05:00Z",
"failure_reason": "some_future_reason",
+ "failure_detail": "Something we have not invented yet.",
}
@@ -118,6 +126,7 @@ def test_run_with_future_failure_reason_parses():
run = Run.model_validate(RUN)
assert run.failure_reason is not None
assert run.failure_reason.value == "some_future_reason"
+ assert run.failure_detail == "Something we have not invented yet."
assert json.loads(run.model_dump_json())["failure_reason"] == "some_future_reason"
@@ -136,3 +145,41 @@ def test_job_with_future_values_parses():
assert job.latest_run is not None
assert job.latest_run.failure_reason is not None
assert job.latest_run.failure_reason.value == "some_future_reason"
+ assert job.latest_run.failure_detail == RUN["failure_detail"]
+
+
+def test_api_key_cap_reached_run_parses():
+ run = Run.model_validate(
+ {
+ **RUN,
+ "failure_reason": "api_key_cap_reached",
+ "failure_detail": "Daily cap reached; resets at 00:00 UTC.",
+ }
+ )
+ assert run.failure_reason is FailureReason.API_KEY_CAP_REACHED
+
+
+@respx.mock
+def test_402_api_key_cap_reached_surfaces_code_and_detail():
+ base = "http://localhost:9000/v1"
+ client = ZenRowsBatchClient(api_key="k", base_url=base)
+ detail = "This API key reached its daily credit cap; it resets at 00:00 UTC."
+ respx.post(f"{base}/jobs").mock(
+ return_value=httpx.Response(
+ 402,
+ headers={"Content-Type": "application/problem+json"},
+ json={
+ "type": "about:blank",
+ "title": "Payment Required",
+ "status": 402,
+ "code": "api_key_cap_reached",
+ "detail": detail,
+ },
+ )
+ )
+ with pytest.raises(BatchAPIError) as exc:
+ client.submit_job({"type": "regular", "tasks": [{"url": "https://example.com"}]})
+ assert exc.value.status_code == 402
+ assert exc.value.code == "api_key_cap_reached"
+ assert exc.value.detail == detail
+ assert exc.value.problem is not None and exc.value.problem.detail == detail