Skip to content

v2.6.0: structured errors — translate non-2xx at the engine - #56

Open
karaposu wants to merge 7 commits into
brightdata:devfrom
karaposu:dev
Open

v2.6.0: structured errors — translate non-2xx at the engine#56
karaposu wants to merge 7 commits into
brightdata:devfrom
karaposu:dev

Conversation

@karaposu

Copy link
Copy Markdown
Contributor

Makes SDK failures machine-readable. Today AsyncEngine converts only HTTP 401 and 403 into exceptions and hands every other non-2xx back as an ordinary response, so each subsystem improvises its own answer and six of the seven exception types carry nothing but a prose message.

Two failures this fixes

A rate-limited request surfaced as a JSON parse error. /datasets/filter answers 429 with the bare string too_many_parallel_jobs under a non-JSON content type, and datasets/base.py called response.json() before checking the status. The user saw an aiohttp content-type error, never "you are rate limited".

A token expiring mid-poll was reported as "Job failed with status: error". DatasetAPIClient.get_status collapsed every non-200 into the string "error", discarding the status code unread — so an authentication problem was blamed on the user's scrape.

What changed

  • Classification happens once, at the single point every request already passes through: 429 → new RateLimitError (with retry_after parsed from the header), other 4xx/5xx → APIError. Bodies are read as text first, so a non-JSON error degrades to a readable message instead of a parse exception.
  • Exceptions carry data: status_code, url, method, retry_after, retryable, and raw (bounded to 4 KB).
  • Results carry it too. ScrapeResult/CrawlResult gained cause, populated wherever an exception is converted into a result — without this the structure was discarded at poll_until_ready, so it never reached scraper callers at all:
    result = await client.scrape.x.posts(url)
    if not result.success and isinstance(result.cause, RateLimitError):
        await asyncio.sleep(result.cause.retry_after or 60)
  • Retry is now opt-in. retry_with_backoff no longer retries by exception type. An error with no status code is raised locally — sometimes after the server accepted the work — so repeating it could create a duplicate billed job; those are never retried. Explicit 5xx still is. 429 never is, since those responses consume quota and retrying extends the lockout.

Deliberately preserved

  • HTTP 202 passes through untouched. It means success for scraper_studio.trigger_immediate, and elsewhere drives the DataNotReadyErrorpoll_until_ready recovery — the only working recovery path in the SDK. Covered by regression tests.
  • crawler.crawl() still returns CrawlResult on HTTP errors, and the unlocker's async get_status still returns a status string for its poll loop — both would otherwise have started raising.
  • except DatasetError still catches dataset failures: the datasets layer re-types engine errors, letting RateLimitError through as the more specific type.
  • The TimeoutError-before-OSError ordering and the SSL guidance messages are unchanged.

Notes for review

  • Error message text changes — messages are shorter and uniform, with detail on attributes. Code matching on message text should use status_code instead. A bounded 200-char body excerpt is appended so a bare print(exc) stays useful.
  • DatasetError now subclasses BrightDataError (still catchable as before). RateLimitError and DataNotReadyError are exported from brightdata top level.
  • Known limit: this is status-based, so it cannot see HTTP 200 responses carrying an error in the body (SERP's inner envelope, Web Unlocker error content). Those remain string-shaped.

Tests: 321 → 365. Version → 2.6.0.

BrightDataError gains status_code/url/method/retry_after/retryable/raw
(all keyword-only, safe defaults). Adds RateLimitError, reparents
DatasetError, exports both levels. No behavior change.
datasets filter/status read the body as text first, so a non-JSON error
(429 returns a bare string) surfaces as RateLimitError instead of a
content-type parse error. api_client.get_status raises instead of
collapsing every non-200 into the status string "error", which made an
expired token look like a failed scrape.
Retryability no longer follows from the exception class. An APIError with
no status code is raised locally, sometimes after the server already
accepted the work, so repeating it can create a duplicate billed job -
those are now never retried. Explicit 5xx still is. 429 never is, since
those responses consume quota and retrying extends the lockout.
Every request already passes through ResponseContextManager, so classify
there instead of leaving each subsystem to improvise: 429 -> RateLimitError
with retry_after, other 4xx/5xx -> APIError, all carrying status_code, url,
method and a bounded body. 202 deliberately passes through - it means
success for scraper_studio.trigger_immediate and drives DataNotReadyError
recovery elsewhere.

Repairs the two call sites whose contract would otherwise change: crawler
crawl() keeps returning CrawlResult on HTTP errors, and the unlocker's
async get_status keeps returning a status string for its poll loop.
Results are the shape most callers actually receive, and their error field
is a string - so every structured attribute was being discarded at the
boundary where an exception became a ScrapeResult or CrawlResult. Adds an
optional cause field, populated wherever that conversion happens, so
callers can branch on the failure instead of parsing its message.

Also resolves retryable from status_code when not given explicitly, so the
attribute and is_retryable() can no longer disagree.
The engine raises before the per-endpoint checks run, which silently
changed two contracts: dataset filter failures surfaced as APIError rather
than DatasetError, and error messages lost the endpoint label. The datasets
layer now re-types engine errors while letting RateLimitError through, and
engine messages carry a bounded body excerpt so a bare print(exc) stays
useful.
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