Error Handling

Typed errors, proxy tiers, and what happens when a scrape fails.

Every failure in Reader surfaces as a typed error. This page covers the mental model; see API Reference - Errors for the full class list.

Everything is typed

All errors extend a base ReaderError class and carry:

  • code - a stable string like TIMEOUT or NETWORK_ERROR
  • message - human-readable description
  • retryable - a boolean telling you whether this is a transient failure worth retrying
  • url - the URL that failed (when applicable)
  • toJSON() - structured output for logging
import { ReaderError } from "@vakra-dev/reader";

try {
  await reader.scrape({ urls: [...] });
} catch (err) {
  if (err instanceof ReaderError) {
    console.error({
      code: err.code,
      retryable: err.retryable,
      message: err.message,
      url: err.url,
    });
  }
}

The retryable flag

retryable: true means the error is transient and a retry with the same input might succeed. retryable: false means the error is terminal - retrying won't help.

Examples of retryable errors:

  • NETWORK_ERROR - connection reset, socket error
  • TIMEOUT - page took too long to load
  • PROXY_CONNECTION_ERROR - proxy unreachable
  • BOT_DETECTED - might pass on retry with a different proxy
  • EMPTY_CONTENT - page might have been rate-limiting

Examples of non-retryable errors:

  • INVALID_URL - malformed URL, not going to improve
  • DNS_ERROR - hostname doesn't exist
  • ROBOTS_BLOCKED - robots.txt forbids it
  • ACCESS_DENIED - 401/403 from the origin
  • PROXY_EXHAUSTED - all proxy tiers tried and failed
  • VALIDATION_ERROR - you passed bad options

Proxy tier handling

If a scrape fails with BOT_DETECTED using the standard (datacenter) tier, consider retrying with the premium (residential) tier:

try {
  return await reader.scrape({ urls: [url], proxyTier: "standard" });
} catch (err) {
  if (err.code === "BOT_DETECTED") {
    return await reader.scrape({ urls: [url], proxyTier: "premium" });
  }
  throw err;
}

Non-retryable errors (DNS failure, invalid URL, robots.txt) skip directly to failure without trying another tier.

The timeouts are configurable per-request:

await reader.scrape({
  urls: [...],
  hardDeadlineMs: 30000,       // total cap per URL (default: 30s)
  datacenterTimeoutMs: 10000,  // first attempt timeout (default: 10s)
});

Error handling patterns

Simple try/catch

For one-off scripts, wrap the call and log:

try {
  const result = await reader.scrape({ urls: [url] });
  console.log(result.data[0].markdown);
} catch (err) {
  console.error(`Scrape failed: ${err.message}`);
}

Retry on retryable errors

For production code, check the flag:

async function scrapeWithRetry(url, maxAttempts = 3) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await reader.scrape({ urls: [url] });
    } catch (err) {
      if (!err.retryable || attempt === maxAttempts - 1) {
        throw err;
      }
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
    }
  }
}

Batch with partial failures

When scraping many URLs, a batch can partially succeed. The result's batchMetadata.errors array tells you which URLs failed:

const result = await reader.scrape({
  urls: [url1, url2, url3, url4],
  batchConcurrency: 2,
});

console.log(`Success: ${result.batchMetadata.successfulUrls}`);
console.log(`Failed:  ${result.batchMetadata.failedUrls}`);

for (const { url, error } of result.batchMetadata.errors ?? []) {
  console.error(`  ${url}: ${error}`);
}

The successful URLs still come back in result.data. Batch scraping never throws on individual URL failures - only on framework-level errors (browser pool exhausted, invalid options, etc.).

Where to go next