Skip to Content

every error from the crawlbrulee api follows a consistent format. parse it once, handle everywhere.

our status vs. the page’s status

there are two status codes in play, and they answer different questions:

  • our http status says whether we did our job. a non-2xx from us means the request was wrong, we could not get a page from the site at all, or something broke on our side.
  • page_status_code says what the site answered. it’s a field in the scrape result: the http status of the final page, after redirects.

when we reach the site and it sends back a page, you get a 200 with that page, whatever status the site gave it. a 404, 410, 401, 451 or 503 page comes back with its content, and page_status_code tells you which one it was. a 404 page is data, not an error:

{ "url": "https://example.com/old-page", "requested_url": "https://example.com/old-page", "page_status_code": 404, "content_type": "text/html", "markdown": "# Page not found\n\nThe page you are looking for does not exist.", "metadata": { "title": "Page not found" }, "response_meta": { "usage": { "total_credit_cost": 1, "engine_credit_cost": 1, "proxy_multiplier": 1, "screenshot_slicing_credit_cost": 0, "zero_data_retention_credit_cost": 0, "engine": "http", "proxy": "basic" } } }

check page_status_code before you use the content. a 200 from us with page_status_code: 404 holds the site’s “not found” page, not the page you asked for. don’t treat every page_status_code of 400 or more as an error by default: some sites send useful content with a 401 or a 451. decide per status what your code should do.

some answers still come back as errors, because we could not get the site’s real page, or could not use it:

what happenedour answer
the site’s anti-bot protection blocked us403 antibot_blocked
the site redirected in a loop, or through too many hops422 too_many_redirects
the site rate-limited us (429)429 too_many_requests, with limited_by: "target_site"
we could not reach the site (for example, it timed out)502 target_unreachable
the page was too large or too complex, or its content type isn’t supported422 page_too_large / 415 unsupported_content

what it costs

an error from us never costs credits. a page we deliver is billed by its page_status_code:

page_status_codebilled?
2xxyes
4xx (for example 404, 410, 401, 400)yes
403, 407, 408, 429, 451no
5xxno

a page we don’t bill still comes back with its content; every *_credit_cost field in its response_meta.usage is 0. see what counts as a failure for the full rules.

error format

all error responses share this shape:

interface ApiError { name: string message: string details?: UsageAllocationDetails | TooManyRequestsDetails }

the most common error names are listed below. the message is a human-readable explanation. the details object is present only for usage_allocation_error and too_many_requests errors.

http status codes

statusmeaningwhen
200successrequest completed — including a page the site answered with a 4xx or 5xx status (see page_status_code)
202acceptedasync job queued
400bad requestvalidation failed (invalid url, bad params)
401unauthorizedmissing or invalid token
403forbiddenthe target site’s anti-bot protection blocked the request (scrape or map), or you sent zero_data_retention: true and it is not enabled for your organization
404not foundasync job doesn’t exist, or a resource you don’t have access to
408request timeoutthe request took too long to complete
415unsupported media typetarget content type isn’t supported for extraction
422unprocessable entitya screenshot-only request targeted a content type that can’t be screenshotted
429too many requestsrate limit or usage limit exceeded
499client closed requestclient disconnected before the request completed
500internal server errorsomething went wrong on our end
502bad gatewaywe could not reach the target site — target_unreachable
503service unavailablea transient failure on our end — retry the same request

error names

namehttp statusdescription
invalid_url400url failed validation
url_too_long400url exceeds 8,192 bytes
unsupported_url_schema400only http and https are supported
url_credentials_not_supported400urls with embedded credentials are rejected
unsupported_content415target content type not supported for extraction
unsupported_screenshot_output422the request asked only for a screenshot and the content type can’t be screenshotted (json, plain text, markdown, xml) — never billed. see screenshots
validation_error400request body failed validation — for example an invalid css selector in extract.elements. the message says which field
blocked_url400url is not allowed to be scraped
invalid_credentials401token missing, expired, or revoked
access_denied404no permission for this resource — returned as 404 to avoid revealing whether it exists
not_found404resource (e.g., async job) not found
too_many_requests429rate limit exceeded
usage_allocation_error429credit or concurrency limit hit
request_timeout408the request took too long to complete
client_closed_request499client disconnected before completion
scrape_error4xxwe could not process the request as sent (for example, a body that isn’t valid json), or you asked for an async result before the job finished. it never carries the target site’s status — that is page_status_code on a 200
antibot_blocked403the target site’s anti-bot protection blocked the request (scrape or map)
zero_data_retention_not_enabled403you sent zero_data_retention: true, but zero data retention is not enabled for your organization. never billed, no details
too_many_redirects422the target site redirected the request in a loop, or through more hops than we follow (scrape or map). not a bad request; retrying rarely helps
page_too_large422the page’s html was too large or too complex to convert (scrape). not a bad request, and terminal — the same url fails the same way, so don’t retry it; scrape a smaller page instead
target_unreachable502we could not reach the target site at all — for example, it did not answer in time or its tls certificate is invalid (scrape or map). the message is always "Could not reach the target site.", with no details. never billed. the site may be back later, so a later retry can help
job_failed400async job failed
internal_server_error500something went wrong on our side. the site’s own error status never causes it — a 404 or 503 page comes back as a 200
service_unavailable503a dependency we need was briefly unavailable. your request was fine — retry it unchanged

a 503 is not an auth problem. authentication failures that are genuinely about your token — unknown, expired, or revoked — always come back as 401 invalid_credentials. 503 means we couldn’t complete the check right now, so retry the same request with the same key rather than rotating it.

usage allocation errors

these errors mean you’ve hit a billing or concurrency limit. the response includes a details object with the specific reason and current usage numbers.

{ "name": "usage_allocation_error", "message": "Credit limit exceeded", "details": { "error_name": "usage_allocation_error", "reason": "credit_limit", "details": { "current_usage": 750, "current_reserved": 0, "max_credits": 750 } } }

reasons

reasonmeaning
credit_limityour organization has used all available credits for the current billing period. upgrade your plan or wait for the next cycle.
concurrency_limittoo many scrape jobs are running at the same time. wait for in-flight jobs to finish before submitting new ones.
duplicate_reservationthe same job is already being processed. retrying the request is usually safe.
internal_errorthe allocation check failed unexpectedly. returned as http 500 rather than 429 — treat it like any other server-side failure.

an error from us costs 0. a delivered page is charged at the delivered engine and proxy, unless its page_status_code is one we don’t bill (403, 407, 408, 429, 451, or any 5xx) — then it costs 0 too. a fully cached result has a 0-credit base, and a newly produced screenshot-slice variant costs +1. see what counts as a failure.

rate limit errors

when you exceed a rate limit, the response tells you exactly how long to wait and which limit you hit.

{ "name": "too_many_requests", "message": "Rate limit exceeded", "details": { "error_name": "too_many_requests", "retry_after_ms": 12000, "limited_by": "org" } }
  • retry_after_ms — milliseconds until the limit resets. use this instead of guessing backoff intervals.
  • limited_by — which limit you hit: org (your organization’s rate limit), ip (ip-based limit on unauthenticated requests), or target_site (the target site itself rate-limited the scrape).

the response also carries a standard Retry-After header (in seconds) alongside retry_after_ms — stock http clients honor it automatically.

see rate limits for the full list of active limits and retry strategies.

handling errors in code

const API_KEY = process.env.CRAWLBRULEE_API_KEY async function scrape(url) { const res = await fetch('https://api.crawlbrulee.com/api/scrape', { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url, extract: { markdown: true } }), }) if (!res.ok) { const error = await res.json() switch (error.name) { case 'too_many_requests': { const retryMs = error.details?.retry_after_ms ?? 10_000 console.log(`Rate limited. Retrying in ${retryMs}ms...`) await new Promise((r) => setTimeout(r, retryMs)) return scrape(url) // retry once } case 'service_unavailable': { // Transient on our side — the key is fine, so retry it unchanged console.log('Service temporarily unavailable. Retrying in 2s...') await new Promise((r) => setTimeout(r, 2_000)) return scrape(url) // retry once } case 'usage_allocation_error': console.error(`Usage limit hit: ${error.details?.reason}`) throw new Error('Usage limit reached — upgrade your plan') case 'invalid_credentials': throw new Error('Bad API key — check your token') case 'target_unreachable': // We could not reach the site at all. Not billed; try again later. throw new Error('Target site unreachable — try again later') default: throw new Error(`API error [${error.name}]: ${error.message}`) } } const page = await res.json() // A 200 from us can hold the site's error page. Check the page's own status. if (page.page_status_code >= 400) { console.warn(`The site answered ${page.page_status_code} for ${page.url}`) } return page }