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