Skip to content

fix(batch): match the Batch client to what the API returns and accepts - #59

Merged
emirlan-zen merged 4 commits into
mainfrom
fix/batch-consistency
Oct 20, 2026
Merged

emirlan-zen merged 4 commits into
mainfrom
fix/batch-consistency

Conversation

@zenrows-dev-pool

Copy link
Copy Markdown
Contributor

Summary

The Batch client had drifted from the Batch API in several places, and the README's Batch example did not compile. This PR brings the client, its types and the README back in line, and adds the helpers the Python and Go SDKs are getting at the same time: webhook signature verification, named error codes and an add-tasks idempotency key.

Nothing breaks: every exported name and signature is kept, removals are deprecations, and getTaskContent() still returns text. The behaviour changes are listed at the end.

Changes

Results

  • TaskStatus gains expired. After the retention window (14 days by default) a successful task comes back expired with no result_url, and a status: "successful" filter still matches it.
  • Before this PR, downloadToDir() and downloadToMemory() failed partway through on the first such row, after some files were already written. They now skip expired and failed rows that have no body, and report each one through a new onSkip option. Any other row without a result_url still fails the download, so a missing body is never dropped silently.
  • New getTaskContentBytes() returns the content as a Buffer. getTaskContent() decodes every body as text, which corrupts a pdf or other binary result; its doc comment and the README now say to use getTaskContentBytes() for those.

Webhooks

  • New verifyWebhookSignature(rawBody, header, secrets, options). It:
    • parses X-Signature: t=...,v1=...,kid=... and picks the secret by kid, so both keys work during a rotation;
    • base64-decodes the secret before computing the HMAC-SHA256 over t + "." + body;
    • compares in constant time;
    • rejects a timestamp more than 300 s away (toleranceSeconds: 0 turns that check off);
    • fails closed on a secret that isn't a 32-byte base64 key, so a mistyped secret can never verify a delivery signed with an empty key.
  • New WebhookEvent type for the delivery body, and WEBHOOK_SIGNATURE_HEADER / WEBHOOK_EVENT_ID_HEADER.
  • putJobWebhook() used to send signature: false whenever the caller left it out, so changing only the URL turned signing off. With signature omitted, it now reads the job's current setting and sends that back (false when the job has no webhook yet). An explicit value is sent as given, with no extra request.

Errors and retries

  • New BatchErrorCode const object (and BatchErrorCodeValue type) with every error code the API sends.
  • ZenRowsBatchError gains detail and retryAfter, matching the Crawl error.
  • A 429 quota_exceeded is 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 or is deleted, so retries only added delay. Other 429s are retried as before.
  • job.addTasks(tasks, { idempotencyKey }) sends Idempotency-Key. With a key, a transient failure is retried; without one, behaviour is unchanged.
  • New BatchClientConfig.timeout: seconds per API request, default 30, the same default as the Python SDK and the Crawl client. This is a fix: Batch requests had no time limit at all before, so a stalled connection could hang a call forever. The timeout covers reading the body too, so getTaskContent() and getTaskContentBytes() can't hang on a body that stalls after the headers. Downloads from result_url and waiters are not limited by it.

Waiting on a paused run

  • A paused run (pause_state: "paused") makes no progress until it is resumed, but waitForRun(), job.run.wait() and run.wait() kept polling it until the timeout. They now stop at once and throw a new RunPausedError whose run is the paused run. A throw matches how the waiters report a timeout (WaiterTimeoutError). Pass waitWhilePaused: true to keep polling, for example when another process will resume the run. A paused run that is already in a target status still resolves.

Waiting for ingestion

  • Every rerun, and every submit of 10,000 tasks or more, returns before the run's task rows are written. rerun() and retryFailed() now accept waitForIngest, like the submit methods already did.
  • The wait now ends only when ingest_status leaves pending. Before, it also ended as soon as the run was terminal, but a run stopped mid-ingest is terminal while the API is still settling its rows and totals.
  • rerun() and the submit methods wait on the run they returned, by id, instead of the job's latest run. A replayed request (same idempotencyKey) returns its original run, which may no longer be the latest one.
  • submitScheduled() ignores waitForIngest. A scheduled submit creates no run: it stores the task template, and the first run starts when the schedule fires. The README says so.

Cost estimate

  • A flag counts as on only for true or "true" (any case), and mode only for "auto". The API rejects anything else with a 400. Before, "1", "yes" and non-zero numbers priced a job the API would refuse.

Types

  • method and body on task inputs (POST tasks).
  • created_at and updated_at on TaskResult.
  • trigger on Run, with a new RunTrigger type.
  • TaskResult.spend / TaskSpend and GetResultsOptions.limit are deprecated. The API never returns the first and ignores the second, which is no longer sent.

README

  • The Batch section is rewritten in the JobRef style: submitRegular / submitOpen, job.run.wait(), job.run.results(), downloadToDir, retryFailed, addTasks.
  • New subsections cover retention, errors and retries, and webhooks (with a node:http receiver).
  • The sentence saying schedules, webhooks, HMAC keys, CSV uploads and exports weren't wrapped is gone; all of them are wrapped.
  • The sentence saying extract_fields isn't supported in Batch is gone; the API accepts it.
  • A new test, tests/readme-batch.test.ts, typechecks every code block in the Batch section against src/. An example that calls a method the client doesn't have now fails CI.

Behaviour changes

None of these changes a type or a signature:

  • Batch requests time out after 30 s unless timeout is set.
  • Run waiters throw RunPausedError on a paused run instead of polling until the timeout.
  • putJobWebhook() without signature keeps the current setting instead of turning signing off.
  • Bulk downloads skip expired and failed rows instead of failing.
  • A 429 quota_exceeded is not retried.

Testing

  • pnpm check (Biome): clean.
  • tsc --noEmit with tsconfig.json and tsconfig.test.json: clean.
  • vitest run: 477 passed, 6 skipped (the Crawl e2e test, which needs a key), no type errors.
  • pnpm build: CJS, ESM and type declarations build. The new exports are present in dist.
  • The signature fixture in tests/batch-webhook.test.ts came from a copy of the API's own signing code, not from the helper under test.
  • The README test was checked against a deliberately broken snippet, and it failed as expected.
  • The timeout test runs against a local socket that sends headers and then stalls.
  • Nothing calls the live API.

Replaces #57.

- Add the expired task status. Bulk downloads skip expired and failed
  rows that have no body and report each through onSkip, instead of
  failing partway through.
- Add getTaskContentBytes, which returns a Buffer so binary results such
  as PDFs arrive intact. getTaskContent still returns text.
- Run waiters no longer poll a paused run until the timeout: they throw
  RunPausedError with the run, unless waitWhilePaused is set.
- Add verifyWebhookSignature, which decodes the base64 secret, picks the
  key by kid, compares in constant time and checks the timestamp
  (300 s by default), plus the WebhookEvent type and header constants.
- Add BatchErrorCode with every error code the API sends, and detail and
  retryAfter on ZenRowsBatchError.
- addTasks accepts an idempotencyKey, so a transient failure is retried.
- rerun, retryFailed and submitScheduled accept waitForIngest.
- putJobWebhook no longer sends signature: false when it is left out; it
  keeps the job's current setting.
- A 429 quota_exceeded is an account limit and is no longer retried.
- Requests time out after 30 seconds by default (timeout option); they
  had no limit before.
- The cost estimate counts a flag as on only for true or "true", and
  mode only for "auto", the values the API accepts.
- Types: method and body on tasks, created_at and updated_at on results,
  trigger on runs. TaskResult.spend and the results limit option are
  deprecated: the API never returns the first and ignores the second.
The old example called methods the client does not have (addTasks,
closeJob, rerun on client.batch) and read job.job_id from a JobRef, so
it did not compile. It also said schedules, webhooks, HMAC keys, CSV
uploads and exports were not wrapped.

The section now uses the JobRef API, documents retention, errors and
retries, paused runs, and webhook verification, and a test typechecks
every code block in it against the source.
…ontent reads

- An ingestion wait is done only when ingest_status leaves pending. A run
  stopped mid-ingest is terminal while the API is still settling its rows
  and totals, so the run status alone no longer ends the wait.
- rerun and submit with waitForIngest poll the run they returned, by id,
  instead of the job's latest run. A replayed request returns its
  original run, which may no longer be the latest.
- getTaskContent and getTaskContentBytes read the body inside the request
  timeout, so a body that stalls after the headers no longer hangs.
- submitScheduled no longer passes waitForIngest: a scheduled submit
  creates no run, so there is nothing to wait for. The README says so.
@emirlan-zen
emirlan-zen merged commit 5e13420 into main Oct 20, 2026
4 checks passed
@emirlan-zen
emirlan-zen deleted the fix/batch-consistency branch October 20, 2026 05:13
@github-actions github-actions Bot mentioned this pull request Oct 20, 2026
@emirlan-zen

Copy link
Copy Markdown
Contributor

Proof on the merged code (5e13420), run against the dev Batch API. Test jobs deleted afterwards.

same job: true
error: {"status":409,"code":"idempotency_key_conflict","isIdempotencyKeyConflict":true,"isZenRowsBatchError":true}
run: {"status":"completed","total":3,"successful":2,"failed":1}
files written: 2
onSkip got: [{"taskId":"01M5BY8R8H7Z6JGM4XHMJW20V2","externalId":"boom","url":"https://httpbin.io/status/500","status":"failed"}]
getTaskContentBytes: {"isBuffer":true,"length":577}
rerun: {"total":3,"successful":2,"failed":1,"credits":0}
RunPausedError after seconds: {"seconds":0.196,"isRunPausedError":true}
after resume: {"status":"completed","successful":3}
genuine delivery: true
tampered body: false
1h old timestamp: false
unknown kid: false

The webhook lines verify a header made by the Batch API's own signing code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant