# How do I add a backup to Scrapfly instead of paying for residential?

Keep Scrapfly on its datacenter pool, where a page with a browser costs 6 credits ($0.55 to $0.90 per 1,000 pages, by plan), and send the pages that fail there to UpRock instead of retrying them on the residential pool. A residential page with a browser costs 30 credits, $2.73 to $4.50 per 1,000 on plan and $10.50 past the Pro quota, while UpRock charges $0.50 per 1,000 pages whatever the page needs.

Updated 25 Oct 2026. Web page: https://uprock.ai/compare/scrapfly

## On this page

- [What Scrapfly does well](#what-scrapfly-does-well)
- [What a page costs by proxy pool and plan](#what-a-page-costs-by-proxy-pool-and-plan)
- [How Scrapfly reports a page it couldn't get](#how-scrapfly-reports-a-page-it-couldnt-get)
- [Scrapfly and UpRock side by side](#scrapfly-and-uprock-side-by-side)
- [The fallback, in TypeScript and Python](#the-fallback-in-typescript-and-python)
- [FAQ](#faq)
- [Other use cases to explore](#explore)
- [Related articles](#related)
- [References](#references)
- [Get started](#get-started)

## What Scrapfly does well

Scrapfly is a scraping platform with one API key and one credit balance across five APIs: Web Scraping, Cloud Browser, Crawler, Extraction and Screenshot. The Web Scraping API renders JavaScript, returns raw HTML, clean HTML, text or markdown, and has an Unblocker (formerly ASP) that adjusts proxies, browser and headers for each target. Around it: extraction with pre-trained models, CSS or XPath templates and LLM prompts; full-page screenshots; Cloud Browser sessions you drive with Playwright or Puppeteer; scheduled jobs and webhooks; a monitoring dashboard with alerts; per-request cost budgets and per-site throttlers; SDKs for Python, TypeScript, Go and Rust plus a Scrapy extension; a CLI; and a hosted MCP server. It lists SOC 2 Type II, ISO 27001 and HIPAA, and gives 1,000 free credits with no card.

If you want extraction, screenshots and a remote browser in the same account, Scrapfly covers a lot. Keep it.

## What a page costs by proxy pool and plan

Scrapfly's billing docs make the proxy pool the base cost of a page, 1 credit on the datacenter pool or 25 on the residential pool (never both), plus 5 when a browser renders it. Here's what 1,000 pages cost on each self-serve plan's included credits, checked 25 Oct 2026 (Discovery is $30 a month for 200,000 credits, Pro $100 for 1 million, Startup $250 for 2.5 million, Enterprise $500 for 5.5 million):

| Request | Credits | Discovery | Pro | Startup | Enterprise | UpRock |
|---|---|---|---|---|---|---|
| Datacenter pool, no browser | 1 | $0.15 | $0.10 | $0.10 | $0.09 | $0.50 |
| Datacenter pool with a browser | 6 | $0.90 | $0.60 | $0.60 | $0.55 | $0.50 |
| Residential pool, no browser | 25 | $3.75 | $2.50 | $2.50 | $2.27 | $0.50 |
| Residential pool with a browser | 30 | $4.50 | $3.00 | $3.00 | $2.73 | $0.50 |

Past the monthly quota, Pro, Startup and Enterprise keep going at $3.50, $2.00 and $1.20 per 10,000 credits (capped by default at half the quota), and Discovery stops. That changes the bottom rows the most:

| Request | Pro, past quota | Startup, past quota | Enterprise, past quota |
|---|---|---|---|
| Datacenter pool with a browser | $2.10 | $1.20 | $0.72 |
| Residential pool with a browser | $10.50 | $6.00 | $3.60 |

On the datacenter pool, Scrapfly costs less than UpRock without a browser on every plan, and about the same with one on the bigger plans. On the residential pool it costs more on every plan. Unused credits don't roll over.

The Unblocker blurs those rows. Its docs say it can move a request to a better proxy pool or turn on a browser by itself, and that some targets carry fees that aren't published; the pricing page lists it at 5 credits and the MCP docs at 10 to 30. The real cost of each request comes back in the `X-Scrapfly-Api-Cost` header, and `cost_budget` puts a ceiling on it.

## How Scrapfly reports a page it couldn't get

Scrapfly puts the reason in the JSON body (`result.error.code`) and in `x-scrapfly-reject-*` headers, and its SDKs raise a typed error that carries the code. When the site itself refused, the API still answers HTTP 200 with `ERR::SCRAPE::BAD_UPSTREAM_RESPONSE`, and the site's own status is in `result.status_code`. Here's how we'd route the codes that matter, from Scrapfly's error catalog (checked 25 Oct 2026; codes without a prefix are `ERR::SCRAPE::`):

| What you see | What it means | Send to UpRock? |
|---|---|---|
| `BAD_UPSTREAM_RESPONSE`, site status 403 or 429 | The site refused or rate-limited the request | Yes |
| `BAD_UPSTREAM_RESPONSE`, site status 5xx | The site failed | Yes |
| `BAD_UPSTREAM_RESPONSE`, site status 404 or 410 | The page is gone (billed) | No: it's the site's answer |
| `ERR::PROXY::POOL_NOT_AVAILABLE_FOR_TARGET` | The domain needs at least the residential pool | Yes, instead of paying 25 credits |
| `COST_BUDGET_LIMIT` | The page would cost more than your `cost_budget` | Yes |
| `ERR::ASP::*` | The Unblocker didn't get the page (except `SHIELD_NOT_ELIGIBLE`, an option it can't combine) | Yes |
| `OPERATION_TIMEOUT`, `UPSTREAM_TIMEOUT`, `DRIVER_TIMEOUT`, `ERR::PROXY::TIMEOUT` | Something timed out | Yes |
| `NETWORK_ERROR`, `NO_BROWSER_AVAILABLE`, `DRIVER_CRASHED`, `ERR::PROXY::RESOURCES_SATURATION` | Trouble on Scrapfly's side or its network | Yes |
| `TOO_MANY_CONCURRENT_REQUEST`, `QUOTA_LIMIT_REACHED`, `ERR::THROTTLE::*` | Your concurrency, quota or throttle limit | Yes |
| `COUNTRY_NOT_AVAILABLE_FOR_TARGET`, `ERR::PROXY::POOL_UNAVAILABLE_COUNTRY` | No proxies in that country for this request | Yes |
| HTTP 401 | A missing or invalid key | Fix the key (the code sends it to UpRock meanwhile) |
| `DOMAIN_NOT_ALLOWED`, `ERR::PROXY::UNAVAILABLE` | Scrapfly restricts the domain (its docs mention mainly government sites) | No: respect it |
| `DNS_NAME_NOT_RESOLVED`, `SSL_ERROR` | The site itself is broken | No |

The 30% rule in the billing docs is one more reason to move a stubborn page elsewhere rather than retry it: if more than 30% of your traffic fails over an hour or more, Scrapfly bills the failures.

## Scrapfly and UpRock side by side

| | Scrapfly | UpRock |
|---|---|---|
| Price per 1,000 pages | [$0.09 to $4.50 on plan](https://scrapfly.io/pricing), up to $10.50 past the Pro quota, by pool and browser (tables above) | $0.50 by card or USDC, $0.40 with UPT |
| Residential | [25 credits a page instead of 1](https://scrapfly.io/docs/scrape-api/billing) | No surcharge: every page is one credit |
| Failed requests | [Not billed, with exceptions](https://scrapfly.io/docs/scrape-api/billing): 404, 410 and other listed statuses, and the 30% rule | Not documented yet |
| Countries | [70 datacenter and 77 residential](https://scrapfly.io/docs/scrape-api/proxy) in its proxy docs; 190+ on its pricing page | 63, or a region |
| Where requests come from | Datacenter and residential proxy pools; the docs we read don't say how the residential IPs are sourced | Devices whose owners opted in |
| Extraction, screenshots, remote browser, crawler | Yes | No. One page per call |
| Cost controls | `cost_budget` per request, project limits, per-site throttlers | Prepaid credits |
| Concurrency | 5 to 100 requests at once, by plan | Not published yet |
| MCP server | [Hosted](https://scrapfly.io/docs/mcp/getting-started), API key or OAuth2 | Yes, remote (streamable HTTP) |
| Video | Not one of its five APIs | Search public video by moment, $1 per 1,000 searches |
| To try it | 1,000 free credits, no card | Prepaid credits |

Where Scrapfly is better: extraction, screenshots and remote browsers in one account, fine control over what each request may cost, more countries, failed scrapes that mostly cost nothing, cheap datacenter pages, compliance certificates and a free allowance. Where UpRock fits: the pages that fail on Scrapfly's datacenter pool, so they don't move to the 25-credit residential pool, and one price per page whatever the page needs.

## The fallback, in TypeScript and Python

Every version ask Scrapfly for markdown from the datacenter pool with a browser (6 credits), with `cost_budget` at 6 so a page can never cost more. They also turn off Scrapfly's own retries and give it 90 seconds, so a page it can't get fails over quickly; leave `retry` on and use a 155-second client timeout if you'd rather give Scrapfly more tries. The codes worth a second network go to UpRock and the rest raise. Both use Scrapfly's SDKs; the TypeScript SDK has no request timeout, so that version races its own. UpRock gets one `POST /v1/crawl` request with your key in an `Authorization: Bearer` header; the MCP and CLI tabs do the same through UpRock's MCP server (TypeScript) or CLI (Python).

### TypeScript (HTTP)

```ts
// npm i scrapfly-sdk   (Node 18+)   env: SCRAPFLY_API_KEY, UPROCK_API_KEY
import { ScrapflyClient, ScrapeConfig, ScrapeResult, errors } from "scrapfly-sdk";

const scrapfly = new ScrapflyClient({ key: process.env.SCRAPFLY_API_KEY ?? "" });

// Scrapfly error codes (scrapfly.io/docs/scrape-api/errors, checked 25 Oct 2026) worth a
// second network: the domain needs the residential pool, the page is over budget, a
// timeout, trouble on Scrapfly's side, your concurrency or quota, a country it lacks.
const TRY_UPROCK = new Set([
  "ERR::PROXY::POOL_NOT_AVAILABLE_FOR_TARGET",
  "ERR::SCRAPE::COST_BUDGET_LIMIT",
  "ERR::SCRAPE::OPERATION_TIMEOUT",
  "ERR::SCRAPE::UPSTREAM_TIMEOUT",
  "ERR::SCRAPE::DRIVER_TIMEOUT",
  "ERR::PROXY::TIMEOUT",
  "ERR::SCRAPE::NETWORK_ERROR",
  "ERR::SCRAPE::NO_BROWSER_AVAILABLE",
  "ERR::SCRAPE::DRIVER_CRASHED",
  "ERR::PROXY::RESOURCES_SATURATION",
  "ERR::SCRAPE::TOO_MANY_CONCURRENT_REQUEST",
  "ERR::SCRAPE::QUOTA_LIMIT_REACHED",
  "ERR::SCRAPE::COUNTRY_NOT_AVAILABLE_FOR_TARGET",
  "ERR::PROXY::POOL_UNAVAILABLE_COUNTRY",
]);
// Also every ERR::ASP:: (the Unblocker didn't get the page) and ERR::THROTTLE:: code.
// The rest are answers: DOMAIN_NOT_ALLOWED and PROXY::UNAVAILABLE (a restricted domain),
// a broken site (DNS_NAME_NOT_RESOLVED, SSL_ERROR), bad parameters.

// A site's own status, from BAD_UPSTREAM_RESPONSE: 404 and 410 are the site's answer.
const siteWorthRetrying = (status: number) =>
  status === 403 || status === 408 || status === 429 || status >= 500;

function worthUpRock(err: unknown) {
  if (!(err instanceof errors.ScrapflyError)) return true; // network error or our timeout
  const code = String(err.args.code ?? "");
  // Errors from the API itself carry no code: send on a bad key (401) or a rate limit (429).
  if (!code) return /"http_code":\s*(401|429)\b/.test(err.message);
  if (code === "ERR::SCRAPE::BAD_UPSTREAM_RESPONSE")
    return siteWorthRetrying(Number(err.args.http_status_code));
  if (code === "ERR::ASP::SHIELD_NOT_ELIGIBLE") return false;
  return TRY_UPROCK.has(code) || /^ERR::(ASP|THROTTLE)::/.test(code);
}

async function viaUpRock(url: string, country: string) {
  // One call: UpRock loads the page on a real device in that country, tries a fresh device
  // every 7 s (up to 3 tries) and returns markdown. Failed calls aren't billed.
  const res = await fetch("https://edge.uprock.com/v1/crawl", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UPROCK_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ url, country }),
    signal: AbortSignal.timeout(90_000),
  });
  const page = await res.json();
  if (!res.ok) throw new Error(`UpRock ${res.status} ${page.code}: ${page.error}`);
  return page.markdown ?? "";
}

// The SDK's scrape() has no timeout of its own.
async function scrapeWithin(config: ScrapeConfig, ms: number) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`no answer in ${ms / 1000} s`)), ms);
  });
  try {
    return (await Promise.race([scrapfly.scrape(config), timeout])) as ScrapeResult;
  } finally {
    clearTimeout(timer);
  }
}

export async function read(url: string, country = "US") {
  const config = new ScrapeConfig({
    url,
    format: "markdown",
    render_js: true, // +5 credits
    proxy_pool: "public_datacenter_pool", // 1 credit; the residential pool is 25
    cost_budget: 6, // never more than a datacenter page with a browser
    country: country.toLowerCase(),
    retry: false, // fail over instead of waiting out Scrapfly's own retries,
    timeout: 90_000, // after 90 s of trying
  });
  try {
    const res = await scrapeWithin(config, 95_000); // Scrapfly's timeout + 5 s
    return { via: "scrapfly", markdown: res.result.content };
  } catch (err) {
    if (!worthUpRock(err)) throw err;
    console.warn(`Scrapfly: ${(err as Error).message.slice(0, 160)}; trying UpRock`);
  }
  return { via: "uprock", markdown: await viaUpRock(url, country) };
}

// node read.ts https://developer.mozilla.org/en-US/docs/Web/HTTP DE
const [url = "https://developer.mozilla.org/en-US/docs/Web/HTTP", country = "US"] =
  process.argv.slice(2);
const page = await read(url, country);
console.log(page.via, page.markdown.slice(0, 200));
```

### Python (HTTP)

```python
# pip install scrapfly-sdk requests   env: SCRAPFLY_API_KEY, UPROCK_API_KEY
import os
import sys

import requests
from scrapfly import ScrapeConfig, ScrapflyClient
from scrapfly.errors import ScrapflyError

# Read timeout: Scrapfly's own 90 s timeout (set below) plus 5 s.
scrapfly = ScrapflyClient(
    key=os.environ.get("SCRAPFLY_API_KEY", ""), web_scraping_api_read_timeout=95
)

# Scrapfly error codes (scrapfly.io/docs/scrape-api/errors, checked 25 Oct 2026) worth a
# second network: the domain needs the residential pool, the page is over budget, a
# timeout, trouble on Scrapfly's side, your concurrency or quota, a country it lacks.
TRY_UPROCK = {
    "ERR::PROXY::POOL_NOT_AVAILABLE_FOR_TARGET",
    "ERR::SCRAPE::COST_BUDGET_LIMIT",
    "ERR::SCRAPE::OPERATION_TIMEOUT",
    "ERR::SCRAPE::UPSTREAM_TIMEOUT",
    "ERR::SCRAPE::DRIVER_TIMEOUT",
    "ERR::PROXY::TIMEOUT",
    "ERR::SCRAPE::NETWORK_ERROR",
    "ERR::SCRAPE::NO_BROWSER_AVAILABLE",
    "ERR::SCRAPE::DRIVER_CRASHED",
    "ERR::PROXY::RESOURCES_SATURATION",
    "ERR::SCRAPE::TOO_MANY_CONCURRENT_REQUEST",
    "ERR::SCRAPE::QUOTA_LIMIT_REACHED",
    "ERR::SCRAPE::COUNTRY_NOT_AVAILABLE_FOR_TARGET",
    "ERR::PROXY::POOL_UNAVAILABLE_COUNTRY",
}
# Also every ERR::ASP:: (the Unblocker didn't get the page) and ERR::THROTTLE:: code.
# The rest are answers: DOMAIN_NOT_ALLOWED and PROXY::UNAVAILABLE (a restricted domain),
# a broken site (DNS_NAME_NOT_RESOLVED, SSL_ERROR), bad parameters.


def worth_uprock(err: ScrapflyError) -> bool:
    code = err.code or ""
    status = err.http_status_code or 0
    if not code:  # errors from the API itself: send on a bad key or a rate limit
        return status in (401, 429)
    if code == "ERR::SCRAPE::BAD_UPSTREAM_RESPONSE":  # status is the site's own
        return status in (403, 408, 429) or status >= 500  # not 404 or 410
    if code == "ERR::ASP::SHIELD_NOT_ELIGIBLE":
        return False
    return code in TRY_UPROCK or code.startswith(("ERR::ASP::", "ERR::THROTTLE::"))


def via_uprock(url: str, country: str) -> str:
    # One call: UpRock loads the page on a real device in that country, tries a fresh device
    # every 7 s (up to 3 tries) and returns markdown. Failed calls aren't billed.
    res = requests.post(
        "https://edge.uprock.com/v1/crawl",
        headers={"Authorization": f"Bearer {os.environ['UPROCK_API_KEY']}"},
        json={"url": url, "country": country},
        timeout=90,
    )
    page = res.json()
    if not res.ok:
        raise RuntimeError(f"UpRock {res.status_code} {page.get('code')}: {page.get('error')}")
    return page.get("markdown", "")


def read(url: str, country: str = "US") -> dict:
    config = ScrapeConfig(
        url=url,
        format="markdown",
        render_js=True,  # +5 credits
        proxy_pool="public_datacenter_pool",  # 1 credit; the residential pool is 25
        cost_budget=6,  # never more than a datacenter page with a browser
        country=country.lower(),
        retry=False,  # fail over instead of waiting out Scrapfly's own retries,
        timeout=90_000,  # after 90 s of trying
    )
    try:
        return {"via": "scrapfly", "markdown": scrapfly.scrape(config).content}
    except ScrapflyError as err:
        if not worth_uprock(err):
            raise
        label = err.code or err.http_status_code
        print(f"Scrapfly {label}; trying UpRock", file=sys.stderr)
    except Exception as err:  # network error or timeout
        print(f"Scrapfly unreachable ({err}); trying UpRock", file=sys.stderr)
    return {"via": "uprock", "markdown": via_uprock(url, country)}


if __name__ == "__main__":
    # python read.py https://developer.mozilla.org/en-US/docs/Web/HTTP DE
    url = sys.argv[1] if len(sys.argv) > 1 else "https://developer.mozilla.org/en-US/docs/Web/HTTP"
    country = sys.argv[2] if len(sys.argv) > 2 else "US"
    page = read(url, country)
    print(page["via"], page["markdown"][:200])
```

### TypeScript (MCP)

```ts
// npm i scrapfly-sdk @modelcontextprotocol/sdk   (Node 18+)   env: SCRAPFLY_API_KEY, UPROCK_API_KEY
import { ScrapflyClient, ScrapeConfig, ScrapeResult, errors } from "scrapfly-sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const scrapfly = new ScrapflyClient({ key: process.env.SCRAPFLY_API_KEY ?? "" });
const uprock = new Client({ name: "scrapfly-fallback", version: "1.0.0" });
await uprock.connect(
  new StreamableHTTPClientTransport(
    new URL("https://mcp.uprock.ai/mcp"),
    { requestInit: { headers: { Authorization: `Bearer ${process.env.UPROCK_API_KEY}` } } },
  ),
);

// Scrapfly error codes (scrapfly.io/docs/scrape-api/errors, checked 25 Oct 2026) worth a
// second network: the domain needs the residential pool, the page is over budget, a
// timeout, trouble on Scrapfly's side, your concurrency or quota, a country it lacks.
const TRY_UPROCK = new Set([
  "ERR::PROXY::POOL_NOT_AVAILABLE_FOR_TARGET",
  "ERR::SCRAPE::COST_BUDGET_LIMIT",
  "ERR::SCRAPE::OPERATION_TIMEOUT",
  "ERR::SCRAPE::UPSTREAM_TIMEOUT",
  "ERR::SCRAPE::DRIVER_TIMEOUT",
  "ERR::PROXY::TIMEOUT",
  "ERR::SCRAPE::NETWORK_ERROR",
  "ERR::SCRAPE::NO_BROWSER_AVAILABLE",
  "ERR::SCRAPE::DRIVER_CRASHED",
  "ERR::PROXY::RESOURCES_SATURATION",
  "ERR::SCRAPE::TOO_MANY_CONCURRENT_REQUEST",
  "ERR::SCRAPE::QUOTA_LIMIT_REACHED",
  "ERR::SCRAPE::COUNTRY_NOT_AVAILABLE_FOR_TARGET",
  "ERR::PROXY::POOL_UNAVAILABLE_COUNTRY",
]);
// Also every ERR::ASP:: (the Unblocker didn't get the page) and ERR::THROTTLE:: code.
// The rest are answers: DOMAIN_NOT_ALLOWED and PROXY::UNAVAILABLE (a restricted domain),
// a broken site (DNS_NAME_NOT_RESOLVED, SSL_ERROR), bad parameters.

// A site's own status, from BAD_UPSTREAM_RESPONSE: 404 and 410 are the site's answer.
const siteWorthRetrying = (status: number) =>
  status === 403 || status === 408 || status === 429 || status >= 500;

function worthUpRock(err: unknown) {
  if (!(err instanceof errors.ScrapflyError)) return true; // network error or our timeout
  const code = String(err.args.code ?? "");
  // Errors from the API itself carry no code: send on a bad key (401) or a rate limit (429).
  if (!code) return /"http_code":\s*(401|429)\b/.test(err.message);
  if (code === "ERR::SCRAPE::BAD_UPSTREAM_RESPONSE")
    return siteWorthRetrying(Number(err.args.http_status_code));
  if (code === "ERR::ASP::SHIELD_NOT_ELIGIBLE") return false;
  return TRY_UPROCK.has(code) || /^ERR::(ASP|THROTTLE)::/.test(code);
}

const textOf = (content: unknown) =>
  (content as { type: string; text?: string }[]).find((c) => c.type === "text")?.text ??
  "";

async function viaUpRock(url: string, country: string) {
  const res = await uprock.callTool(
    { name: "crawl_fetch", arguments: { url, country } },
    undefined,
    { timeout: 300_000 },
  );
  if (res.isError) throw new Error(`UpRock: ${textOf(res.content)}`);
  const uri = JSON.parse(textOf(res.content)).content?.markdown?.resource;
  if (!uri) throw new Error(`UpRock: no markdown for ${url}`);
  return textOf(
    (await uprock.callTool({ name: "resource_fetch", arguments: { uri } })).content,
  );
}

// The SDK's scrape() has no timeout of its own.
async function scrapeWithin(config: ScrapeConfig, ms: number) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => reject(new Error(`no answer in ${ms / 1000} s`)), ms);
  });
  try {
    return (await Promise.race([scrapfly.scrape(config), timeout])) as ScrapeResult;
  } finally {
    clearTimeout(timer);
  }
}

export async function read(url: string, country = "US") {
  const config = new ScrapeConfig({
    url,
    format: "markdown",
    render_js: true, // +5 credits
    proxy_pool: "public_datacenter_pool", // 1 credit; the residential pool is 25
    cost_budget: 6, // never more than a datacenter page with a browser
    country: country.toLowerCase(),
    retry: false, // fail over instead of waiting out Scrapfly's own retries,
    timeout: 90_000, // after 90 s of trying
  });
  try {
    const res = await scrapeWithin(config, 95_000); // Scrapfly's timeout + 5 s
    return { via: "scrapfly", markdown: res.result.content };
  } catch (err) {
    if (!worthUpRock(err)) throw err;
    console.warn(`Scrapfly: ${(err as Error).message.slice(0, 160)}; trying UpRock`);
  }
  return { via: "uprock", markdown: await viaUpRock(url, country) };
}

// node read.ts https://developer.mozilla.org/en-US/docs/Web/HTTP DE
const [url = "https://developer.mozilla.org/en-US/docs/Web/HTTP", country = "US"] =
  process.argv.slice(2);
try {
  const page = await read(url, country);
  console.log(page.via, page.markdown.slice(0, 200));
} finally {
  await uprock.close();
}
```

### Python (CLI)

```python
# pip install scrapfly-sdk; npm i -g uprock   env: SCRAPFLY_API_KEY, UPROCK_API_KEY
import json
import os
import subprocess
import sys

from scrapfly import ScrapeConfig, ScrapflyClient
from scrapfly.errors import ScrapflyError

# Read timeout: Scrapfly's own 90 s timeout (set below) plus 5 s.
scrapfly = ScrapflyClient(
    key=os.environ.get("SCRAPFLY_API_KEY", ""), web_scraping_api_read_timeout=95
)

# Scrapfly error codes (scrapfly.io/docs/scrape-api/errors, checked 25 Oct 2026) worth a
# second network: the domain needs the residential pool, the page is over budget, a
# timeout, trouble on Scrapfly's side, your concurrency or quota, a country it lacks.
TRY_UPROCK = {
    "ERR::PROXY::POOL_NOT_AVAILABLE_FOR_TARGET",
    "ERR::SCRAPE::COST_BUDGET_LIMIT",
    "ERR::SCRAPE::OPERATION_TIMEOUT",
    "ERR::SCRAPE::UPSTREAM_TIMEOUT",
    "ERR::SCRAPE::DRIVER_TIMEOUT",
    "ERR::PROXY::TIMEOUT",
    "ERR::SCRAPE::NETWORK_ERROR",
    "ERR::SCRAPE::NO_BROWSER_AVAILABLE",
    "ERR::SCRAPE::DRIVER_CRASHED",
    "ERR::PROXY::RESOURCES_SATURATION",
    "ERR::SCRAPE::TOO_MANY_CONCURRENT_REQUEST",
    "ERR::SCRAPE::QUOTA_LIMIT_REACHED",
    "ERR::SCRAPE::COUNTRY_NOT_AVAILABLE_FOR_TARGET",
    "ERR::PROXY::POOL_UNAVAILABLE_COUNTRY",
}
# Also every ERR::ASP:: (the Unblocker didn't get the page) and ERR::THROTTLE:: code.
# The rest are answers: DOMAIN_NOT_ALLOWED and PROXY::UNAVAILABLE (a restricted domain),
# a broken site (DNS_NAME_NOT_RESOLVED, SSL_ERROR), bad parameters.


def worth_uprock(err: ScrapflyError) -> bool:
    code = err.code or ""
    status = err.http_status_code or 0
    if not code:  # errors from the API itself: send on a bad key or a rate limit
        return status in (401, 429)
    if code == "ERR::SCRAPE::BAD_UPSTREAM_RESPONSE":  # status is the site's own
        return status in (403, 408, 429) or status >= 500  # not 404 or 410
    if code == "ERR::ASP::SHIELD_NOT_ELIGIBLE":
        return False
    return code in TRY_UPROCK or code.startswith(("ERR::ASP::", "ERR::THROTTLE::"))


def via_uprock(url: str, country: str) -> str:
    run = subprocess.run(
        ["uprock", "ai", "crawl", url, "--country", country, "--content"],
        capture_output=True,
        text=True,
        timeout=330,
    )
    if run.returncode != 0:
        raise RuntimeError(f"UpRock: {run.stderr.strip()}")
    return json.loads(run.stdout).get("inlined_markdown", "")


def read(url: str, country: str = "US") -> dict:
    config = ScrapeConfig(
        url=url,
        format="markdown",
        render_js=True,  # +5 credits
        proxy_pool="public_datacenter_pool",  # 1 credit; the residential pool is 25
        cost_budget=6,  # never more than a datacenter page with a browser
        country=country.lower(),
        retry=False,  # fail over instead of waiting out Scrapfly's own retries,
        timeout=90_000,  # after 90 s of trying
    )
    try:
        return {"via": "scrapfly", "markdown": scrapfly.scrape(config).content}
    except ScrapflyError as err:
        if not worth_uprock(err):
            raise
        label = err.code or err.http_status_code
        print(f"Scrapfly {label}; trying UpRock", file=sys.stderr)
    except Exception as err:  # network error or timeout
        print(f"Scrapfly unreachable ({err}); trying UpRock", file=sys.stderr)
    return {"via": "uprock", "markdown": via_uprock(url, country)}


if __name__ == "__main__":
    # python read.py https://developer.mozilla.org/en-US/docs/Web/HTTP DE
    url = sys.argv[1] if len(sys.argv) > 1 else "https://developer.mozilla.org/en-US/docs/Web/HTTP"
    country = sys.argv[2] if len(sys.argv) > 2 else "US"
    page = read(url, country)
    print(page["via"], page["markdown"][:200])
```

Tested 25 Oct 2026 against the live UpRock API with an invalid Scrapfly key: Scrapfly answered 401 `Invalid API key`, the TypeScript SDK raised `BadApiKeyError` and the Python SDK `ApiHttpClientError`, and both versions fell back to UpRock. Without an account, the routing of the other codes follows Scrapfly's published error catalog, and the cost tables follow its pricing and billing pages. On 6 Nov 2026 we ran the HTTP and MCP versions again with an UpRock API key in an `Authorization` header: with an invalid Scrapfly key, each fell back to UpRock and got the page.

## FAQ

### Why does a residential page cost so much more on Scrapfly?

Scrapfly's billing docs make the proxy pool the base cost of a request, 1 credit on the datacenter pool or 25 on the residential pool, and a browser adds 5. So a residential page with a browser is 30 credits, five times a datacenter page with a browser.

### Does Scrapfly charge for failed scrapes?

Mostly not. Its billing docs exclude a list of status codes from its failed-scrape protection, among them 400, 401, 404, 410 and 422, and bill failures anyway if more than 30% of your traffic fails over an hour or more. Its pricing page puts it more simply, saying upstream 4xx and 5xx and failed scrapes are free.

### Can I stop the Unblocker from moving a page to the residential pool?

Yes, with cost_budget. Scrapfly's docs say the Unblocker may upgrade the proxy pool or turn on a browser, which raises the cost, and a request whose cost would pass the budget fails with ERR::SCRAPE::COST_BUDGET_LIMIT instead. The code on this page sets the budget to 6 credits and sends that error to UpRock.

### Which countries does Scrapfly cover?

Its pricing page says 190+ proxy countries. The country lists in its proxy docs name 70 for the datacenter pool and 77 for the residential pool. UpRock covers 63 countries, or a region such as EU or APAC, at one price.

### Does Scrapfly have an MCP server?

Yes, a hosted one at mcp.scrapfly.io/mcp, with tools for scraping, screenshots, Cloud Browser sessions, block checks, monitoring and docs search. It takes an API key or OAuth2.

## Other use cases to explore

- [See what automatic site tiers do to a bill](https://uprock.ai/compare/crawlbase#what-the-tiers-do-to-a-pages-price): another crawler that sets each site's price for you, from 1 to 20 times its base rate.
- [Decide when an unlocker is worth paying for](https://uprock.ai/compare/web-unlocker-apis#which-one-fits): when a plain proxy is enough, when an unlocker earns its price, and when a second network helps.
- [Price a page from one country](https://uprock.ai/compare/zenrows#what-a-page-costs-with-and-without-a-country): a provider where choosing a country moves every page to its premium tier.
- [Search public video by moment](https://uprock.ai/video-semantic-search): find the second something is shown or said, across platforms.

## Related articles

- [How do I add a backup crawler to Firecrawl?](https://uprock.ai/compare/firecrawl.md): When to send a page from Firecrawl to UpRock, published prices side by side, and a tested fallback in TypeScript and Python.
- [How do I add a backup for Tavily Extract?](https://uprock.ai/compare/tavily.md): Retry the URLs in Tavily Extract's failed results on a real device, and read pages as they appear in a chosen country. Prices side by side and tested code.
- [How do I add a fallback for Exa contents errors?](https://uprock.ai/compare/exa.md): Read the per-URL statuses in Exa's /contents response and retry the right ones live on a real device. Error tags explained, prices side by side, tested code.
- [How do I add a backup to Jina Reader, and when is it cheaper?](https://uprock.ai/compare/jina-reader.md): Spot a failed read in Reader's JSON (it still answers 200), retry it on a real device, and see what six real pages cost in Reader tokens next to a flat per-page price.
- [How do I add a hosted fallback to Crawl4AI?](https://uprock.ai/compare/crawl4ai.md): Plug UpRock into Crawl4AI's fallback_fetch_function so blocked pages are fetched on a real device in the country you choose, then run through Crawl4AI's own markdown pipeline.
- [How do I add a backup to Bright Data's Web Unlocker?](https://uprock.ai/compare/bright-data.md): Which Web Unlocker errors to retry, which to send to a second network and which to respect, with Bright Data's published prices next to ours and tested fallback code.
- [How do I add a fallback to an Apify or Crawlee crawler?](https://uprock.ai/compare/apify.md): Hand the requests Crawlee gives up on to a second network with failedRequestHandler, see what a fetched page costs on Apify's own Actors, and run the tested code.
- [When should a Browserbase agent read a page without opening a browser?](https://uprock.ai/compare/browserbase.md): What 1,000 page reads cost on Browserbase three ways, when to send a read to UpRock instead (including when every browser is busy), and tested fallback code.
- [How do I cap ScrapingBee's credit cost and send the rest to a backup?](https://uprock.ai/compare/scrapingbee.md): ScrapingBee charges 1 to 75 credits a page depending on the options. Cap it with Auto-Mode and max_cost, watch the country fallback, and send the rest to UpRock with tested code.
- [Spider costs less per page than UpRock. When is a backup still worth it?](https://uprock.ai/compare/spider.md): Spider is cheaper per page, so keep it as your primary. How Spider reports a page it couldn't get (from our own tests), and tested code that sends only those pages to UpRock.
- [How do I add a fallback to Parallel's Extract API?](https://uprock.ai/compare/parallel.md): Make Parallel Extract report a failed live fetch instead of quietly serving an old cached copy, then retry those URLs on UpRock. Prices side by side and tested code.
- [How do I add a backup to ZenRows?](https://uprock.ai/compare/zenrows.md): Which ZenRows error codes to send to a second network and which to respect, what a page from one country costs once premium proxies are required, and tested fallback code.
- [How do I retry a ScraperAPI batch's failed jobs on another network?](https://uprock.ai/compare/scraperapi.md): Send the jobs ScraperAPI's async batch gives up on to UpRock, see which countries each ScraperAPI plan can target, and compare credit costs. Tested code for both.
- [How do I add a backup to Oxylabs' new Web API?](https://uprock.ai/compare/oxylabs.md): Which Oxylabs Web API failures to send to a second network and which to respect, what a generic page really costs after its October 2026 pricing change, and tested fallback code.
- [How do I add a backup to Context.dev's scrape API?](https://uprock.ai/compare/context-dev.md): Context.dev reports a failed scrape inside an HTTP 200. Which of its error codes to retry on a second network, what a page costs by plan, and tested fallback code.
- [How do I keep Crawlbase's site tiers from surprising my bill?](https://uprock.ai/compare/crawlbase.md): Crawlbase bills each site at 1 to 20 times its base rate. What that does to the cost per 1,000 pages, and tested code that moves pricey domains to UpRock.
- [Which unlocker API fits: Bright Data, Oxylabs, Zyte or UpRock?](https://uprock.ai/compare/web-unlocker-apis.md): Bright Data's Web Unlocker, Oxylabs' Web API and Zyte API priced per 1,000 hard pages, with what each bills and when a second network helps.
- [Compare: all articles](https://uprock.ai/compare.md)

## References

1. [Scrapfly, Pricing (plans, credits, extra usage)](https://scrapfly.io/pricing), checked 25 Oct 2026
2. [Scrapfly docs, Web Scraping API billing (credit costs, failed-scrape protection, cost_budget)](https://scrapfly.io/docs/scrape-api/billing), checked 25 Oct 2026
3. [Scrapfly docs, API errors](https://scrapfly.io/docs/scrape-api/errors), checked 25 Oct 2026
4. [Scrapfly docs, Unblocker (how it changes the cost)](https://scrapfly.io/docs/scrape-api/unblocker), checked 25 Oct 2026
5. [Scrapfly docs, Proxy (pools and country lists)](https://scrapfly.io/docs/scrape-api/proxy), checked 25 Oct 2026
6. [Scrapfly docs, Understanding timeouts](https://scrapfly.io/docs/scrape-api/understand-timeout), checked 25 Oct 2026
7. [Scrapfly docs, MCP tools](https://scrapfly.io/docs/mcp/tools), checked 25 Oct 2026
8. [Scrapfly docs, MCP getting started](https://scrapfly.io/docs/mcp/getting-started), checked 25 Oct 2026
9. [Scrapfly TypeScript SDK on GitHub](https://github.com/scrapfly/typescript-scrapfly), checked 25 Oct 2026
10. [Scrapfly Python SDK on GitHub](https://github.com/scrapfly/python-scrapfly), checked 25 Oct 2026
11. [UpRock, everything in one file (API, CLI, MCP, pricing)](https://uprock.ai/llms-full.txt), checked 25 Oct 2026

## Get started

Get an API key: https://uprock.ai/app/api-keys

Paste this into your agent:

```text
Set up UpRock for me: read https://uprock.ai/agents.md, connect the UpRock MCP server, and run one test crawl. Tell me what it costs before you spend anything.
```

Setup for every client (MCP, skill, CLI): https://uprock.ai/agents.md
