Repository navigation
feat(batch): verify webhook signatures, skip expired results and match the API's responses - #61
Open
emirlan-zen wants to merge 8 commits into
Open
emirlan-zen wants to merge 8 commits into
emirlan-zen wants to merge 8 commits into
Conversation
Add the expired task status, Run.trigger and the Idempotency-Key on add-tasks. Raise the task list limits to 100,000 per submit and 10,000 per add-tasks call, document 429 quota_exceeded, and drop the per-task spend that result rows never carry. Regenerate the models.
…wnloads - verify_webhook_signature checks the X-Signature header: it decodes the base64 secret, picks it by kid, compares in constant time and enforces a timestamp tolerance (300 s by default). - Bulk downloads skip failed and expired rows instead of stopping, log the counts and return the rows through skipped=. - add_tasks takes an idempotency_key, so transient failures are retried. - BatchErrorCode names every Batch error code. - A 429 quota_exceeded is an account limit and is no longer retried. - Export docstrings give the 100 GiB cap.
…otent add-tasks Update the README and examples: the 2 hour result links, the 14 day retention, the 100 GiB export cap, webhook events and signature checks, the partial rerun window and what is billed. Regenerate the reference.
…ditive - A run waiter raises RunPaused (a WaiterError carrying the run) when the run is paused and not yet in a target status, instead of polling until its timeout. wait_while_paused=True keeps polling. - Bulk downloads report each skipped row through an on_skip callback. - The webhook helper takes tolerance_seconds and its constants are WEBHOOK_SIGNATURE_HEADER, WEBHOOK_EVENT_ID_HEADER and DEFAULT_WEBHOOK_TOLERANCE_SECONDS, matching the other SDKs. - TaskResult.spend and TaskSpend stay, marked deprecated: result rows never carry a per-task spend.
…rerun ingest - estimate_cost counts js_render / premium_proxy as on only for True or the string "true" in any case, and mode as auto only for "auto". The API rejects 1, "yes" and "on", so the estimator no longer prices a job it would refuse. - rerun, retry_failed and submit_scheduled take wait_for_ingest=False. When true, they poll until the new run's ingest_status is done before returning.
wait_for_ingest on rerun and retry_failed now polls the run the call returned, by id, until its ingest_status is no longer pending. It used to poll the job's latest run and stop at any terminal status, so it followed the wrong run after an idempotent replay and returned a run stopped mid-ingest before its rows and totals settled. submit_scheduled documents that creating a scheduled job starts no run, so wait_for_ingest has nothing to wait for there.
- JobRef.wait_for_ingest, and submit_job(wait_for_ingest=True), poll the run a submit returned by id when the ref has one, so a replayed submit never follows a newer run. A ref without a submit response still polls the job's latest run. - The wait ends only when ingest_status is no longer pending. A run stopped mid-ingest is terminal at once but keeps pending until its rows and totals are settled, so its status no longer ends the wait. - submit_scheduled loses the wait_for_ingest option added earlier on this branch: a scheduled submit creates no run, so it could never act.
emirlan-zen
marked this pull request as ready for review
October 16, 2026 14:19
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
This PR brings the Batch client in line with what the Batch API returns. It also adds the helpers that the Node and Go SDKs get under the same names. It is additive: no exported name, field, model or signature is removed or narrowed.
griffe check zenrows -s src --against origin/mainreports nothing.New public API (all exported from
zenrows.batch):verify_webhook_signature(raw_body, signature_header, secrets, *, tolerance_seconds=300, now=None) -> boolchecks theX-Signatureheader (t=<unix>,v1=<hex>,kid=<kid>) on a signed delivery.secretsmaps eachkidto the base64 secret fromrotate_hmac_key(). The helper decodes it and picks it bykid, so it keeps working during a key rotation.<t>.<raw body>and compares in constant time.tolerance_secondsoff.0turns the check off.Falseon bad input and never raises.WEBHOOK_SIGNATURE_HEADER,WEBHOOK_EVENT_ID_HEADERandDEFAULT_WEBHOOK_TOLERANCE_SECONDS, and re-exportsWebhookEvent/WebhookEventTypefor the payload.BatchErrorCode: astrenum naming every error code the Batch API sends (43 codes). Members compare equal toBatchAPIError.code.JobRef.add_tasks(body, *, idempotency_key=None).on_skip: Callable[[TaskResult], None] | None = Noneondownload_to_diranddownload_to_memory(on a run and onjob.run).RunPaused(WaiterError)with.run, andwait_while_paused: bool = Falseonwait_for_run,run.wait()andjob.run.wait().wait_for_ingest: bool = FalseonJobRef.rerunandJobRef.retry_failed.GET /jobs/{id}/runs/{run_id}, up to 300 s) until itsingest_statusis no longerpending, and returns that fresh snapshot.ingest_status: pendinguntil ingestion winds down, and it comes backstoppedwith only the rows written so far.submit_scheduledhas no wait option: creating a scheduled job starts no run (the API answers201with nolatest_run), so there is nothing to ingest until the schedule fires.TaskStatus.EXPIRED, andRun.triggertyped asRunTrigger(manual/scheduled). The API already sends both.Behaviour changes:
expiredand has noresult_url. It still matchesstatus="successful".download_to_diranddownload_to_memoryused to raiseValueErroron the first such row, part-way through.on_skip, and log one WARNING onzenrows.batch.downloadwith the counts per status. Skipped rows do not count towardmax_files/max_count.successfulrow without aresult_urlstill raises, so a result never goes missing silently.ValueErrorfor an expired task.RunPausedon the first poll that seespause_state: pausedwhile the run is not yet in a target status.RunPausedsubclassesWaiterError, so an existingexcept WaiterErrorstill catches it.429 quota_exceededis no longer retried. It is an account limit (active jobs, scheduled jobs, or an open job's task cap) that clears only when a job finishes, is closed or is deleted. Other 429s are retried as before.estimate_costreads flags the way the API does.js_render/premium_proxycount as on only forTrueor the string"true"in any case.modecounts as auto only for exactly"auto".1,"1","yes"and"on"with a 400. The estimator used to price those as on, for jobs the API would refuse.JobRef.wait_for_ingest()andsubmit_job(..., wait_for_ingest=True)now poll the run the submit returned, by id, when the ref has one. A replayed submit therefore never follows a newer run.ingest_statusis no longerpending. A run stopped mid-ingest used to end it at once, before its rows andstats.totalwere settled.TaskResult.spendandTaskSpendare kept but documented as deprecated. Result rows never carry a per-task spend, so the field is alwaysNone. Readrun.stats.spendinstead.Spec refresh (
docs/openapi.yaml, models regenerated):Idempotency-Key.max_length.429 quota_exceededresponse.result_urlis valid for 2 hours.Docs, docstrings and examples:
X-ZenRows-Event-Id. There is a receiver example.extract_fieldsworks in Batch.Atdate is computed in the future. Example 07 explains the base64 secret. Example 06 handlespartial_rerun_window_expired. Example 02 reports skipped rows.docs/batch-client-reference.mdis regenerated and gains a "Webhook signatures" section.How it was tested
make check(ty, ruff, ruff format) andmake test: 295 passed, 1 deselected (e2e).griffe check zenrows -s src --against origin/main: no breaking changes.tests/test_webhooks.pypinsverify_webhook_signatureto a fixed vector produced by the API's signing routine (secret bytes0x00..0x1f,t=1790000000). It covers using the base64 text as the key, tampering, rotation bykid, tolerance bounds, malformed headers and bad secrets.tests/test_batch_client.pycovers:quota_exceededwhile a plain 429 is still retriedBatchErrorCodeagainst the full code listRunPausedon a paused run, for a specific run and forjob.runwait_while_paused=Truepolling through to completionwait_for_ingest=Trueon rerun and retry_failed polling the returned run until ingest is donesubmit_scheduled's real201with no run returning after one requesttests/test_estimate.pycovers the accepted and rejected flag spellings and the exactmode: "auto".make generateandmake docswere re-run, and their output is committed.