fix(v2): match Firecrawl's success semantics, final URL and error envelope - #561
Open
behramcelen wants to merge 4 commits into
Open
behramcelen wants to merge 4 commits into
behramcelen wants to merge 4 commits into
Conversation
…elope
A target that answers 404/403/429/500 is not a failed scrape. Firecrawl
returns it as `success: true` with the code in `metadata.statusCode`; we
returned `success: false` — and did it inconsistently, because the old gate
(`ScrapeData::http_error`) only fires under `ERROR_PAGE_MAX_TEXT`, so the same
site failed on a terse 404 and succeeded on a chatty one.
The rule is `scrapeURL/index.ts`:
const isGoodStatusCode = (s >= 200 && s < 300) || s === 304;
const hasRequiredOutput = isParsedImage || isLongEnough || !isGoodStatusCode;
`!isGoodStatusCode` is an OR term, so a bad status is *sufficient* to accept
the result, and `controllers/v2/scrape.ts` then never reads statusCode at all.
In Firecrawl `success:false` means their infrastructure failed — DNS, timeout,
every engine dead — never "the target said 404".
Six divergences closed on `/v2` only. `/v1` is untouched: `http_error()` is
still what the native surface, crawl and batch consult.
1. 4xx/5xx target -> `success:true` + `metadata.statusCode` + `metadata.error`
(the bare reason phrase, "Not Found", exactly as captured).
2. `metadata.url` was aliased to `sourceURL`, so a v2 caller could not see
where a redirect landed. `FetchResult.final_url` already existed and was
already computed; it is now carried on `PageMetadata` and read here.
3. `metadata.error` added.
4. The error envelope emitted `errorCode` only. Both official SDKs read `code`,
so every one of our error codes was invisible to their error handling. We
now emit both — `code` in Firecrawl's taxonomy, `errorCode` kept because the
SaaS resolves it for `RequestLog` (`upstreamErrorCode`).
5. Error statuses: DNS 422 -> 200, timeout 504 -> 408, binary 422 -> 500.
A DNS failure returning 200 is an explicit branch in their controller.
6. A 200 with an empty body was `200 {success:false}`; it is now
`500 SCRAPE_ALL_ENGINES_FAILED`. Empty only fails on a GOOD status, because
`isLongEnough` and `!isGoodStatusCode` are alternatives — so 404+empty is a
success and 200+empty is not.
Deliberately NOT matched: a vendor wall served with HTTP 200 still fails for
us. Firecrawl hands back the Cloudflare challenge shell as the page's content,
and we have already paid for that — see `is_cdn_origin_error`, where a dead
origin behind Cloudflare shipped as `success:true` with the CDN's error text as
its markdown, billed, for one customer for months. On a 4xx/5xx the wall does
not fail, which is the case this change is about.
Billing is unaffected. The SaaS refunds an engine 5xx and an envelope
`success:false` alike, and a 4xx/5xx page now bills the same on v1 and v2.
Verified with a new conformance axis, `conformance/mock_parity.py`, driven at
mock.fastcrw.com and diffed against responses captured from the real
api.firecrawl.dev for the identical URLs: 7/23 before, 21/21 after.
The existing golden corpus could not have caught any of this — all 11 fixtures
target pages that return 200 and never redirect, so `sourceURL == url` holds in
every one of them by accident and no error path is exercised at all.
Two rows are recorded and deliberately not matched: the live API 500s on a
3-line valid CSV and on recoverable malformed HTML, both of which we parse.
Matching their contract is not the same as copying their extraction failures.
Contributor
|
All contributors have signed the CLA ✍️ ✅ |
CI caught this: `v2_scrape_screenshot_without_render_js_is_not_rejected_upfront`
expects 422 for a refused port and got the 200 I had just mapped
`TargetUnreachable` to.
The mapping was an overreach. Firecrawl splits a case we merge, both captured
live against api.firecrawl.dev:
hostname does not resolve -> HTTP 200 SCRAPE_DNS_RESOLUTION_ERROR
port refuses -> HTTP 500 SCRAPE_SITE_ERROR
(ERR_TUNNEL_CONNECTION_FAILED)
`CrwError::TargetUnreachable` comes from `reqwest::Error::is_connect()`
(`http_only.rs:909`), true for both, message "error sending request" either
way. Nothing to branch on, so neither of their answers is safe to claim:
the DNS code is wrong whenever the cause was a refused connection, and HTTP 200
for a dead port tells the caller "fine" about a request that failed.
Keeps the existing 422 and reports `code: SCRAPE_SITE_ERROR`, the "URL failed
to load" family, which is true in both cases. The new test asserts we never
answer 200 here. Closing the split properly needs resolver-failure detection in
the renderer's error chain, which touches /v1 as well; written up in
FIRECRAWL-DIFF.md §4b.
Collaborator
Author
|
I have read the CLA Document and I hereby sign the CLA |
…t earning its place Self-review pass. Four things, one of which was a real defect. The error tests did not test. `envelope()` rebuilt the response body from `firecrawl_code` instead of reading what `into_response` produced, so deleting the `body.code = ...` line left all six passing. They now drive the real `into_response` and read the real body; mutation-checked by removing that line, which now fails four of them. The handler re-derived the verdict three times: `matches!` for NothingUsable, a second `matches!` for Blocked, a `match` on that bool, then an `if` on the same bool again to decide `clear_body()`. One match on the verdict, all three variants named, and `clear_body()` in the arm that decided it. 45 lines -> 31. `CAPABILITY_GAP` was a dead set: defined, then referenced only inside comment strings. `report_only` already does the work. Trimmed the two comments that restated captured evidence already recorded in FIRECRAWL-DIFF.md. The added Rust is now 518 lines at 40% comments, against a crw-server baseline of 15% — still high, but this change is entirely about why two engines disagree, and the repo's own decision-point comments (`http_error`, `is_cdn_origin_error`) are just as dense. No behaviour change: parity still 21/21.
…rowsers
The earlier parity runs used `renderer.mode = "none"`, so nothing exercised the
browser tiers. Running the same corpus against a full local stack surfaced two
things worth writing down. Neither is fixed here; both are measured.
Thin pages. /js/csr, /js/hydrate and /js/fetch all come back from the live
Firecrawl API as success:true with the fixture sentinel in the markdown (43, 23
and 17 chars). We answer 500 SCRAPE_ALL_ENGINES_FAILED: chrome renders them
correctly and `structural_failure` then discards the render as "minimal_text on
small page". Firecrawl's bar is `trim().length > 0`; ours is a heuristic that
exists to catch JS shells, and loosening it is how a Cloudflare interstitial
gets billed as content. A trade, not an oversight — but the whole /js/* group is
affected, and a legitimately short page fails here and succeeds there. Added as
three report-only rows with captured fixtures.
/v2/scrape and /v2/crawl now disagree about the same URL. This change never
touched `state.rs`, so crawl still turns `http_error()` into a block:
/v2/scrape /status/404 -> success:true, 1 credit, HTTP tier only
/v2/crawl /status/404 -> blocked:1, 0 credits, chrome + lightpanda
That is the "not billed on one surface and refunded on the other" split one
level down, and the SaaS reads it through page-billing.ts, so it costs real
money. `state.rs` is shared with /v1/crawl, which is why it stayed out of a
v2-scoped change; it should be next.
Also noted: with browsers configured /html/empty answers 408 SCRAPE_TIMEOUT
rather than 500 — an empty page escalates through chrome (12s budget) and
lightpanda before anything concludes. Same end state, reached expensively.
behramcelen
force-pushed
the
feat/v2-firecrawl-parity
branch
from
September 19, 2026 13:17
c600f5e to
b64d740
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A target that answers 404 / 403 / 429 / 500 is not a failed scrape. Firecrawl returns it as
success: truewith the code inmetadata.statusCode. We returnedsuccess: false— and did it inconsistently, because the old gate (ScrapeData::http_error) only fires underERROR_PAGE_MAX_TEXT(200 bytes), so the same site failed on a terse 404 and succeeded on a chatty one.The rule
scrapeURL/index.ts, "Success factors":!isGoodStatusCodeis an OR term, so a bad status is sufficient to accept the result — even with an empty body.controllers/v2/scrape.tsthen never readsmetadata.statusCodeat all.Captured from
api.firecrawl.devfor every one of 401/403/404/429/500/503:What changed —
/v2onlysuccess:falsesuccess:true+metadata.statusCode+metadata.errormetadata.urlaliased tosourceURLmetadata.errorerrorCodeonlycode(Firecrawl's taxonomy) anderrorCode(ours)200 {success:false}500 SCRAPE_ALL_ENGINES_FAILED#2 is a plain bug.
adapters.rsseturl: m.source_url.clone()— the same string assourceURL— so a v2 caller had no way to learn where a redirect landed.FetchResult.final_urlalready existed and was already computed; it just never reached the wire.#4 matters more than it looks. Both official SDKs read
code. Every error code we emit today is invisible tofirecrawl-py/firecrawl-jserror handling. We now emit both keys —errorCodestays because the SaaS resolves it forRequestLog(upstreamErrorCodeinapi-handler.ts)./v1is untouched.http_error()is still what the native surface, crawl and batch consult; only this compat surface stopped asking.Deliberately NOT matched
A vendor wall served with HTTP 200 still fails for us. Firecrawl hands back the Cloudflare challenge shell as the page's content, and we have already paid for that — see
is_cdn_origin_errorincrw-crawl:On a 4xx/5xx the wall does not fail — the status already explains the page, which is the case this PR is about. Pinned by
vendor_wall_on_a_200_still_failsandvendor_wall_on_a_403_is_a_document.Two more rows are recorded and not matched: the live API 500s on a 3-line valid CSV (
SCRAPE_RETRY_LIMIT (document_antibot)) and on recoverable malformed HTML (SCRAPE_ALL_ENGINES_FAILED), both of which we parse fine. Matching their contract is not the same as copying their extraction failures.Billing
Unaffected. The SaaS refunds an engine 5xx and an envelope
success:falsealike (api-handler.ts:904and:954), so a 4xx/5xx page still costs the same, and now costs the same on v1 and v2.Two consequences worth a decision, neither blocking:
RequestLog.outcomefor a 404 moves fromupstream_failedtosuccess, so any error-rate metric shifts meaning; and the once-per-user onboarding mail flips from "first-error" to "first-crawl-success" for a user whose first call is a 404.Verification
New conformance axis,
conformance/mock_parity.py, driven at mock.fastcrw.com and diffed against responses captured from the realapi.firecrawl.devfor the identical URLs:The existing golden corpus could not have caught any of this. All 11 fixtures target pages that return 200 and never redirect, so
sourceURL == urlholds in every one of them by accident and no error path is exercised at all. That is exactly how #2 shipped.Plus 11 unit tests (
v2_verdict,routes::v2::error) that run in CI with no network. The error tests drive the realinto_responseand read the real body — mutation-checked by deleting thebody.codeline, which fails four of them.Two gaps found against a local stack WITH browsers, not fixed here
Recorded in
conformance/FIRECRAWL-DIFF.md§4b/§5b, both measured:Thin pages.
/js/csr,/js/hydrateand/js/fetchall come back from Firecrawl assuccess:truewith the fixture sentinel in the markdown (43, 23 and 17 chars). We answer 500 — chrome renders them correctly andstructural_failurethen discards the render asminimal_text on small page. Firecrawl's bar istrim().length > 0; ours is a heuristic built to catch JS shells, and loosening it is how a Cloudflare interstitial gets billed as content. A trade, not an oversight — but the whole/js/*group is affected./v2/scrapeand/v2/crawlnow disagree about the same URL. This change never touchedstate.rs, so crawl still turnshttp_error()into ablock:That is the "not billed on one surface and refunded on the other" split one level down, and the SaaS reads it through
page-billing.ts, so it costs real money.state.rsis shared with/v1/crawl, which is why it stayed out of a v2-scoped change. It should be the next change.Test status
cargo test --workspace --no-fail-fastmatchesorigin/main: 8 pre-existing PDF-extraction failures, plushttp_only::…invalid_user_agentwhich appears only under full parallel load and passes in isolation. Nothing incrw-rendereris touched by this PR.Follow-ups
/v2can answer Firecrawl's 200-DNS vs 500-site split (§4b)createdAt/completedAt/duration/warningPath<Uuid>rejection → plain-text 400, unparseable by any SDK/v2/map,/v2/search,/v2/extracterror paths undiffed on both sides