Reference

Troubleshooting / FAQ

Eight common failure patterns with verified causes and fixes — from empty markdown to MCP server visibility issues.

Covers: scrape, search, MCP, auth, rate limits, credits
Verified against: crw-core/src/error.rs · crw-server/src/middleware.rs · crw-server/src/app.rs

Quick diagnosis checklist

Before diving into individual patterns:

  1. Check success in the response body — false means a hard failure.
  2. Check errorCode (a snake_case string) for the machine-readable cause.
  3. Check data.warnings[] when success: true: soft issues like truncation, unsupported formats, and renderer fallbacks surface there. Anti-bot blocks are not in data.warnings[]; they always return success: false + errorCode: "anti_bot" (see pattern 2). A page the origin answered with a >= 400 status returns success: false; if it is a plain origin error page the code is "http_error" and the page stays in data, but when the anti-bot classifier recognises it as a wall (Cloudflare and friends serve their challenge with a 403) the code is "anti_bot" and the challenge shell is stripped from data.
  4. Check metadata.statusCode — this is the target site's HTTP status, not fastCRW's.
  5. Check the fastCRW HTTP status separately: 401/402/422/429 all mean different things.

1. Empty or minimal markdown

Symptom: Response is success: true but data.markdown is an empty string or fewer than 100 characters. The page renders fine in a browser.

Cause: The page is a JavaScript Single-Page Application (SPA). The HTTP-only fetcher receives the shell HTML before React/Vue/Next.js mounts content into the DOM. No JavaScript executes, so readability sees an empty container.

Fix: Set renderJs: true to force a headless browser render:

# cURL
curl -X POST https://api.fastcrw.com/v1/scrape \
  -H "Authorization: Bearer $CRW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/dashboard",
    "renderJs": true,
    "waitFor": 1500
  }'
# Python — pip install crw
from crw import CrwClient

client = CrwClient(api_key="crw_live_YOUR_KEY")
result = client.scrape(
    "https://app.example.com/dashboard",
    render_js=True,
    wait_for=1500,
)
print(result["markdown"][:300])

Self-hosted: renderJs: true requires a configured CDP renderer (LightPanda or Chrome). If neither is running you will get a warning "JS rendering was requested but no renderer is available" and the engine falls back to HTTP. Check GET /health and GET /metrics/renderer-breakers.

wait_for guidance: Start with 1000–2000 ms for most SPAs. Complex hydration (Next.js App Router, React 18 concurrent mode) may need 3000–5000 ms.


2. Anti-bot block / Cloudflare challenge page

Symptom: Response contains success: false, errorCode: "anti_bot", and the error message names the protection class (e.g., "Blocked by anti-bot (cloudflare): ..." or "Blocked by anti-bot (datadome): ...").

Cause: The target site fingerprints the request as non-browser traffic. Common triggers: missing User-Agent, no Sec-Fetch-* headers, a datacenter IP, or a detectable headless browser.

Not a block: "No usable content could be extracted (...)". That case has its own code, errorCode: "no_usable_content": we fetched the page fine but it held nothing usable: a handful of visible characters, no content elements. That is usually a broken TLS certificate serving an error stub, a parked domain, or a JS shell that never hydrated. The staged anti-bot fixes below will not help it. Check the URL in a browser first; if it renders a real page there, raise waitFor or try renderJs: true. Either way you are not charged for it.

Fix (staged):

  1. Add "stealth": true to route through randomized headers and a pooled real-browser UA.
  2. Switch renderer to "chrome_proxy" to egress through residential IPs (fastcrw.com managed tier only):
# cURL — step 1: stealth headers
curl -X POST https://api.fastcrw.com/v1/scrape \
  -H "Authorization: Bearer $CRW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.example-protected.com/product/123",
    "renderJs": true,
    "stealth": true
  }'
# cURL — step 2: residential proxy Chrome tier
curl -X POST https://api.fastcrw.com/v1/scrape \
  -H "Authorization: Bearer $CRW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.example-protected.com/product/123",
    "renderer": "chrome_proxy",
    "stealth": true,
    "country": "us"
  }'
# Python — residential proxy tier
from crw import CrwClient

client = CrwClient(api_key="crw_live_YOUR_KEY")
result = client.scrape(
    "https://www.example-protected.com/product/123",
    renderer="chrome_proxy",
    stealth=True,
    country="us",
)
print(result["markdown"][:300])

Note: renderer: "chrome_proxy" is only available on fastcrw.com managed infrastructure. Self-hosted deployments must supply their own residential proxy pool via proxy or proxy_list and use renderer: "chrome".

Do not retry anti-bot blocks in a tight loop — each failed attempt teaches the WAF more about your traffic pattern.


3. HTTP 422 target_unreachable

Symptom: API returns HTTP 422 with errorCode: "target_unreachable" and an error like "Target unreachable: dns error: failed to lookup address" or "connection refused".

Cause: The fastCRW engine could not establish a TCP connection to the target host. Most common reasons:

  • The hostname does not resolve (typo, private DNS, or the domain is down).
  • The host refuses connections on port 80/443 (firewall, no HTTP server).
  • The URL targets a private/reserved IP that the URL safety validator blocks (e.g., localhost, 10.x.x.x, 192.168.x.x).

Fix:

# Verify DNS resolves before calling the API
nslookup your-target.example.com

# Check the URL is reachable from outside your network
curl -I https://your-target.example.com

# Check the exact error message in the response body
curl -X POST https://api.fastcrw.com/v1/scrape \
  -H "Authorization: Bearer $CRW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-target.example.com"}' | jq '.error'

Error code mapping (from crw-server/src/error.rs):

CrwError::TargetUnreachable      → HTTP 422, error_code: "target_unreachable"
CrwError::ExtractionError        → HTTP 422, error_code: "extraction_error"
CrwError::UnsupportedContentType → HTTP 422, error_code: "unsupported_content_type"

If the target URL is correct and externally reachable, the issue is intermittent — retry with backoff. If it is consistent, the target host has a problem unrelated to fastCRW.


4. HTTP 401 Invalid API key

Symptom: Every request returns HTTP 401 with {"success": false, "error": "Invalid API key"} or {"success": false, "error": "Missing Authorization header"}.

Cause: The Authorization header is missing, uses the wrong format, or the key value does not match any configured key.

The server checks (from crw-server/src/middleware.rs):

  1. The header must be present and start with exactly "Bearer " (capital B, space after).
  2. The token after Bearer must match a key in [auth].api_keys (constant-time comparison).
  3. If [auth].api_keys is empty, all requests pass — no auth required.

Fix:

# Correct header format
curl -X POST https://api.fastcrw.com/v1/scrape \
  -H "Authorization: Bearer crw_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}'

# Common mistakes that return 401:
# -H "Authorization: crw_live_YOUR_KEY"        ← missing "Bearer "
# -H "Authorization: bearer crw_live_YOUR_KEY" ← lowercase "bearer"
# -H "Authorization: Basic ..."               ← wrong scheme
from crw import CrwClient

# Key is passed in the constructor — the SDK sets the header correctly
client = CrwClient(api_key="crw_live_YOUR_KEY")

Self-hosted: If you did not set [auth].api_keys in your config, the API is open to all. If you set keys, pass one of them verbatim as the Bearer token.


5. HTTP 429 rate limit vs. HTTP 402 out of credits

Symptom: Requests return HTTP 429 or HTTP 402. They are different problems with different fixes.

How to tell them apart:

Condition Status errorCode Has Retry-After header? Fix
Request-rate or concurrency limit 429 none Yes (fastcrw.com); absent on self-hosted Respect Retry-After; use exponential backoff on self-hosted
Out of credits 402 insufficient_credits No Top up, upgrade or enable auto-recharge
Out of credits, legacy /v2 surface only 429 none (X-FASTCRW-Credits-Available header present) No Same as above

Rate limit (RPM):

# Honor the Retry-After header (fastcrw.com cloud)
RETRY_AFTER=$(curl -sI -X POST https://api.fastcrw.com/v1/scrape \
  -H "Authorization: Bearer $CRW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com"}' | grep -i retry-after | awk '{print $2}')
echo "Retry after: $RETRY_AFTER seconds"
import time, requests

def scrape_with_retry(url, api_key, max_retries=5):
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    for attempt in range(max_retries):
        resp = requests.post(
            "https://api.fastcrw.com/v1/scrape",
            headers=headers,
            json={"url": url},
        )
        if resp.status_code == 402:
            body = resp.json()
            # insufficient_credits or llm_not_available: a retry cannot succeed.
            raise RuntimeError(f"{body.get('errorCode')}: {body.get('error')}")
        if resp.status_code == 429:
            # Rate or concurrency limit: back off
            wait = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        resp.raise_for_status()
        return resp.json()
    raise RuntimeError("Max retries exceeded")

result = scrape_with_retry("https://example.com", "crw_live_YOUR_KEY")

Out of credits: Retrying on a timer does not help, because the balance does not replenish on its own. Go to fastcrw.com/dashboard to top up, upgrade or enable auto-recharge.

Self-hosted: 429 from a self-hosted instance is always the RPM rate limiter (rate_limit_rps in config). There is no credit system on self-hosted. No Retry-After header is emitted; use client-side exponential backoff.


6. Search returns empty results

Symptom: POST /v1/search returns HTTP 503 with errorCode: "search_disabled", or it returns success: true but data.results is an empty array [].

Two separate problems:

6a. errorCode: "search_disabled" (503)

Cause: The engine has no search backend configured. The search route requires [search].search_backend_url in config or the CRW_SEARCH__SEARCH_BACKEND_URL environment variable.

Fix (self-hosted):

# Quickest self-hosted setup — boot the bundled search sidecar
crw setup --local
# Or point at a backend you already run
# config.toml
[search]
search_backend_url = "http://localhost:8080"
# Or via environment variable
export CRW_SEARCH__SEARCH_BACKEND_URL=http://localhost:8080
# The bundled docker-compose.yml already wires CRW to the search sidecar
docker compose up -d

fastcrw.com cloud: search_disabled never occurs on the managed SaaS tier. crw_search is always available.

6b. Empty results array (no error)

Cause: The search backend returned zero results — usually because no search engines are enabled on it, the instance is rate-limited by upstream search providers, or the query matched no results.

Diagnosis:

# Hit the backend directly to check it is working
curl "http://localhost:8080/?q=test+query&format=json" | jq '.results | length'

# Check which engines the backend has enabled
curl "http://localhost:8080/config" | jq '.engines[] | select(.enabled == true) | .name'

Fix: Enable engines in the backend's settings.yml, or use the bundled Docker image which comes pre-configured.


7. MCP server not appearing in AI client

Symptom: After adding the MCP config, the tool list in Claude Code / Claude Desktop / Cursor / Windsurf does not show crw_scrape, crw_crawl, or the other CRW tools.

Common causes and fixes:

7a. The client was not restarted

MCP servers are registered at client start. Add the config entry then fully restart the application (not just reload the window).

7b. npx is not in PATH when the client launches

GUI applications on macOS may not inherit your shell's PATH. The binary is not found and the MCP process silently fails to start.

// Workaround: use the absolute path to npx
{
  "mcpServers": {
    "crw": {
      "command": "/usr/local/bin/npx",
      "args": ["-y", "crw-mcp"]
    }
  }
}

Find your npx path with which npx in a terminal.

7c. Wrong config file location

Client Config file
Claude Code claude mcp add crw -- npx -y crw-mcp (CLI manages the file) or ~/.claude/mcp.json
Claude Desktop ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
Cursor .cursor/mcp.json in project root, or ~/.cursor/mcp.json (global)
Windsurf ~/.codeium/windsurf/mcp_config.json

7d. crw_search is missing but other tools appear (embedded mode)

crw_search is only advertised when a search backend is configured. In embedded mode with no backend, the tool is intentionally hidden. To get crw_search, either:

  • Switch to fastcrw.com cloud mode (CRW_API_URL + CRW_API_KEY), or
  • Run crw setup --local to boot a local backend, or set CRW_SEARCH__SEARCH_BACKEND_URL to one you already run.

7e. Diagnosing with the JSON-RPC wire

The MCP server communicates over stdio JSON-RPC. You can test it directly:

# List available tools (paste and press Enter, then Ctrl-D)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | npx -y crw-mcp 2>/dev/null

If this produces a JSON response with a tools array, the binary works and the problem is in the client config.


8. Credit exhausted: 402

Symptom: The fastcrw.com SaaS returns HTTP 402 with errorCode: "insufficient_credits" (or, on the legacy /v2 surface only, a 429 carrying the X-FASTCRW-Credits-Available header).

How the SaaS uses these codes:

HTTP status errorCode When
402 insufficient_credits The credit balance cannot cover the request. X-FASTCRW-Stop-Reason is added when an enabled auto-recharge could not run
402 llm_not_available A managed-LLM feature was requested on the FREE plan
429 none Legacy /v2 surface only (root /v2, /firecrawl/v2, compat.fastcrw.com): the credit balance cannot cover the request

Note for self-hosted users: No credit 402 or credit 429 exists on self-hosted. The only 429 on self-hosted is the RPM rate limiter (rate_limited error code). Self-hosted has no billing system.

Fix:

# Check your balance (fastcrw.com SaaS)
curl https://fastcrw.com/api/v1/account/balance \
  -H "Authorization: Bearer $CRW_API_KEY" | jq '{balance, plan}'
# Python — check balance before a large crawl
import requests

resp = requests.get(
    "https://fastcrw.com/api/v1/account/balance",
    headers={"Authorization": f"Bearer {api_key}"},
)
data = resp.json()
print(f"Balance: {data['balance']} credits")
if data["balance"] < 100:
    print("Warning: low balance — top up at fastcrw.com/dashboard")

Do not retry an out-of-credit 402 (or a legacy /v2 credit 429) on a timer. The balance is exhausted and retrying consumes no credits (the request is rejected before any work starts), but it does waste time and may look like abuse. Replenish the balance first.


Error code reference

errorCode HTTP Source
invalid_request 400 Bad JSON, bad URL, missing required field
invalid_url — Reserved — not emitted in practice; invalid URLs are returned as invalid_request (HTTP 400) by all server routes
target_unreachable 422 DNS failure, connection refused, host down
extraction_error 422 LLM extraction failed or CSS/XPath selector invalid
unsupported_content_type 422 The origin answered with a body that is neither a web page nor a PDF (an office document, an image, an archive). Not retryable: no renderer turns one into a page
http_error 502, or 200 with success: false The origin answered with a >= 400 status and what came back is its error page. The body is kept in data so you can read the error page and metadata.statusCode. A large page served under an error status is still returned as a success: some sites answer 403/404 while serving the real content. If the page is also recognised as an anti-bot wall, anti_bot wins instead and the body is cleared. In the default auto mode, a URL whose path names a PDF and gets a 401, 403, 429 or 503 is http_error with success: false and the body kept, whatever the page's size, since no browser tier renders a PDF and nothing could have done better; unless the page is also recognised as an anti-bot wall (a short 429 page always is), which is answered as anti_bot. None of this applies when the request sets renderJs or asks for a screenshot, pins a renderer, the engine sets render_js_default, or the engine has no browser tier
no_usable_content 200 with success: false The fetch worked and produced nothing you asked for — a parked domain, an un-hydrated JS shell, an error stub, an oversized PDF the decompression-bomb guard refused, or any requested format that came back empty. Not an anti-bot block
timeout 504 Engine or upstream search timed out
rate_limited 429 RPM rate limit (engine-level)
insufficient_credits 402 Cloud only: the credit balance cannot cover the request
llm_not_available 402 Cloud only: managed-LLM feature requested on the FREE plan
search_disabled 503 /v1/search called with no search backend configured
not_found 404 Unknown endpoint or crawl job ID does not exist
anti_bot 200 Anti-bot wall detected and no renderer tier could clear it. In the default auto mode, a generic wall on a URL whose path names a PDF, which no browser tier can render, is answered this way under any 4xx status or any 5xx other than a CDN origin error (520 to 527, which is target_unreachable). Always success: false, and the page body is cleared. Detect via errorCode: "anti_bot", never via the HTTP status: the request reached the target and the target refused it, so this is not a gateway failure and is never billed
renderer_error 500 CDP browser internal error
internal_error 500 Unexpected engine failure

Source: crw-core/src/error.rs (error_code() method), crw-server/src/error.rs (HTTP status mapping), and crw-server/src/routes/scrape.rs (the http_error / no_usable_content / anti_bot split).