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 likeTIMEOUTorNETWORK_ERRORmessage- human-readable descriptionretryable- a boolean telling you whether this is a transient failure worth retryingurl- 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 errorTIMEOUT- page took too long to loadPROXY_CONNECTION_ERROR- proxy unreachableBOT_DETECTED- might pass on retry with a different proxyEMPTY_CONTENT- page might have been rate-limiting
Examples of non-retryable errors:
INVALID_URL- malformed URL, not going to improveDNS_ERROR- hostname doesn't existROBOTS_BLOCKED- robots.txt forbids itACCESS_DENIED- 401/403 from the originPROXY_EXHAUSTED- all proxy tiers tried and failedVALIDATION_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.).