every error from the crawlbrulee api follows a consistent format. parse it once, handle everywhere.
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 |
| 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) |
| 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 |
| 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 |
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 / 5xx | the scrape itself failed. the response mirrors the target’s http status — a page that returns 404 surfaces as scrape_error with status 404, a 500 as 500 |
antibot_blocked | 403 | the target site’s anti-bot protection blocked the request (scrape or map) |
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 to process (scrape). not a bad request, and terminal — the same url fails the same way, so don’t retry it; scrape a smaller page instead |
job_failed | 400 | async job failed |
internal_server_error | 500 | server-side failure |
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 already has an active credit reservation. this is an internal-consistency guard — 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. |
credits are reserved when a request starts and finalized on completion. failed requests release the reservation. successful requests are charged at the delivered engine and proxy; a fully cached result has a 0-credit base, and a newly produced screenshot-slice variant costs +1.
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')
default:
throw new Error(`API error [${error.name}]: ${error.message}`)
}
}
return res.json()
}