Changelog
What changed, per package, newest first.
Written from the changesets in each pull request, so it cannot drift from what shipped. Versions move per package; there is no single release train. Last release: 2026-08-29.
Recent
The last 12 releases across every package, newest first.
- Gateway 0.13.0Minor
- #238
9860659wait_for=<css selector>tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It impliesrender=true, and it narrows the chain to providers that can express it: ScrapingBee'swait_forand Scrapfly'swait_for_selectorwere verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot —x-unblock-expectis accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait. - #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing.
- #238
- Gateway 0.12.0Minor
- #235
b694712A Scrapfly response over 5MB no longer arrives as a URL markedOK. Scrapfly offloads bodies above that size to an object store and returns a pointer;parse()read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is nowPROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
- #235
- CLI 0.4.8Patch
- #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing.
- #237
- CLI 0.4.7Patch
Dependency updates only.
- Adapters 0.9.0Minor
- #238
9860659wait_for=<css selector>tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It impliesrender=true, and it narrows the chain to providers that can express it: ScrapingBee'swait_forand Scrapfly'swait_for_selectorwere verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot —x-unblock-expectis accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait. - #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing.
- #238
- Adapters 0.8.0Minor
- #235
b694712A Scrapfly response over 5MB no longer arrives as a URL markedOK. Scrapfly offloads bodies above that size to an object store and returns a pointer;parse()read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is nowPROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
- #235
- Shared 0.11.0Minor
- #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing. - #238
9860659wait_for=<css selector>tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It impliesrender=true, and it narrows the chain to providers that can express it: ScrapingBee'swait_forand Scrapfly'swait_for_selectorwere verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot —x-unblock-expectis accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
- #237
- Shared 0.10.0Minor
- #235
b694712A Scrapfly response over 5MB no longer arrives as a URL markedOK. Scrapfly offloads bodies above that size to an object store and returns a pointer;parse()read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is nowPROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
- #235
- Gateway 0.11.0Minor
- Gateway 0.10.3Patch
Dependency updates only.
- CLI 0.4.6Patch
Dependency updates only.
- CLI 0.4.5Patch
Dependency updates only.
Gateway0.13.0
The proxy itself. This is what you run.
- 0.13.0Minor
- #238
9860659wait_for=<css selector>tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It impliesrender=true, and it narrows the chain to providers that can express it: ScrapingBee'swait_forand Scrapfly'swait_for_selectorwere verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot —x-unblock-expectis accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait. - #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing.
- #238
- 0.12.0Minor
- #235
b694712A Scrapfly response over 5MB no longer arrives as a URL markedOK. Scrapfly offloads bodies above that size to an object store and returns a pointer;parse()read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is nowPROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
- #235
- 0.11.0Minor
- 0.10.3Patch
Dependency updates only.
- 0.10.2Patch
Dependency updates only.
- 0.10.1Patch
Dependency updates only.
- 0.10.0Minor
- #194
3302c1cA block no longer cools every premium tier.cd:blknow carries the tier the request asked for, so a plain request that gets blocked stops suppressing the stealth retry — the escalation most likely to work, and the reason the tier exists. The implication still runs downward: a block at stealth cools residential and plain too, because they are strictly weaker against the same defence./health/cooldownsreports the tier. Existing armed keys are in the old format and are ignored rather than migrated; they expire on their own within the cap. - #193
bd8942bA cooldown no longer truncates the chain. When every provider the walk tried has failed, one cooled provider is attempted before giving up — the same single per-domain slot the existing floor uses. Previously a chain whose best provider happened to be cooling could fail on all the others and return a provider fault having never tried the one that would have worked.
- #194
- 0.9.0Minor
- #188
7e77a6dA challenge page served with a target 5xx is now recognised as a block instead of being reported asTARGET_ERROR. The detector only ever examinedOKresponses, so Cloudflare's under-attack mode — which answers 503 — came back as "the site is broken" when the truth was that the site's defences refused every provider. It also armed no cooldown, so every later request re-bought the same failures. A claimed success that returned zero bytes is no longer billed as a successful scrape. - #182
7d44744The request-body cap now stops the read instead of measuring it afterwards.c.req.text()resolved only once the whole body was in memory, so an oversized POST was refused having already paid the allocation the cap exists to prevent. A client that disconnects now aborts the in-flight provider request instead of leaving the chain walking every provider for the full deadline. It is reported as its own outcome, never asPROVIDER_TIMEOUT— blaming a healthy provider for a caller hanging up would cool it and feed the health statistic a failure nobody caused. - #170
84e83ceCapabilities can now describe a combination a provider refuses even though it offers each part alone. ScraperAPI's sessions and premium proxies are mutually exclusive by their own documentation, and the router used to send requests asking for both. Declared as data rather than a predicate, soproxlane providersprints it. - #179
8c05ff9Scrapfly's stealth tier is priced at the residential figures, which is what it costs:translate()sends the residential proxy pool for every tier abovenone, so a stealth request is a residential request. It was published at the datacenter base — 1x against a real 25x on the comparison page. The router now reads the cost matrix as a capability claim. A null cell means the provider does not sell that combination, and ScrapingBee's stealth-without-rendering is one — it was being routed there and paid for. - #171
b0bf5b4Every attempt now records the provider's reported cost, our own table's prediction for the same request shape, and which of the two the figure came from. Responses carryX-Cost-Source:reportedwhen the provider told us,estimatedwhen we worked it out. This is what makes a wrong cost table findable from live traffic instead of by re-reading a vendor's pricing page. - #181
bb6348dA provider's ownRetry-Afternow reaches the caller.ParsedResult.retryAfterMshad been in the contract since it landed and the chain already armed cooldowns from it, but no adapter ever set it — so a provider that capped us and said exactly how long to wait had that answer discarded, the cooldown drew a 30s jittered guess, and the caller got a bare 429. Two chain fixes: an answered request whose next candidate lost its probe claim kept only the outcome name, dropping the provider, the body and the detect rule. And a throwing health store could make the chain re-attempt — and re-pay for — a provider it had already tried.
- #188
- 0.8.0Minor
- #142
d8f0661The global deadline defaults to 120s, whichoperations.mddecided some time ago and the gateway never picked up. At 90s a three-hop chain gave the terminal provider 38s of its 70s cap, because the budget reserves time for every hop still to come. The hop that exists to rescue a failing request was the one being cut short. Callers still ask for less viatimeoutand never for more. The operator's deadline is the ceiling, because it bounds how long one request holds an in-flight slot.
- #142
- 0.7.1Patch
- #137
8acb6fbX-Chainis omitted rather than sent empty when no provider was tried. A request refused before the chain starts has no attempts, so 0.7.0 emitted a bareX-Chain:—X-Provider-Usedalready follows "omitted, never empty" for the same reason. The homepage transcript matches the gateway again: the boot banner listed the providers in the wrong order and carried no version, and the response was missingx-chainandx-cost-unit. The order now derives from the capability table on the same page. The social card is redrawn from an SVG source, having spent six days showing a retired wordmark and claiming three providers.
- #137
- 0.7.0Minor
- #136
89f92baA block cooldown that keeps failing now backs off to 6 hours instead of re-arming at a flat 15 minutes forever. A (provider, domain) that had refused a hundred times running cost 96 paid probes a day, 288 for a domain three providers block, all of it re-buying evidence already held. Account cooldowns are unchanged: a rate limit resets on its own and is private to one org. Because a fully-blocked domain would otherwise go dark for the whole backoff, an all-cooling domain now gets one forced attempt, rate-limited per domain and reported asX-Provider-Health: cooling-forced. - #134
e103c8fX-Chainnames every attempt asprovider:outcome, in order, and the request log carries it too.X-Provider-Usednames the winner, so a request that failed over and then succeeded came back as a clean 200 that hid the provider which had just cost 22 seconds. Found on the live gateway: four requests timed out at one provider, all four failed over and returned 200, and identifying the culprit needed/health/cooldowns, which expires.
- #136
- 0.6.0Minor
- #125
fc0b684Returning a body byte for byte is now a declared capability, andbinary=truea request parameter. An image request used to return 200 with a corrupted body; it now routes only to providers that can carry bytes, or answersNO_PROVIDER_AVAILABLE. Three of the four launch providers carry binary. ScraperAPI does not — it decodes bodies as UTF-8, and its own API says so:binary_target=trueanswers 400, "The file type you are trying to scrape is not supported." - #123
ae17465A cost number now carries its unit, and units are never summed together. Three launch providers sell credits and Bright Data bills cents, soX-Cost-Estimatewas adding one provider credit to fifteen hundredths of a cent and reporting the result as a quantity.CostTable.unitis required, the gateway emitsX-Cost-Unitbeside the figure, and a chain that spent in two units reportsmixedrather than an invented total.
- #125
- 0.5.0Minor
- #120
b6e7891One NDJSON line per/v1request, to stdout. The gateway logged nothing at all, so the moment it was reachable by anyone there was no way to answer who probed it, which domains were scraped, which provider served them, or what the outcome mix looked like. Records the target's host rather than the URL, because query strings carry credentials.PROXLANE_LOG=offto silence it; `proxlane
- #120
- 0.4.0Minor
- #113
7b4a27aGET /healthreports the running version, so a deploy can be verified. Publishing an image is not deploying it — an orchestrator keeps serving the digest it started with — and there was no way to ask what was live. - #107
e0d94b2Atimeoutquery parameter sets the deadline for one request, capped at the server's own.integrations.mdhas promised it since the budget arithmetic was written and nothing read it. Every error now carriesX-Outcome-Class,X-Attempts: 0andX-Cost-Estimate: 0.000000. Four paths shipped without them, including a missingurl. A forced provider that does not exist now says so and names the ones that do, rather than "no providers configured". - #105
1e3ea1fRetry the last provider in the chain once before giving up, onPROVIDER_ERRORandPROVIDER_TIMEOUTonly. SetPROXLANE_TERMINAL_RETRIESto change it, 0 to switch it off. Everywhere else in the chain, failover is still the retry. - #101
401548fBump every GitHub Action to a major that targets Node 24. GitHub was already force-running the Node 20 ones and warning on each release. Includeschangesets/actionv2, whose breaking change moves the custom token from an environment variable to agithub-tokeninput — left alone, the release PR would have silently reverted to the default token and arrived with no checks. - #102
79128abPinchangesets/actionback to v1. Its v2 requires Changesets CLI v3 and refuses to run against the v2 CLI this repo pins, and it renames every input. Moving it is a release-path migration behind a CLI major, not a version bump. v1 already targets Node 24, so it was never part of the deprecation. - #111
258e5fcproxlane outcomesnow says what to do about an outcome, not only what it means: anaction, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally —failover: trueon a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link toproxlane.dev/docs/outcomes, which is live. They pointed at GitHub becausedocs.proxlane.devhas no DNS record; it was the subdomain that never existed, not the docs. - #114
f40b2c4The release now tags the gateway image with the version it actually released. It read the working tree, whichchangeset versionhad already bumped, so every push to main published an image tagged with the pending version —0.3.3and0.4.0exist on ghcr while main has been0.3.2throughout — and re-pointed that tag at a new digest each time.
- #113
- 0.3.2Patch
- Fix the image publish. The per-architecture digest was written to a filename containing a colon, which
upload-artifactrejects, so both builds succeeded and then failed at the upload step. Native arm64 itself works: it produced a digest in under a minute, against the hour the emulated build ran without finishing. - Publish the image on a gateway release. The image job was gated on npm having published something, but the gateway is
private: trueand never publishes, so every gateway-only release skipped it and ghcr fell two minor versions behind. The image is also now tagged with the gateway's own version rather than the CLI's.
- Fix the image publish. The per-architecture digest was written to a filename containing a colon, which
- 0.3.1Patch
- Publish the container image for arm64 on a native arm64 runner instead of emulating it. The previous multi-arch build ran under QEMU and did not finish, which left the published image two minor versions behind npm. Self-hosters on Pi and Ampere are the reason arm64 ships at all, so the image being stale mattered most to exactly them.
- 0.3.0Minor
- Enforce the in-flight ceiling. Past
PROXLANE_MAX_INFLIGHTconcurrent/v1requests the gateway answers 429GATEWAY_BUSYwithRetry-Afterand sheds, rather than queueing — a queued scrape burns its own deadline waiting and the queue is memory the ceiling bounds./healthis never shed. The variable was documented since the scaffold and read by nothing.GATEWAY_BUSYis a new outcome, classgateway. Deliberately notRATE_LIMITED, which is classprovider, writes an account cooldown and fails over — all three wrong when the gateway itself is full.OutcomeClassdoes not grow, so a caller branching on the class is unaffected. - Check at boot that the gateway fits in the memory it has been given. It reads the container's limit from cgroup v2 then v1, and refuses to start when
maxInflight * bodyCap * 2.5exceeds it, printing both numbers and the ceiling that would fit. It never falls back toos.totalmem(), which reports the host's memory inside a limited container. When no limit is readable, which is normal off a container, it prints the arithmetic and starts, sopnpm devstill works.PROXLANE_MEMORY_LIMIT_MBdeclares a limit where there is none and overrides one where there is.proxlane doctorreports the same budget from the same code..env.exampleanddocs/self-hosting.mddescribed this check for months before it existed; both now describe what it does. - Emit
Server-Timing: gw;dur=…, up;dur=…, total;dur=…on every/v1response.gwis gateway-internal time — the numberoperations.mdsection 1 gates p95 on, and the one a user needs when asking whether the gateway or the provider was slow. Split by subtraction, so a segment nobody instrumented lands ingwwhere it is visible rather than going unmeasured. Each attempt now recordsupstreamMs, which is wall time inside the provider call and unlikelatencyMsis set even when the hop times out. - Fix
pnpm devfor the gateway. It rannode --watch src/index.ts, which could never work: application source imports siblings as./app.js, and Node's type stripping does not rewrite that to.ts, so the process died on its first import. It builds and runs the output now.repo:checkassertion 27 fails on any script that runs bare node againstsrc/**TypeScript. - Build the load harness
operations.mdsection 9 asks for: a local mock provider that returns slow responses, 429s, huge bodies and challenge pages on demand, the real gateway wired to it over a real socket, and a k6 soak that gates on p95 ofServer-Timing: gw;dur=, RSS slope from minute 10, and the concurrency ceiling actually shedding.pnpm k6:soakis implemented; 22 of 25 commands are now real. The gateway gains./appand./transportexport paths so the harness can build the real app from the shipped artifact rather than importing source. - Cover the two things nothing tested. The real HTTP transport now has an e2e against a deliberately hostile server, including a regression test for the measured bug where a body trickling in after the headers ran six times its budget. And
build-dockernow boots the image it builds, asserting the gateway refuses to start without a key, serves/health, answers/v1with the taxonomy, and prints its banner.selfhost:smokeruns weekly.
- Enforce the in-flight ceiling. Past
- 0.2.1Patch
Dependency updates only.
- 0.2.0Minor
- Accept the gateway key as
Authorization: Bearer <key>alongside?api_key=, and route POST on/v1with the request body capped bymaxBodyBytes. The query parameter stays: it is the drop-in migration surface. - Breaking: every non-2xx now returns one envelope —
{requestId, error: {code, class, message, docs}, attempts?}— instead of{error, message}for auth and validation and{outcome, class, attempts}for a failed scrape. Response headers are unchanged. - Add
OutcomeClass, a closed six-member classification alongside the openOutcomeunion, and send it asX-Outcome-Classand in the JSON error body. Branch on the class: it does not grow, so adding an outcome no longer breaks callers. - Every response now carries
X-Request-Idand a matchingrequestIdin the JSON body, including 401s and validation errors. A caller's ownX-Request-Idis echoed when it is safe to. Addsuuidv7, monotonic and clock-regression-safe, which will also berequests.id.
- Accept the gateway key as
- 0.1.0Minor
- Cooldowns are implemented: two namespaces (
cd:blkby domain,cd:acctby org), exponential backoff with full jitter and a 15-minute cap, and half-open expiry where the single post-expiry probe must be claimed atomically. The gateway skips cooled providers before dividing the deadline, and returnsRetry-Afterwhen every capable provider is cooling. - The provider order is now an explicit, operator-overridable list (
PROXLANE_PROVIDER_ORDER) rather thanObject.keys(REGISTRY).sort()— which was alphabetical, chosen by nobody, and decided which provider got paid first on every request. - Health tracking now defaults to off: its calibration assumes independent failures, and a two-regime provider with the same mean failure rate spends over 90% of its time demoted in simulation. Set
PROXLANE_HEALTH=onto enable it. Every published figure has been regenerated from the simulation, andrepo:checkassertion 21 now fails when the prose drifts from the measurement. - The gateway now routes on provider health: the chain is re-ranked by state, demoted providers are dropped, the least-bad one is forced rather than refusing, and the result is reported on
X-Provider-HealthandGET /health/providers. - A cooldown now waits as long as the target's own
Retry-Afterasks, when the provider exposes it — measured: ScrapingBee forwards it asspb-retry-after, Scrapfly exposes it in the envelope, ScraperAPI strips it. Clamped to the 15-minute cap, with the jittered backoff as the fallback. - The background prober lifts demoted providers back into rotation: paced by the documented backoff, leased when several replicas share a Valkey, and pointed at the same stable target the canary uses.
GET /health/cooldownsshows the cooldowns actually held, split by namespace, so an operator can see why a provider is being skipped. - A target 429 is now
TARGET_RATE_LIMITEDrather thanTARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban. - Six routing defects found by an independent review panel: a claimed cooldown probe was never released unless the outcome armed or was exactly
OK, a concurrent success deleted the account cooldown another request had just armed, the demoted floor was computed before cooldowns and so could be routed past, the terminal hop's larger timeout went to the least healthy provider, an exhausted chain reported the previous provider's failure, andRetry-Aftercould be0. - Corrects every published health figure that disagreed between files, removes three false citations from
health.ts(including one reintroducing the exact defect that file documents), pins the five constants a mutation sweep found unpinned, and strengthensrepo:checkassertion 21 against the bypasses a verification panel demonstrated. - The probe settlement fix was incomplete: it marked a claim settled whenever any cooldown key was written, so eight of sixteen outcomes stranded a probe when the claimed and written keys were in different namespaces — including a successful probe on an account cooldown, which took a working provider out of service. A lost probe claim now also re-ranks the chain, so the demoted floor can still see a usable fallback.
- Security follow-ups: hostnames in cooldown keys are bounded to 253 characters, a trailing FQDN dot no longer creates a second key for the same site, the gateway key comparison uses
timingSafeEqualrather than a length-short-circuiting loop, andPROXLANE_ORG_IDactually reaches the chain socd:acctis namespaced per deployment. - Fix the default self-host deployment, which built a Redis client for an empty
PROXLANE_VALKEY_URLand returned 500 from/health/providers. Compose now passes every variable the gateway reads, empty is treated as unset, and/health/providersfails open like the routing path. - A gateway shutting down while Valkey is unreachable no longer crashes:
redis.quit()rejects on a broken socket, and each shutdown step is now independent. - Valkey store robustness: the observation buffer is bounded and no longer amplifies load against a struggling store, in-flight batches stay visible to reads, a throwing error reporter can no longer kill the process, the claim script fails open on an unreadable record like the JS side does, and the gateway drains on SIGTERM instead of dropping buffered work.
- Cooldowns are implemented: two namespaces (
CLI0.4.8
The `proxlane` command.
- 0.4.8Patch
- #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing.
- #237
- 0.4.7Patch
Dependency updates only.
- 0.4.6Patch
Dependency updates only.
- 0.4.5Patch
Dependency updates only.
- 0.4.4Patch
- #213
a43b34eProvider keys are trimmed when read from the environment. They were not, and the failure that produced is silent and expensive.Headersnormalises TRAILING whitespace away, so a key ending in a space or a newline works — which is most accidents, and it teaches you whitespace is harmless here. A LEADING space survives:Authorization: Bearer <key>goes out with two spaces, the provider answers 401, and that reaches the caller asAUTH_FAILED— a taxonomy member meaning "your credential was refused", pointing at the key's value when the value is correct and its framing is not. Only one of the four adapters could show it. The other three put the key in a query string, whereURLSearchParamspercent-encodes the space rather than sending it, so exactly one provider looked broken and the rest looked fine — which is the most misleading shape this could have had.proxlane doctornow says when a key had surrounding whitespace, because the gateway trimming it does not stop the same value confusing someone comparing their.envagainst what they pasted.
- #213
- 0.4.3Patch
- #201
b6995ddThe Quickstart now starts a gateway before telling you to call one. "Get started" is the site's primary call to action and it lands here; the page opened by asking the reader to curlhttps://your-gateway/…, a placeholder that resolves to nothing, and only explained how to have a gateway eighty lines further down. It never mentionedlocalhostat all, so the address you would actually call appeared nowhere on the page. Order is now: start it, call it, migrate, move the key out of the query string.proxlane doctorfails when no provider key is set. Each per-key check stays green when absent, because BYOK means you bring the providers you use and flagging the three you do not have trains people to skip the output. Applied to every key, that produced "13 checks, all good" for a gateway that cannot route one request. Zero keys is a different condition from one missing key, and now it has its own check with a fix line.
- #201
- 0.4.2Patch
Dependency updates only.
- 0.4.1Patch
- #170
84e83ceCapabilities can now describe a combination a provider refuses even though it offers each part alone. ScraperAPI's sessions and premium proxies are mutually exclusive by their own documentation, and the router used to send requests asking for both. Declared as data rather than a predicate, soproxlane providersprints it. - #186
4468690Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including@proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
- #170
- 0.4.0Minor
- #152
dc62320CostTablestops beingbase × multipliersand becomes a matrix: one cost per proxy tier and render state, exhaustive,nullwhere the provider does not sell that combination. No provider prices multiplicatively, so every adapter had been writing the closest product it could and leaving a note about it. Scrapfly is additive and was estimated 4.17× too high on residential plus rendering; ScrapingBee's premium tier is 25 credits with rendering, not 10. `proxlane
- #152
- 0.3.3Patch
Dependency updates only.
- 0.3.2Patch
Dependency updates only.
- 0.3.1Patch
- #120
b6e7891One NDJSON line per/v1request, to stdout. The gateway logged nothing at all, so the moment it was reachable by anyone there was no way to answer who probed it, which domains were scraped, which provider served them, or what the outcome mix looked like. Records the target's host rather than the URL, because query strings carry credentials.PROXLANE_LOG=offto silence it; `proxlane
- #120
- 0.3.0Minor
- #111
258e5fcproxlane outcomesnow says what to do about an outcome, not only what it means: anaction, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally —failover: trueon a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link toproxlane.dev/docs/outcomes, which is live. They pointed at GitHub becausedocs.proxlane.devhas no DNS record; it was the subdomain that never existed, not the docs. - #105
1e3ea1fRetry the last provider in the chain once before giving up, onPROVIDER_ERRORandPROVIDER_TIMEOUTonly. SetPROXLANE_TERMINAL_RETRIESto change it, 0 to switch it off. Everywhere else in the chain, failover is still the retry.
- #111
- 0.2.0Minor
- Enforce the in-flight ceiling. Past
PROXLANE_MAX_INFLIGHTconcurrent/v1requests the gateway answers 429GATEWAY_BUSYwithRetry-Afterand sheds, rather than queueing — a queued scrape burns its own deadline waiting and the queue is memory the ceiling bounds./healthis never shed. The variable was documented since the scaffold and read by nothing.GATEWAY_BUSYis a new outcome, classgateway. Deliberately notRATE_LIMITED, which is classprovider, writes an account cooldown and fails over — all three wrong when the gateway itself is full.OutcomeClassdoes not grow, so a caller branching on the class is unaffected. - Check at boot that the gateway fits in the memory it has been given. It reads the container's limit from cgroup v2 then v1, and refuses to start when
maxInflight * bodyCap * 2.5exceeds it, printing both numbers and the ceiling that would fit. It never falls back toos.totalmem(), which reports the host's memory inside a limited container. When no limit is readable, which is normal off a container, it prints the arithmetic and starts, sopnpm devstill works.PROXLANE_MEMORY_LIMIT_MBdeclares a limit where there is none and overrides one where there is.proxlane doctorreports the same budget from the same code..env.exampleanddocs/self-hosting.mddescribed this check for months before it existed; both now describe what it does. - A mark, honest SEO, and no em dashes in shipped copy. The wordmark sets the interchange station as the
oin proxlane, and the standalone mark is three provider lines with a station on the middle one, which is the version that survives 16px in a browser tab. No second typeface: design.md chooses one sans and says the diagram is the display element. Adds canonical, Open Graph and Twitter tags, our own robots.txt, and a sitemap, and points the Worker at proxlane.dev. Without a robots.txt Cloudflare served its own, which was 25 lines of AI content-signal terms nobody here wrote. Removes em dashes from user-facing copy. Five of the six were realproxlane doctoroutput and the exit-code table, so they are fixed at source rather than edited on the page, which would have made a transcript into a mock-up.
- Enforce the in-flight ceiling. Past
- 0.1.2Patch
Dependency updates only.
- 0.1.1Patch
Dependency updates only.
- 0.1.0Minor
proxlane doctornow diagnoses routing state: where it lives, whether an emptyPROXLANE_VALKEY_URLis being read as unset, whether the replica count matches the state backing, which of health and cooldowns are on, and whether a configured Valkey is actually reachable.proxlane --versionnow reports the package's real version. It was hardcoded to0.0.0and printed that from a package published as0.0.1.- A target 429 is now
TARGET_RATE_LIMITEDrather thanTARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban.
Adapters0.9.0
Provider adapters and the capability registry.
- 0.9.0Minor
- #238
9860659wait_for=<css selector>tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It impliesrender=true, and it narrows the chain to providers that can express it: ScrapingBee'swait_forand Scrapfly'swait_for_selectorwere verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot —x-unblock-expectis accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait. - #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing.
- #238
- 0.8.0Minor
- #235
b694712A Scrapfly response over 5MB no longer arrives as a URL markedOK. Scrapfly offloads bodies above that size to an object store and returns a pointer;parse()read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is nowPROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
- #235
- 0.7.5Patch
- #231
1f955e8A fixture shape change now opens an issue instead of reddening a scheduled run nobody watches. The canary has reported that way since it was written;record:diffnever did, and on 2026-08-26 three of four adapters failed there and it went unnoticed for two days. One issue per adapter, with the shape diff in the body, and a comment rather than a duplicate on the next run. Bright Data gains the POST fixture it never had — the only adapter of four declaringpost: truewith no recorded evidence for it. Recorded against the live API; the target echoes the body back, so the fixture proves the body arrived rather than only that the request succeeded. Scrapfly's fixtures are re-recorded. Their request shape genuinely changed whenosstarted being pinned, and the fixtures had not caught up. ScrapingBee's are re-recorded too, though its drift was the test target echoing a different set of request headers rather than anything ScrapingBee did.
- #231
- 0.7.4Patch
- #225
a0d6487The live canary's JavaScript-render check now scrapes a page this project serves, not a third-party scraping-demo site. That target failed twice in one morning on two different providers while answering in half a second from a laptop, and the launch gate counts three consecutive scheduled greens with no way for a manual re-dispatch to repair a red one. The new marker is absent from the served HTML — the page assembles it from two halves at runtime — so a provider returning the unrendered document cannot satisfy the assertion by accident. The old marker was plain text in the source that the page also rendered, so it could. Verified live against all three providers before landing: OK with the marker present on each, at the exact cost the table predicts.
- #225
- 0.7.3Patch
- #217
a9c624eThe live canary retries once when the TARGET failed, and says so loudly.operations.mdsection 9 counts three consecutive scheduled greens, and a manual re-dispatch does not repair a red one. So a third-party page having a bad minute on a Monday morning resets a three-week launch clock and nothing done afterwards fixes it. That happened twice in one morning on 2026-08-25, on two different providers, against a demo site answering in half a second from a laptop.TARGET_ERRORis the one outcome that says the failure was not the provider's, and this canary exists to ask whether the provider still behaves. Only that outcome is retried; everything the provider is blamed for is reported on the first attempt. The three tests expectOK,TARGET_NOT_FOUNDandOK, so a retry cannot paper over an assertion.
- #217
- 0.7.2Patch
- #199
71a0421Scrapfly now pinsosinstead of letting Scrapfly choose. ScraperAPI and ScrapingBee were both pinned to desktop, so the same request fetched a pinned desktop page on two providers and a provider-chosen one on the third — visible to the caller only asX-Provider-Usedchanging, on a gateway whose claim is that failover is invisible. It was always a leak; it got worse when their changelog addedandroid,iphoneandipadto the valuesospicks among, so the unpinned set grew phones without anything changing on our side. The per-adapter test named "sets every parameter explicitly" passed throughout, because the list it iterates is hand-typed and therefore only covers the parameters somebody remembered. The check is now cross-adapter and asserts its own completeness against the registry, so a fifth adapter fails until somebody decides whether its API has a device parameter.
- #199
- 0.7.1Patch
Dependency updates only.
- 0.7.0Minor
- #175
7d5c835Bright Data now returns the target's body for every outcome the taxonomy says carries one. A target 404 came back empty from Bright Data and full from the other three, so what a caller received depended on which provider won the chain. An error code arriving without a message header also fell through to OK —reject_blockwith no message was returned as a successful scrape of a challenge page. The conformance check that exists to catch exactly this only enforced theOKcase. - #170
84e83ceCapabilities can now describe a combination a provider refuses even though it offers each part alone. ScraperAPI's sessions and premium proxies are mutually exclusive by their own documentation, and the router used to send requests asking for both. Declared as data rather than a predicate, soproxlane providersprints it. - #168
1248872ScraperAPI, ScrapingBee and Scrapfly now forward a POST body to the target. All four providers document POST support and only one adapter implemented it, so a POST request reached exactly one provider and could not fail over at all. Recordedpostfixtures for all three, echoed back by the target to prove the body arrived. - #181
bb6348dA provider's ownRetry-Afternow reaches the caller.ParsedResult.retryAfterMshad been in the contract since it landed and the chain already armed cooldowns from it, but no adapter ever set it — so a provider that capped us and said exactly how long to wait had that answer discarded, the cooldown drew a 30s jittered guess, and the caller got a bare 429. Two chain fixes: an answered request whose next candidate lost its probe claim kept only the outcome name, dropping the provider, the body and the detect rule. And a throwing health store could make the chain re-attempt — and re-pay for — a provider it had already tried. - #179
8c05ff9Scrapfly's stealth tier is priced at the residential figures, which is what it costs:translate()sends the residential proxy pool for every tier abovenone, so a stealth request is a residential request. It was published at the datacenter base — 1x against a real 25x on the comparison page. The router now reads the cost matrix as a capability claim. A null cell means the provider does not sell that combination, and ScrapingBee's stealth-without-rendering is one — it was being routed there and paid for. - #186
4468690Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including@proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
- #175
- 0.6.0Minor
- #152
dc62320CostTablestops beingbase × multipliersand becomes a matrix: one cost per proxy tier and render state, exhaustive,nullwhere the provider does not sell that combination. No provider prices multiplicatively, so every adapter had been writing the closest product it could and leaving a note about it. Scrapfly is additive and was estimated 4.17× too high on residential plus rendering; ScrapingBee's premium tier is 25 credits with rendering, not 10. `proxlane - #153
2a9142dCost tables must now carry a source URL and the date somebody last read it, no two providers may cite the same page, the scaffold's placeholder zeroes cannot ship, and a table nobody has re-read in a year fails the build. No test can verify a price without fetching a vendor's marketing site in CI, so this enforces the next best thing. - #151
ea6e393ScrapingBee's country list goes from 7 codes to the 42 it actually sells on classic proxies. The seven were an example table in their docs, and the router filters the failover chain on this set, so the provider was silently ineligible for thirty-five countries.ruleaves: it is premium-only, and classic silently servesusinstead of erroring. AddsCAPABILITIES, a static export of every adapter's capabilities that does not load the adapters, and cross-provider assertions over it. - #155
40897dcpostandsessionsnow say what they mean: a claim about the adapter, not about the provider. Both were undocumented booleans, and a research pass against the vendors' docs reported all four as live bugs because every provider sells POST and sessions — while every value was correct, since the field describes whattranslate()actually wires. Tests now hold each claim to the code that implements it. - #156
6b89f31A scraping API comparison at/scraping-api-comparison: pick a request shape and see what each provider charges on top of its own base rate, from their published tables. Compares multipliers, which are dimensionless, and never compares base rates across billing units. Fixes Bright Data's base cost, which was a hundred times too low — the only provider whose cost we estimate rather than read off the response.@proxlane/sharedgains an./error-bodysubpath so@proxlane/adaptersno longer dragsnode:cryptointo anything that imports it.
- #152
- 0.5.1Patch
Dependency updates only.
- 0.5.0Minor
- #125
fc0b684Returning a body byte for byte is now a declared capability, andbinary=truea request parameter. An image request used to return 200 with a corrupted body; it now routes only to providers that can carry bytes, or answersNO_PROVIDER_AVAILABLE. Three of the four launch providers carry binary. ScraperAPI does not — it decodes bodies as UTF-8, and its own API says so:binary_target=trueanswers 400, "The file type you are trying to scrape is not supported." - #127
ae360a9The Bright Data adapter asks forformat: 'raw'and can now return bytes. It usedjsonbecause "raw returns an API 200 whatever the target did" — true of the API's status line, butx-brd-status-codecarries the target's status on every raw response, so raw is a strict superset: same outcomes, plus the original bytes and the target's real charset. Three of four adapters now carry binary; ScraperAPI cannot, and says so. - #123
ae17465A cost number now carries its unit, and units are never summed together. Three launch providers sell credits and Bright Data bills cents, soX-Cost-Estimatewas adding one provider credit to fifteen hundredths of a cent and reporting the result as a quantity.CostTable.unitis required, the gateway emitsX-Cost-Unitbeside the figure, and a chain that spent in two units reportsmixedrather than an invented total. - #126
0a63439Scrapfly is declared binary-capable, which it always was — it reportsresult.format: 'binary'and base64-encodes the content, and the adapter already decoded that. The earlierfalsecame from measuring the provider's wire response instead of the adapter's output. Conformance now asserts thebinaryflag against a recorded JPEG throughparse, in both directions, and the fixture is required of every adapter. - #128
8e146cdThe conformance binary check no longer passes a corrupt file whose magic bytes happen to be printable. WebP is "RIFF", PDF is "%PDF", ZIP and XLSX are "PK", GIF is "GIF8" — all survive a UTF-8 round trip intact while the rest of the file is destroyed, so a magic-only check went quiet on every one of them. It now also counts U+FFFD replacement characters, whose presence in a binary body is the corruption itself, whatever the format.
- #125
- 0.4.0Minor
- #104
e7dc4a4Add a Bright Data Web Unlocker adapter, the fourth provider and the first for a service this project does not itself pay for. It reads the target's real status out of the JSON envelope rather than the raw body, and decodes Bright Data's ownx-brd-error-codeso a dead target is not blamed on the provider. Two pieces of shared tooling assumed a provider's parameters live in the URL, which was true of the first three by coincidence. The conformance suite and the replay transport now read the request body too, and the recorded-target matrix moved off a Cloudflare-fronted host that an unblocking provider correctly refuses to pass through. - #106
d3ff8c2A host that does not resolve is nowTARGET_ERRORfrom every provider. Scrapfly reported it asINVALID_REQUEST, which paged a human and stopped the chain; Bright Data asPROVIDER_ERROR, which cooled a healthy provider. Conformance now asserts the recorded outcome for the newdead-hostfixture, which is what makes it stay fixed. - #108
4db45e1Bright Data gets its own line colour. It shared slot 1 with ScraperAPI, becauselinewas typed1 | 2 | 3and taking an existing slot was the only way to compile — so the two were drawn in the same colour and a failover between them was invisible.pnpm tokens:checknow fails on a shared slot or a slot with no token behind it. - #111
258e5fcproxlane outcomesnow says what to do about an outcome, not only what it means: anaction, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally —failover: trueon a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link toproxlane.dev/docs/outcomes, which is live. They pointed at GitHub becausedocs.proxlane.devhas no DNS record; it was the subdomain that never existed, not the docs.
- #104
- 0.3.1Patch
Dependency updates only.
- 0.3.0Minor
- The route diagram: one request's journey drawn from attempt data, with provider line colours assigned in the adapter registry so every surface picks them up from one place.
- 0.2.0Minor
- Breaking: every non-2xx now returns one envelope —
{requestId, error: {code, class, message, docs}, attempts?}— instead of{error, message}for auth and validation and{outcome, class, attempts}for a failed scrape. Response headers are unchanged. - Add
OutcomeClass, a closed six-member classification alongside the openOutcomeunion, and send it asX-Outcome-Classand in the JSON error body. Branch on the class: it does not grow, so adding an outcome no longer breaks callers.
- Breaking: every non-2xx now returns one envelope —
- 0.1.0Minor
HealthStatenow carriesenteredAt, so the state a store persists is exactly the state the machine needs;observeandobserveProbetakenowand nothing else.INITIALis replaced byinitial(now). Conformance requires the fixture categories that prove an adapter can tell a target failure from its own.- A cooldown now waits as long as the target's own
Retry-Afterasks, when the provider exposes it — measured: ScrapingBee forwards it asspb-retry-after, Scrapfly exposes it in the envelope, ScraperAPI strips it. Clamped to the 15-minute cap, with the jittered backoff as the fallback. - A target 429 is now
TARGET_RATE_LIMITEDrather thanTARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban. - The outcome taxonomy,
Outcome,FAILOVERandGatewayRequestmove from@proxlane/adaptersto@proxlane/shared, so the base layer no longer depends on a leaf.@proxlane/adaptersre-exports all of it, so adapter authors import exactly what they imported before. - ScraperAPI now reads the target's status from
sa-statuscodeinstead of matching body prose, and reports the real credit cost fromsa-credit-cost— a rendered request previously billed at 1 credit instead of 10. A target returning 403 is nowHARD_BLOCKrather thanAUTH_FAILED, which had marked the caller's own key unhealthy.
Detection0.3.0
Block-page heuristics.
- 0.3.0Minor
- #188
7e77a6dA challenge page served with a target 5xx is now recognised as a block instead of being reported asTARGET_ERROR. The detector only ever examinedOKresponses, so Cloudflare's under-attack mode — which answers 503 — came back as "the site is broken" when the truth was that the site's defences refused every provider. It also armed no cooldown, so every later request re-bought the same failures. A claimed success that returned zero bytes is no longer billed as a successful scrape. - #186
4468690Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including@proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to.
- #188
- 0.2.0Minor
- #162
d9525c0Whether a detection rule has been confirmed by a real block page is now derived from stored captures rather than a hand-set boolean.pnpm corpus:verifyruns every capture through the real detector and generates the table, recording each capture's SHA-256, so a claim points at an artefact.cloudflare-challengeis the first rule confirmed against the thing it describes. - #165
fd84d98A Cloudflare block page is now reported ascloudflare-blockedrather thancloudflare-challenge. Real block pages carry both signatures, and rule order meant the block rule could never fire —X-Detect-Rulenamed the wrong reason on every Cloudflare block. Five of six detection rules are now confirmed against a real captured page. - #160
935ab4epnpm capture-blockturns a real HTTP response into a block-page corpus entry.plan.md§19 decides where it lands: a purpose-built scraping sandbox may enter this repository, anything else requires a private directory and is refused without one. Captures store a class of target, never a hostname, and are scrubbed of provider keys. - #166
30c894aimperva-incapsulano longer fires on ordinary pages. It matched_Incapsula_Resource, which is how any Incapsula-protected site loads Imperva's client script; it now keys on the structural difference — a block page frames that resource, an ordinary page scripts it — which also survives Imperva rotating the query parameter.capture-blocknames files by content digest, so two captures can no longer overwrite each other. - #149
b322019A block page detector at/block-page-detector. Paste a response body and it runs the gateway's own detector in your browser, names the rule that fired, and reads the consequence out of the same policy table the router uses. Nothing is sent anywhere.@proxlane/detectnow exportsSCAN_BYTES, so a caller can say how much of a body a verdict was formed from. - #164
a342613akamai-bot-managernow matches Akamai's real deny page, whose signature is HTML-entity-encoded so the previous literal could never fire on it. Four of six detection rules are now confirmed against a real captured block page, up from zero. - #167
2690ec7The FAQ said the detector's rules had never seen a real block page. Five of six have now been confirmed against one, and five of six turned out to have a defect only a real page could show. - #163
9127601datadomeis confirmed against real DataDome block markup, taking the detector to two rules of six backed by a capture. Documents a measured false positive inimperva-incapsula: the token it matches appears on ordinary pages of Incapsula-protected sites, and only escapes firing because it fell outside the scan window on the page tested.
- #162
- 0.1.0Minor
- The block detector:
detect(bytes, contentType, charset)returns the vendor rule that fired, or nothing. Six rules covering Cloudflare, DataDome, PerimeterX, Imperva and Akamai, each anchored to a vendor asset path rather than generic words like "captcha" — a false positive here fails over and spends a second provider's credits. - Documents a limitation with a real capture: a site that serves its own block page — no Cloudflare, DataDome or Imperva markup — returns 200 with nothing to fingerprint, and the detector calls it
OK. A rule matching the words would flag any article about bot detection, so catching it needs a per-domain baseline rather than a string match.
- The block detector:
Shared0.11.0
The outcome taxonomy and request types.
- 0.11.0Minor
- #237
afd2423One executor for every request. The gateway,proxlane scrape,pnpm record, the k6 harness and the live canary now all callcreateFetchTransport()from@proxlane/shared/transport, instead of each hand-rolling its ownfetch. The canary's copy droppedwire.body, which only Bright Data sends, so it reported a working key asAUTH_FAILEDon every run —repo:checkassertion 50 and a new contract test now hold the line.proxlane scrapeandpnpm recordgain the capped streaming read and the timeout/abort discrimination they were missing. - #238
9860659wait_for=<css selector>tells the renderer what to wait for before it snapshots the page. Rendering means the renderer ran, not that the content arrived — on a late-hydrating page the same request returns the full listing on one attempt and an empty shell on the next, and until now nothing in the request could name the finish line. It impliesrender=true, and it narrows the chain to providers that can express it: ScrapingBee'swait_forand Scrapfly'swait_for_selectorwere verified live, ScraperAPI's is its published name and the canary confirms it, and Bright Data declares it cannot —x-unblock-expectis accepted there and could not be shown to enforce a wait, so it is filtered out rather than charging for a page that did not wait.
- #237
- 0.10.0Minor
- #235
b694712A Scrapfly response over 5MB no longer arrives as a URL markedOK. Scrapfly offloads bodies above that size to an object store and returns a pointer;parse()read it as the page, so the caller got 70 bytes with HTTP 200, the target's content-type and a charge. It is nowPROVIDER_BODY_OFFLOADED, which fails over to a provider that returns the body inline.
- #235
- 0.9.0Minor
- #213
a43b34eProvider keys are trimmed when read from the environment. They were not, and the failure that produced is silent and expensive.Headersnormalises TRAILING whitespace away, so a key ending in a space or a newline works — which is most accidents, and it teaches you whitespace is harmless here. A LEADING space survives:Authorization: Bearer <key>goes out with two spaces, the provider answers 401, and that reaches the caller asAUTH_FAILED— a taxonomy member meaning "your credential was refused", pointing at the key's value when the value is correct and its framing is not. Only one of the four adapters could show it. The other three put the key in a query string, whereURLSearchParamspercent-encodes the space rather than sending it, so exactly one provider looked broken and the rest looked fine — which is the most misleading shape this could have had.proxlane doctornow says when a key had surrounding whitespace, because the gateway trimming it does not stop the same value confusing someone comparing their.envagainst what they pasted.
- #213
- 0.8.0Minor
- #194
3302c1cA block no longer cools every premium tier.cd:blknow carries the tier the request asked for, so a plain request that gets blocked stops suppressing the stealth retry — the escalation most likely to work, and the reason the tier exists. The implication still runs downward: a block at stealth cools residential and plain too, because they are strictly weaker against the same defence./health/cooldownsreports the tier. Existing armed keys are in the old format and are ignored rather than migrated; they expire on their own within the cap.
- #194
- 0.7.1Patch
- #188
7e77a6dA challenge page served with a target 5xx is now recognised as a block instead of being reported asTARGET_ERROR. The detector only ever examinedOKresponses, so Cloudflare's under-attack mode — which answers 503 — came back as "the site is broken" when the truth was that the site's defences refused every provider. It also armed no cooldown, so every later request re-bought the same failures. A claimed success that returned zero bytes is no longer billed as a successful scrape. - #186
4468690Every published package now has a description, keywords, a homepage and a README. Four of the five rendered "ERROR: No README data found!" on npmjs.com — including@proxlane/adapters, the Apache-2.0 package this project most wants strangers to contribute to. - #176
292e67borderChain's docstring described chain order backwards. Ranking best-first puts the least healthy provider last, and the docs already recorded that; the source comment and the test named after it still claimed the reverse, and that test asserted the opposite of its own title while passing. Behaviour is unchanged — the comment was wrong, not the code. - #183
df7a4c0The edge guard now refuses three IPv6 forms that carried an IPv4 address past it: RFC 2765's IPv4-translated::ffff:0:0/96, RFC 8215's local-use NAT64 prefix64:ff9b:1::/48, and the five RFC 6052 embedding positions other than the well-known one.http://[::ffff:0:169.254.169.254]/reached the cloud metadata endpoint.
- #188
- 0.7.0Minor
- #156
6b89f31A scraping API comparison at/scraping-api-comparison: pick a request shape and see what each provider charges on top of its own base rate, from their published tables. Compares multipliers, which are dimensionless, and never compares base rates across billing units. Fixes Bright Data's base cost, which was a hundred times too low — the only provider whose cost we estimate rather than read off the response.@proxlane/sharedgains an./error-bodysubpath so@proxlane/adaptersno longer dragsnode:cryptointo anything that imports it. - #142
d8f0661The global deadline defaults to 120s, whichoperations.mddecided some time ago and the gateway never picked up. At 90s a three-hop chain gave the terminal provider 38s of its 70s cap, because the budget reserves time for every hop still to come. The hop that exists to rescue a failing request was the one being cut short. Callers still ask for less viatimeoutand never for more. The operator's deadline is the ceiling, because it bounds how long one request holds an in-flight slot.
- #156
- 0.6.0Minor
- #136
89f92baA block cooldown that keeps failing now backs off to 6 hours instead of re-arming at a flat 15 minutes forever. A (provider, domain) that had refused a hundred times running cost 96 paid probes a day, 288 for a domain three providers block, all of it re-buying evidence already held. Account cooldowns are unchanged: a rate limit resets on its own and is private to one org. Because a fully-blocked domain would otherwise go dark for the whole backoff, an all-cooling domain now gets one forced attempt, rate-limited per domain and reported asX-Provider-Health: cooling-forced.
- #136
- 0.5.0Minor
- #125
fc0b684Returning a body byte for byte is now a declared capability, andbinary=truea request parameter. An image request used to return 200 with a corrupted body; it now routes only to providers that can carry bytes, or answersNO_PROVIDER_AVAILABLE. Three of the four launch providers carry binary. ScraperAPI does not — it decodes bodies as UTF-8, and its own API says so:binary_target=trueanswers 400, "The file type you are trying to scrape is not supported."
- #125
- 0.4.0Minor
- #111
258e5fcproxlane outcomesnow says what to do about an outcome, not only what it means: anaction, a sentence of why, and a link to the class's docs section. The policy fields describe what the gateway does internally —failover: trueon a blocked outcome means every provider was already tried — which is the opposite of what a caller reading it as "retryable" would conclude. Error responses and the CLI both link toproxlane.dev/docs/outcomes, which is live. They pointed at GitHub becausedocs.proxlane.devhas no DNS record; it was the subdomain that never existed, not the docs.
- #111
- 0.3.0Minor
- Add the docs site.
/docswas linked from the header and the primary call to action and had no route at all, so both 404ed on the live site. Pages are markdown inapps/web/content/docs, versioned and reviewed like code, rendered to HTML at build time by a Vite plugin. Neithermarkdown-itnor Shiki reaches the Worker bundle. The outcome reference is generated from the taxonomy instead, because a hand-written copy of the thing callers write switch statements against is the one page that must not drift.pnpm docs:checkis now real: it asserts every page has a file, a route and a nav entry, that every query parameter and response header the gateway implements is documented, that internal links resolve, and thatllms.txtlists exactly the pages that exist.@proxlane/sharedgains a./outcomesubpath export, so the taxonomy can be imported without pulling the edge guard andnode:cryptointo a browser bundle. - Enforce the in-flight ceiling. Past
PROXLANE_MAX_INFLIGHTconcurrent/v1requests the gateway answers 429GATEWAY_BUSYwithRetry-Afterand sheds, rather than queueing — a queued scrape burns its own deadline waiting and the queue is memory the ceiling bounds./healthis never shed. The variable was documented since the scaffold and read by nothing.GATEWAY_BUSYis a new outcome, classgateway. Deliberately notRATE_LIMITED, which is classprovider, writes an account cooldown and fails over — all three wrong when the gateway itself is full.OutcomeClassdoes not grow, so a caller branching on the class is unaffected. - Check at boot that the gateway fits in the memory it has been given. It reads the container's limit from cgroup v2 then v1, and refuses to start when
maxInflight * bodyCap * 2.5exceeds it, printing both numbers and the ceiling that would fit. It never falls back toos.totalmem(), which reports the host's memory inside a limited container. When no limit is readable, which is normal off a container, it prints the arithmetic and starts, sopnpm devstill works.PROXLANE_MEMORY_LIMIT_MBdeclares a limit where there is none and overrides one where there is.proxlane doctorreports the same budget from the same code..env.exampleanddocs/self-hosting.mddescribed this check for months before it existed; both now describe what it does.
- Add the docs site.
- 0.2.0Minor
- Breaking: every non-2xx now returns one envelope —
{requestId, error: {code, class, message, docs}, attempts?}— instead of{error, message}for auth and validation and{outcome, class, attempts}for a failed scrape. Response headers are unchanged. - Add
OutcomeClass, a closed six-member classification alongside the openOutcomeunion, and send it asX-Outcome-Classand in the JSON error body. Branch on the class: it does not grow, so adding an outcome no longer breaks callers. - Every response now carries
X-Request-Idand a matchingrequestIdin the JSON body, including 401s and validation errors. A caller's ownX-Request-Idis echoed when it is safe to. Addsuuidv7, monotonic and clock-regression-safe, which will also berequests.id.
- Breaking: every non-2xx now returns one envelope —
- 0.1.0Minor
- Cooldowns are implemented: two namespaces (
cd:blkby domain,cd:acctby org), exponential backoff with full jitter and a 15-minute cap, and half-open expiry where the single post-expiry probe must be claimed atomically. The gateway skips cooled providers before dividing the deadline, and returnsRetry-Afterwhen every capable provider is cooling. - Health tracking now defaults to off: its calibration assumes independent failures, and a two-regime provider with the same mean failure rate spends over 90% of its time demoted in simulation. Set
PROXLANE_HEALTH=onto enable it. Every published figure has been regenerated from the simulation, andrepo:checkassertion 21 now fails when the prose drifts from the measurement. HealthStatenow carriesenteredAt, so the state a store persists is exactly the state the machine needs;observeandobserveProbetakenowand nothing else.INITIALis replaced byinitial(now). Conformance requires the fixture categories that prove an adapter can tell a target failure from its own.- Provider health as a CUSUM against each provider's own measured baseline: detects a slow success-rate slide that no single request's outcome can reveal, and exposes
observe,orderChainandeligiblefor the router. - A cooldown now waits as long as the target's own
Retry-Afterasks, when the provider exposes it — measured: ScrapingBee forwards it asspb-retry-after, Scrapfly exposes it in the envelope, ScraperAPI strips it. Clamped to the 15-minute cap, with the jittered backoff as the fallback. - A target 429 is now
TARGET_RATE_LIMITEDrather thanTARGET_ERROR: it returns 429 to the caller, fails over to a different egress, and arms the shared domain cooldown. Previously it armed nothing, so the next request retried immediately — which is what escalates a rate limit into a ban. - The outcome taxonomy,
Outcome,FAILOVERandGatewayRequestmove from@proxlane/adaptersto@proxlane/shared, so the base layer no longer depends on a leaf.@proxlane/adaptersre-exports all of it, so adapter authors import exactly what they imported before. - Corrects every published health figure that disagreed between files, removes three false citations from
health.ts(including one reintroducing the exact defect that file documents), pins the five constants a mutation sweep found unpinned, and strengthensrepo:checkassertion 21 against the bypasses a verification panel demonstrated. - ScraperAPI now reads the target's status from
sa-statuscodeinstead of matching body prose, and reports the real credit cost fromsa-credit-cost— a rendered request previously billed at 1 credit instead of 10. A target returning 403 is nowHARD_BLOCKrather thanAUTH_FAILED, which had marked the caller's own key unhealthy. - Security follow-ups: hostnames in cooldown keys are bounded to 253 characters, a trailing FQDN dot no longer creates a second key for the same site, the gateway key comparison uses
timingSafeEqualrather than a length-short-circuiting loop, andPROXLANE_ORG_IDactually reaches the chain socd:acctis namespaced per deployment. - Valkey-backed health and cooldown stores, so more than one gateway replica can share routing state. Set
PROXLANE_VALKEY_URLto use them; unset, both stay in-process and the server still refuses to boot withPROXLANE_REPLICAS>1.
- Cooldowns are implemented: two namespaces (