Expand AI logo
DocsDocs
Glow Active
API Reference
Login

Documentation

Get started

IntroductionWhy ExpandQuickstartAuthenticationConnect your agent (MCP)

Products

OverviewFetching a URLBatched Fetch
API reference

SDKs & tools

TypeScript SDK
MCP tools & resourcesDocs for agents

Reference

API referenceErrors & retriesPricing & UsageLimitsConcurrency tiersFAQ
Browse docs

Get started

IntroductionWhy ExpandQuickstartAuthenticationConnect your agent (MCP)

Products

OverviewFetching a URLBatched Fetch
API reference

SDKs & tools

TypeScript SDK
MCP tools & resourcesDocs for agents

Reference

API referenceErrors & retriesPricing & UsageLimitsConcurrency tiersFAQ

Errors & retries

How to tell an Expand failure from a dead URL, which errors to retry, and what you are charged for.

Check the HTTP status and the error body together:

A 200 can still report an origin failure. A non-2xx can be an API or capture error, or an origin refusing to serve a file. Browser pages and files report origin errors differently below.

Nothing retries a dead URL by accident. Every response, success or error, carries an x-expand-trace-id header. Include it when you contact support.

This page is the guide. Every status code, error type, and example is in API reference: Errors.

The two error shapes

When a request fails with a non-2xx response, the body carries a _tag that names the error and, for errors that might be temporary, a retryable boolean:

{ "_tag": "ServiceUnavailable", "message": "โ€ฆ", "retryable": true }

Errors that are unambiguous from their status alone (400, 401, 403, 404, 409, 410, 413) omit retryable. Treat a missing field on those as false.

When a browser page's origin fails, Fetch returns 200 with a FetchOriginError instead of, or next to, the page. A dead page URL, a broken certificate, or a site that answers 404 is a 200 with an origin error attached. A file's origin can instead return 404, 410, or 502 with FetchOriginHttpError. The fields, the headers on POST /v1/fetch, and examples are in When the origin fails.

Crawl uses its own shape: { "error": { "code", "message", "retryable" } }. See Crawl errors.

When the origin fails

Not every URL can be fetched. The domain may not resolve, the certificate may be broken, nothing may be listening, or the origin may answer with a 404. Those are the origin's failures, not Expand's, and Fetch reports them as such.

POST /v1/fetch/json returns a typed FetchOriginError instead of a document when the origin was never reached:

{
  "_tag": "FetchOriginError",
  "url": "https://checkout.deadstartup.example/pricing",
  "kind": "dns",
  "upstreamError": "net::ERR_NAME_NOT_RESOLVED",
  "retryable": false
}

When a browser page's origin did answer with an error status, the captured content still comes back with the outcome attached, because an error page is often the answer you needed:

{
  "meta": { "url": "https://site.example/removed-page", "title": "Page not found" },
  "markdown": "# Page not found\n\nโ€ฆ",
  "json": [],
  "originError": {






POST /v1/fetch answers in Markdown, so the same facts ride the x-expand-origin-error, x-expand-origin-error-detail, and x-expand-origin-error-retryable response headers. When no document exists, the body is a short Markdown error document an agent can read directly.

A file has no error page to render. If its origin refuses it, Fetch returns a non-2xx FetchOriginHttpError: 404 or 410 for a missing or removed file, and 502 for other origin statuses. See Document errors.

For browser origin outcomes, kind says what went wrong and retryable says whether trying again could help. Network-error verdicts use the exact Chromium code; HTTP-error verdicts use the origin status. Every kind, field, and header is in API reference: Errors.

Should I retry?

Trust retryable:

OutcomeRetry?
200 with FetchOriginError, retryable: trueYes, after a delay. The condition may clear.
200 with FetchOriginError, retryable: falseNo. The URL is dead. Fix the URL or drop it.
200 with no origin errorNothing to retry.
Warning
Do not treat a 200 carrying an origin error as a transport failure. It is a completed answer about a URL that could not serve content.

When a response carries a Retry-After header (429, 503, and 529 do), wait that many seconds. Otherwise back off exponentially with jitter, up to about 32 seconds. Hitting a limit is the most common reason for a 429. What the limits are is on Limits.

The SDK retries for you

The TypeScript SDK follows the same rules. It retries when an error says retryable: true, up to maxRetries (default 2) within one timeoutMs budget (default 60000), which covers the retries and the waits between them.

It never retries the Batched Fetch 409 conflicts, the 502 page errors, or the 504 capture timeout, because repeating them reaches the same answer. It does retry 529 FetchCapacityTimeout and 503 FetchDocumentProcessingUnavailable with normal backoff, and 502 FetchOriginHttpError for a document. It does not retry 403, 413, 415, a 404 or 410 document refusal, or a 200 origin outcome.

Older TypeScript SDK versions expose an unrecognized 404 or 410 body as raw JSON text. Upgrade the SDK if you read structured fields from the error body. Crawl calls surface errors as a CrawlClientApiError with code, status, and retryable. Options are in TypeScript SDK: Errors and retries.

What you are charged for

You pay for delivered content, not for attempts. Charges follow the outcome, not the status code.

OutcomeBilled
Page captured successfullyYes, one credit
Origin responded with an error status (400+) and content was capturedYes, the content was delivered alongside the originError
Origin unreachable, any network FetchOriginError kindNo
(403)

The one case that surprises people is the second row: an origin's 404 or 500 page still contains content, such as a soft-404, a maintenance page, or a status dashboard, and Expand captured and returned it, so it costs a credit like any other page. If you do not want to pay for error pages, check originError before treating a response as useful.

Everything else is straightforward: if no content reached you, no charge was made. See Pricing & Usage for what each operation costs in credits.

Note
A request made with no credits left is refused before any work starts.

Handle errors in code

With the TypeScript SDK

ExpandClient throws for non-2xx API responses, including a file origin's FetchOriginHttpError. Inspect status and body on ExpandClientApiError to branch on the _tag. See the TypeScript SDK for the full error hierarchy.

import { ExpandClient, ExpandClientApiError, ExpandClientError } from '@expandai/sdk'

const expand = new ExpandClient()

try {
  const result = await expand.fetchJson({ url: 'https://example.com' })
  // Reached here means the API returned 200. Check result for an origin outcome.
} catch (error) {
  if (error instanceof ExpandClientApiError) {
    // A non-2xx response. `body` is the parsed error above. Branch on `_tag`.
    console.log('Status:', error.status)
    console.log('Tag:', (error.body 




The SDK already retries for you, using the same retryable field. It never retries the 502 page errors, FetchNavigationFailed and FetchCaptureFailed, and it waits out Retry-After before retrying a 429. Anything that reaches your catch block has run out of attempts.

fetchJson resolves to either shape, so narrow before reading content. Only the origin-outcome variant carries a top-level _tag:

import { isFetchOriginError } from '@expandai/sdk'

const result = await expand.fetchJson({ url: 'https://example.com' })

if (isFetchOriginError(result)) {
  // The origin was never reached, so there is no content.
  console.log(result.kind, result.retryable)
} else if (result.originError) {
  // The origin answered with an error status, but the page was captured.
  console.log(result.originError.originStatusCode, result.markdown)
} else {
  console.log(result.markdown)
}

With cURL

Check the status code first, then the body. On /v1/fetch, the origin outcome is in the response headers.

response=$(curl -s -D /tmp/expand-headers -w "\n%{http_code}" \
  -X POST https://api.expand.ai/v1/fetch \
  -H "x-expand-api-key: $EXPAND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}')

status_code=$(echo "$response" | tail -n 1)
body=$(echo 







Without an SDK

If you are calling the API directly rather than through an SDK, retry only when the body says to, and never sooner than Retry-After asks:

  1. Read retryable from the error body. If it is false, stop. No retry can succeed
  2. Wait at least Retry-After seconds (or the body's retryAfterSeconds, if present)
  3. Back off exponentially from there, up to a maximum (e.g., 32 seconds)
  4. Jitter the wait. Every client rejected in the same burst is handed the same Retry-After, so an unjittered wait sends the whole population back at one instant
  5. After your maximum attempts, surface the error to the user

A successful /v1/fetch responds with text/markdown, not JSON. Read it with response.text(). Only the error responses are JSON, and a failure from an intermediary (a proxy's own error page, an empty body) may not be JSON either, so parsing it has to be allowed to fail.

async function fetchWithRetry(url: string, maxRetries = 5) {
  let delay = 1000

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch('https://api.expand.ai/v1/fetch', {
      method: 'POST',
      headers: { 'x-expand-api-key': API_KEY, 'content-type': 'application/json' },
      body: JSON.stringify({ url }),
    })
    // The success body is the Markdown document itself.






















Best practices

  1. Check the status and the body. A 200 may carry an origin outcome. A non-2xx may be an API or capture error, or a file origin's FetchOriginHttpError refusal (404, 410, or 502).
  2. Branch on _tag, not on message text.
  3. Trust retryable when present. For browser origin network errors, it is derived from the exact Chromium code. false means stop, and true means the condition may clear. For a file-origin FetchOriginHttpError, use the HTTP status because its body has no retryable field.
  4. Retry transient outcomes with backoff and never sooner than Retry-After. The SDK also retries a file-origin 502 FetchOriginHttpError.
  5. Do not automatically retry page verdicts (403, 413, 502) or the conservative timeout fallback (). A deliberate 504 retry may still be appropriate, but each attempt re-runs a full capture.

Next steps

  • API reference: Errors: every status code, error type, and example.
  • Limits: concurrency, timeouts, and sizes.
  • Pricing & Usage: what each outcome costs.
PreviousDocs for agents
NextPricing & Usage

On This Page

The two error shapesWhen the origin failsShould I retry?The SDK retries for youWhat you are charged forHandle errors in codeWith the TypeScript SDKWith cURLWithout an SDKBest practicesNext steps
"
_tag
"
:
"FetchOriginError"
,
"url": "https://site.example/removed-page",
"kind": "http",
"originStatusCode": 404,
"retryable": false
}
}
404 or 410 with FetchOriginHttpError for a fileNo. The file is missing or gone.
400, 401, 403, 413No. Fix the request, the credentials, the balance, or the URL.
429Yes, following retryable and Retry-After. See Limits.
500Yes when the body says retryable: true. Report it with the trace ID if it persists.
502 with FetchNavigationFailed or FetchCaptureFailedNo. The page or the capture fails the same way every time, so another attempt reaches the same result.
502 with FetchOriginHttpError for a fileYes. The file's origin may serve it later; the SDK retries this outcome.
503Yes, after Retry-After. Expand could not prove the origin or the page was at fault, so the failure counts as Expand's and may clear.
504 with FetchCaptureTimeoutNo automatic retry. A deliberate retry may still be appropriate.
529 with FetchCapacityTimeoutYes, after Retry-After. The capture never started.
FetchBlocked
No
FetchPageTooLarge (413)No
ServiceUnavailable (503) or InternalError (500)No
as
{ _tag?: string })?._tag)
} else if (error instanceof ExpandClientError) {
// Everything else the SDK can throw: timeouts, connection failures, bad options.
console.log('Error:', error.message)
}
}
"
$response
"
| sed
'$d'
)
if [ "$status_code" -ne 200 ]; then
# API, capture, or file-origin error. The body carries a _tag.
echo "Fetch error ($status_code): $body"
elif grep -qi '^x-expand-origin-error:' /tmp/expand-headers; then
# Expand succeeded; the origin did not.
grep -i '^x-expand-origin-error' /tmp/expand-headers
fi
if (response.ok) return response.text()
// An undecodable error body tells you nothing about retryability. Fall back to
// the status: 408/429/5xx are the transient ones. 409 is a conflict verdict,
// not a transient failure. Retrying reaches the same answer.
const body = await response.json().catch(() => undefined)
const retryable =
typeof body?.retryable === 'boolean'
? body.retryable
: response.status === 408 || response.status === 429 || response.status >= 500
if (!retryable || attempt === maxRetries - 1) throw new Error(body?._tag ?? `HTTP ${response.status}`)
// The body wins over the header: it survives proxies that strip headers, and it is the
// value the server chose for this error rather than the default for this status.
const retryAfter =
Number(body?.retryAfterSeconds ?? response.headers.get('retry-after') ?? 0) * 1000
const wait = Math.max(delay, retryAfter)
// Spread over [1x, 1.25x], upward only, since `Retry-After` names the earliest
// moment worth trying again and jittering below it retries sooner than asked.
await new Promise(resolve => setTimeout(resolve, wait * (1 + Math.random() * 0.25)))
delay = Math.min(delay * 2, 32_000)
}
}
504
  • Allow time for the content you fetch: use a 180000 ms client timeout for browser captures or 3600000 ms for documents. Documents can process for up to 55 minutes.
  • Record x-expand-trace-id on every response. It is what support needs, and it is present even when the body is not.
  • Validate inputs before sending to avoid 400 errors.