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_codesays 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 happened | our answer |
|---|---|
| the site’s anti-bot protection blocked us | 403 antibot_blocked |
| the site redirected in a loop, or through too many hops | 422 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 supported | 422 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_code | billed? |
|---|---|
2xx | yes |
4xx (for example 404, 410, 401, 400) | yes |
403, 407, 408, 429, 451 | no |
5xx | no |
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
| status | meaning | when |
|---|---|---|
| 200 | success | request completed — including a page the site answered with a 4xx or 5xx status (see page_status_code) |
| 202 | accepted | async job queued |
| 400 | bad request | validation failed (invalid url, bad params) |
| 401 | unauthorized | missing or invalid token |
| 403 | forbidden | the 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 |
| 404 | not found | async job doesn’t exist, or a resource you don’t have access to |
| 408 | request timeout | the request took too long to complete |
| 415 | unsupported media type | target content type isn’t supported for extraction |
| 422 | unprocessable entity | a screenshot-only request targeted a content type that can’t be screenshotted |
| 429 | too many requests | rate limit or usage limit exceeded |
| 499 | client closed request | client disconnected before the request completed |
| 500 | internal server error | something went wrong on our end |
| 502 | bad gateway | we could not reach the target site — target_unreachable |
| 503 | service unavailable | a transient failure on our end — retry the same request |
error names
| name | http status | description |
|---|---|---|
invalid_url | 400 | url failed validation |
url_too_long | 400 | url exceeds 8,192 bytes |
unsupported_url_schema | 400 | only http and https are supported |
url_credentials_not_supported | 400 | urls with embedded credentials are rejected |
unsupported_content | 415 | target content type not supported for extraction |
unsupported_screenshot_output | 422 | the request asked only for a screenshot and the content type can’t be screenshotted (json, plain text, markdown, xml) — never billed. see screenshots |
validation_error | 400 | request body failed validation — for example an invalid css selector in extract.elements. the message says which field |
blocked_url | 400 | url is not allowed to be scraped |
invalid_credentials | 401 | token missing, expired, or revoked |
access_denied | 404 | no permission for this resource — returned as 404 to avoid revealing whether it exists |
not_found | 404 | resource (e.g., async job) not found |
too_many_requests | 429 | rate limit exceeded |
usage_allocation_error | 429 | credit or concurrency limit hit |
request_timeout | 408 | the request took too long to complete |
client_closed_request | 499 | client disconnected before completion |
scrape_error | 4xx | we 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_blocked | 403 | the target site’s anti-bot protection blocked the request (scrape or map) |
zero_data_retention_not_enabled | 403 | you sent zero_data_retention: true, but zero data retention is not enabled for your organization. never billed, no details |
too_many_redirects | 422 | the 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_large | 422 | the 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_unreachable | 502 | we 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_failed | 400 | async job failed |
internal_server_error | 500 | something 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_unavailable | 503 | a 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
| reason | meaning |
|---|---|
credit_limit | your organization has used all available credits for the current billing period. upgrade your plan or wait for the next cycle. |
concurrency_limit | too many scrape jobs are running at the same time. wait for in-flight jobs to finish before submitting new ones. |
duplicate_reservation | the same job is already being processed. retrying the request is usually safe. |
internal_error | the 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), ortarget_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
js/ts
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
}