refactor: layered scrape API, timeout_phase telemetry, and isolation hardening - #45
Merged
Conversation
Handler wait_for cancellations were always reporting attempts=0 with no stage, which made Sentry P0 timeouts impossible to triage. Track thread-visible progress and surface timeout_phase on 504 envelopes and Sentry tags.
Address review follow-ups: README/OpenAPI 504 example for the new diagnostics field, mark boot before profile setup, and assert Driver construction sees BOOT while request/browser tiers advance to WORK.
Lock the progress.mark placement that handler-timeout telemetry depends on, so deleting engine marks cannot pass the suite via a faked execute alone.
Collapse duplicate terminal-error plumbing and coalesce progress once so tier marks stay unconditional; shrink the regression suite around the same BOOT/WORK locks.
Document that request-tier timeout exceptions mirror browser remapping, and assert envelope timeout_phase plus Sentry fingerprint invariance.
Extract runtime cleanup helpers, prune inactive dirs at session entry, and retry profile creation after ENOSPC with another prune pass.
gildesmarais
marked this pull request as draft
August 24, 2026 18:32
Split the monolithic engine and main modules into api/domain/engine/infra layers, centralize env config in Settings, and wire scrape orchestration through ScrapeService with dependency injection.
Split monolithic contract tests into api/security/infra/engine layers, extract shared fakes, and add scripts/bench_scrape.py with a lightweight regression guard for the bench entrypoint.
Defer Botasaurus/CDP imports to tier entrypoints, consolidate post-bypass page-state collection, and document SCRAPE_MAX_WORKERS tuning for low-RAM hosts. Bench p50 wall_ms: 106.3 -> 102.9 (no regression).
Wire pyright into make check, tighten Settings.sentry and DriverProtocol seams, and relax test-only diagnostics via executionEnvironments.
Capture singleton lifecycle, settings threading, isolation invariants, driver capabilities, schemas layout, bench harness, and pyright gate.
* chore(types): add vendor stubs and CDP TypedDicts Introduce local .pyi stubs for Botasaurus/browser driver seams and shared CDP log/XHR shapes so strict pyright can type vendor boundaries. * refactor(types): type engine vendor seams end-to-end Wire DriverProtocol/CdpTabProtocol through metadata, XHR capture, and envelope builders so xhr_responses stays list[XhrResponse] throughout. * refactor(types): harden API and OpenAPI module boundaries Replace mutable OpenAPI globals with a registry/getter pattern and add typed validation-error and Sentry callback seams for strict pyright. * test(types): add factories and typed test support seams Introduce scrape_request()/example_url() helpers, protocol-shaped fakes, and an execute-side-effect proxy so tests stay strict-ready without raw HttpUrl construction at every call site. * chore(types): enable pyright strict mode gate Flip typeCheckingMode=strict, add httpx/TestClient stubs and test support helpers, document residual policy, and regenerate OpenAPI after schema tweak.
…aths Remove scripts/xhr_spike.py (Phase 0 spike; its finding lives in the XhrCollector docstring), unused tests/support/responses.py, the uncalled ScrapeService.serialize, the assessment error_category field that only tests asserted, dead target_url/min_free_bytes parameters, the unreachable re-raise in ScrapeSession.prepare_profile_dirs, and the redundant import-time configure_openapi in app.main.
Fourteen modules hardcoded the "botasaurus_scrape_api" logger name while app.logging_config already owned it; route every module through get_logger() so the name has one home.
Move remaining_total/work seconds, step budget, elapsed_ms, and is_timeout_exception into app/engine/budget.py so browser_tier no longer imports from request_tier and the orchestrator no longer reaches into browser_tier for a generic timeout predicate. Fold the challenge/bypass double-collect into settle_page_state and dedupe the five hand-rolled render_ms computations behind elapsed_ms.
The route owned guardrail policy: which validations run, in what order, and each fallback message. Move request-id resolution and SSRF guard sequencing into ScrapeService.process so the HTTP shell only parses and serializes; drop the never-injected resolve_scrape_request_id dependency and narrow the service surface to process + build_timeout_error. OpenAPI snapshot verified unchanged.
Replace ten attr-defined ignores in ops telemetry with a SentryScope protocol cast once at the vendor boundary. Collapse sentry_is_ready to the _initialized fact (init already requires a DSN) and delete the now-callerless is_sentry_enabled; drop the duplicate sample-rate clamp that Settings validators already own. Tighten ChallengeDetector's driver param to object and make PendingXhrMeta.request_id required so harvest stops re-normalizing ids.
Move root test modules into their owning layer dirs (sentry, ops telemetry, request id, runtime cleanup -> infra; isolation -> engine) and split the timeout-phase monolith by flight height: progress snapshot unit (infra), build_timeout_error mapping (domain), engine phase marking (engine), and the 504 envelope contract (api). Share one canned botasaurus Request fake instead of three inline mock blobs, route all HTTP tests through test_client, and drop the eleven-rule blanket pyright directive from the seven modules that pass strict without it (narrow reportPrivateUsage or real annotations elsewhere).
Layout now names logging_config.py, engine/budget.py, and the layered test tree; conventions point at get_logger(), budget.py as the only wall-clock owner, and test_client() over ad-hoc TestClient. The contract section reflects ScrapeService.process() (serialize() is gone), and the typing-residuals ledger lists the six remaining per-module pyright directives instead of a blanket-everywhere policy.
Review flagged that after folding URL guardrails into ScrapeService.process(), no in-process HTTP test proved the 403 path; only the Docker smoke script covered it. Pin localhost, private-IP, and blocked-proxy targets to the 403 validation envelope with honored request ids so a regression in the service wiring fails make check, not just make smoke.
Review nice-to-haves: the ENOSPC retry in ScrapeSession could never succeed when runtime_dir was created but profile_dir hit ENOSPC -- the retry's exist_ok=False mkdir tripped on the surviving partial dir. Remove the partial tree before the prune-and-retry pass. Add direct unit tests for budget.py clamps (floor at 1s, tighter-constraint min) and timeout-substring classification, and make the 504 fixture return an inert sentinel so the test proves the envelope comes from the handler timeout path, not the fixture.
Resolve refactor vs timeout-phase conflicts by keeping the layered app layout and dropping pre-refactor paths (engine.py, schemas.py, test_api_contract.py, scrape_progress.py).
gildesmarais
marked this pull request as ready for review
August 24, 2026 20:15
Copilot stopped reviewing on behalf of
gildesmarais due to an error
August 24, 2026 20:36
gildesmarais
requested
a lite review from Copilot
and removed request for
Copilot
August 24, 2026 20:38
Contributor
There was a problem hiding this comment.
Pull request overview
This PR refactors the scrape service into layered packages while preserving the HTTP contract and adding timeout telemetry, runtime isolation, and stricter typing.
Changes:
- Separates API, domain, engine, schema, infrastructure, and security responsibilities.
- Adds centralized timeout budgets, progress telemetry, cleanup, SSRF guardrails, and typed driver seams.
- Reorganizes tests and updates OpenAPI, documentation, dependencies, and validation tooling.
Reviewed changes
Copilot reviewed 84 out of 90 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Review result |
|---|---|
typings/starlette/testclient.pyi |
Reviewed; no final comment. |
typings/httpx/__init__.pyi |
Reviewed; no final comment. |
typings/botasaurus/request.pyi |
Reviewed; no final comment. |
typings/botasaurus/browser.pyi |
Reviewed; no final comment. |
typings/botasaurus_driver/core/custom_storage_cdp.pyi |
Reviewed; no final comment. |
typings/botasaurus_driver/cdp/network.pyi |
Reviewed; no final comment. |
typings/botasaurus_driver/cdp/__init__.pyi |
Reviewed; no final comment. |
typings/botasaurus_driver/__init__.pyi |
Reviewed; no final comment. |
tests/test_bench_regression.py |
Reviewed; no final comment. |
tests/support/http.py |
Reviewed; no final comment. |
tests/support/fakes.py |
Reviewed; no final comment. |
tests/support/factories.py |
Reviewed; no final comment. |
tests/support/__init__.py |
Reviewed; no final comment. |
tests/security/test_url_guard.py |
Reviewed; no final comment. |
tests/security/__init__.py |
Reviewed; no final comment. |
tests/infra/test_xhr_collector.py |
Reviewed; no final comment. |
tests/infra/test_sentry.py |
Reviewed; no final comment. |
tests/infra/test_scrape_progress.py |
Reviewed; no final comment. |
tests/infra/test_runtime_cleanup.py |
Reviewed; no final comment. |
tests/infra/test_request_id.py |
Reviewed; no final comment. |
tests/infra/test_ops_telemetry.py |
Reviewed; no final comment. |
tests/infra/test_metadata_extractor.py |
Reviewed; no final comment. |
tests/infra/test_challenge_detector.py |
Reviewed; no final comment. |
tests/infra/__init__.py |
Reviewed; no final comment. |
tests/engine/test_timeout_progress.py |
Reviewed; no final comment. |
tests/engine/test_scraper_engine.py |
Reviewed; no final comment. |
tests/engine/test_isolation.py |
Reviewed; no final comment. |
tests/engine/test_budget.py |
Reviewed; no final comment. |
tests/engine/__init__.py |
Reviewed; no final comment. |
tests/domain/test_timeout_error.py |
Reviewed; no final comment. |
tests/domain/__init__.py |
Reviewed; no final comment. |
tests/api/test_timeout_http.py |
Reviewed; no final comment. |
tests/api/test_request_schema.py |
Reviewed; no final comment. |
tests/api/test_http_contract.py |
nit (2 votes): Use tests/support/http.test_client() at lines 53, 70, and 151 instead of the module-global app. |
tests/api/__init__.py |
Reviewed; no final comment. |
tests/__init__.py |
Reviewed; no final comment. |
scripts/xhr_spike.py |
Reviewed; no final comment. |
scripts/bench_scrape.py |
Reviewed; no final comment. |
requirements.txt |
Reviewed; no final comment. |
README.md |
Reviewed; no final comment. |
pyproject.toml |
Reviewed; no final comment. |
openapi.yaml |
Reviewed; no final comment. |
Makefile |
critical (1 vote): Ensure clean CI/development installs include Pyright, or make check fails with No module named pyright. |
docs/typing-residuals.md |
Reviewed; no final comment. |
app/sentry.py |
Reviewed; no final comment. |
app/security/url_guard.py |
Reviewed; no final comment. |
app/security/__init__.py |
Reviewed; no final comment. |
app/schemas/response.py |
Reviewed; no final comment. |
app/schemas/request.py |
nit (3 votes): Avoid import-time freezing of the hard-coded 15-second default and timeout description; derive them from live per-app settings. |
app/schemas/enums.py |
Reviewed; no final comment. |
app/schemas/__init__.py |
Reviewed; no final comment. |
app/schemas.py |
Reviewed; no final comment. |
app/main.py |
Reviewed; no final comment. |
app/logging_config.py |
Reviewed; no final comment. |
app/infra/xhr_collector.py |
Reviewed; no final comment. |
app/infra/sentry.py |
Reviewed; no final comment. |
app/infra/scrape_progress.py |
Reviewed; no final comment. |
app/infra/runtime_cleanup.py |
Reviewed; no final comment. |
app/infra/request_id.py |
Reviewed; no final comment. |
app/infra/ops_telemetry.py |
Reviewed; no final comment. |
app/infra/metadata.py |
Reviewed; no final comment. |
app/infra/detector.py |
Reviewed; no final comment. |
app/infra/cdp_types.py |
Reviewed; no final comment. |
app/infra/__init__.py |
Reviewed; no final comment. |
app/exceptions.py |
Reviewed; no final comment. |
app/engine/strategies.py |
moderate (3 votes): Route optional-method resolution and invocation through driver_capabilities.call_if_available. |
app/engine/session.py |
critical (3 votes): Unregister the request ID when runtime preparation fails before __exit__ runs. |
app/engine/request_tier.py |
Reviewed; no final comment. |
app/engine/orchestrator.py |
critical (3 votes): Preserve the submission start time for tier budgets after queue waits. critical (1 vote): Synchronize active-request registration with runtime-directory pruning or re-check activity during deletion. |
app/engine/envelope.py |
Reviewed; no final comment. |
app/engine/driver_capabilities.py |
Reviewed; no final comment. |
app/engine/budget.py |
Reviewed; no final comment. |
app/engine/browser_tier.py |
moderate (3 votes): Catch and map non-timeout driver-construction failures at the browser boot boundary instead of allowing generic 500 responses. |
app/engine/__init__.py |
Reviewed; no final comment. |
app/domain/scrape_service.py |
Reviewed; no final comment. |
app/domain/__init__.py |
Reviewed; no final comment. |
app/constants.py |
Reviewed; no final comment. |
app/config.py |
Reviewed; no final comment. |
app/api/routes/scrape.py |
moderate (2 votes): Defer or factory-scope timeout-dependent response metadata so later configured app instances do not reuse the first import's values. |
app/api/routes/health.py |
Reviewed; no final comment. |
app/api/routes/__init__.py |
Reviewed; no final comment. |
app/api/openapi.py |
Reviewed; no final comment. |
app/api/openapi_examples.py |
Reviewed; no final comment. |
app/api/errors.py |
Reviewed; no final comment. |
app/api/deps.py |
Reviewed; no final comment. |
app/api/__init__.py |
Reviewed; no final comment. |
app/__init__.py |
Reviewed; no final comment. |
AGENTS.md |
Reviewed; no final comment. |
Suppressed comments (12)
app/api/errors.py:100
- Unexpected exceptions are encoded with
validation_error, so this handler returns HTTP 500 witherror_category=validation. That category is documented for 400/422 request validation, while execution failures map to 502; the resulting envelope is misleading and the 500 response is absent from the documented route contract. Use a distinct internal-error response or map scrape execution exceptions to the appropriate execution category/status.
return json_response(
validation_error(
"",
"Internal server error",
request_id=request_id,
app/config.py:13
- The outer
Settingsloads.env, but this nestedBaseSettingshas its own source configuration and noenv_file. Consequently, Sentry values present only in.envare ignored while the other settings load them, despite the documented single environment source. GiveSentrySettingsthe same dotenv source or populate it through the outer settings model.
model_config = SettingsConfigDict(extra="ignore")
app/domain/scrape_service.py:199
- The engine tiers return
ScrapeError(error_category=TIMEOUT)for in-tier and queue timeouts instead of raising, but this branch maps every returned error to HTTP 502. As a result, request-mode/browser tier timeouts are exposed as 502 rather than the documented 504, and terminal telemetry receives the wrong status. Map returned timeout errors to 504 here.
status_code = 200 if isinstance(result, ScrapeSuccess) else 502
app/engine/browser_tier.py:99
timeout_phaseis documented as present only whenerror_categoryistimeout, but this ENOSPC path returnsnavigation_errorwithtimeout_phase=boot. Clients will misclassify a storage failure as a timeout and the model/README contract is violated. Leave this field null for non-timeout outcomes and update the regression assertion.
timeout_phase=TimeoutPhase.BOOT,
app/infra/metadata.py:113
- The refactored metadata loop still falls back with
metadata_error=Noneeven when extraction fails; the individual extractors also swallow their exceptions. This makes a metadata failure indistinguishable from a successful absence of metadata, contrary to the documented success contract that populatesmetadata_errorwhile preserving HTML. Preserve the failure detail when returning the fallback result.
app/main.py:36 - Because
app.api.routesis imported only once, its decorators evaluateget_scrape_*_responses()against the first global registry. A latercreate_app(Settings(...))updates the top-level metadata but reuses those already-built route objects, so route examples such as the 504 timeout response still contain the first app's settings. Build route metadata per app instead of relying on the module-global registry.
app/schemas/request.py:184 - This validator reads the cached process-global settings instead of the
Settingsobject threaded intocreate_app()andScrapeService. An app created withscrape_work_timeout_seconds=5can therefore acceptwait_timeout_seconds=30unchanged, leaving request validation/schema behavior inconsistent with that app's configured budget. Make request validation settings-aware or apply the same clamp at the service boundary.
app/schemas/request.py:27 - The field description calls
get_settings()while the model class is imported, freezing the advertised clamp range beforecreate_app(settings=...)can supply its settings. A factory-created app with a different work timeout will expose stale OpenAPI text. Keep the schema description settings-independent or generate it as part of per-app schema configuration.
scripts/bench_scrape.py:50 - The benchmark records render timings only for 200 responses but still exits successfully and prints
wall_ms_p50when every scrape returns 502/504. The new regression test consequently passes while the endpoint is failing. Treat a non-200 response as a benchmark failure (or make the script explicitly report a failed run).
tests/api/test_http_contract.py:70 - Use
tests/support/http.test_client()here instead of constructing the module-global app directly. The new test convention inAGENTS.md:66centralizes lifespan and dependency-override cleanup; this second directTestClientseam should not remain in the reorganized HTTP suite.
tests/api/test_http_contract.py:155 - This test also bypasses
tests/support/http.test_client()and imports the module-global app directly, contrary toAGENTS.md:66. Use the shared helper inside the existingassertLogscontext so lifespan and dependency cleanup follow the same path as the other HTTP tests.
tests/test_bench_regression.py:18 - This regression test runs the benchmark against the default
https://example.comURL with no mock or local server. The unit-test suite therefore depends on external DNS/network and can spend roughly two 45-second scrape timeouts when CI is offline; the benchmark script also returns success without asserting a 200 response. Use a deterministic fake/local transport for the test and leave real network benchmarking to smoke/bench jobs.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Derive wall-clock start from the API deadline so queue wait cannot grant a second full budget; unregister on prepare failure; prune under the active-id lock; map Driver() boot failures to the 502 envelope; resolve optional methods via driver_capabilities.resolve_callable.
…ault create_router() factories capture timeout-dependent response metadata per app instance. wait_timeout_seconds uses a settings-aware default_factory with a stable OpenAPI default of 15.
Align HTTP contract tests on test_client() and collapse dual sentry imports.
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.
What changed
app/main.py,app/engine.py, andapp/schemas.pyinto layered packages:api/(routes, deps, errors, OpenAPI),domain/scrape_service.py,engine/(orchestrator, tiers, strategies, envelope,budget.py),schemas/,infra/, andsecurity/url_guard.py.create_app()lifespan owns one sharedScraperEngineandThreadPoolExecutoronapp.state; per-request isolation stays on runtime dirs, browser profiles, and drivers.create_router()factories included afterconfigure_openapi(settings), so timeout-dependent response metadata is not frozen on first import;wait_timeout_secondsuses a settings-awaredefault_factorywith a stable OpenAPI default of 15.ScrapeService.process()owns request-id resolution and SSRF guardrails;/scrapeis a thin call plusjson_response().diagnostics.timeout_phase(queue|boot|work) on timeout outcomes viaapp/infra/scrape_progress.py; engine marks phases at queue/boot/work boundaries; 504 handler and Sentry tags read the last snapshot.app/engine/budget.py; orchestrator derives start from the API submission deadline so queue wait cannot grant a second full work budget.Driver()boot failures to the 502 envelope; ENOSPC on profile creation removes the partial tree before prune-and-retry.driver_capabilities.call_if_available/resolve_callable; lazy Botasaurus/CDP imports in tier entrypoints.SentryScopeprotocol); single readiness fact; single logger owner viaapp.logging_config.get_logger().app/api/openapi_examples.py;Settingsthreaded through deps (no import-time freezes).ScrapeService.serialize(),scripts/xhr_spike.py, unused params, duplicate Sentry readiness checks).tests/api/,tests/domain/,tests/engine/,tests/infra/,tests/security/) with shared fakes intests/support/; HTTP contract pins SSRF 403 at the in-process seam.make typecheck(pyright strict onapp+tests) tomake checkand CI (requirements-dev.txt+.github/workflows/ci.yml); updatedAGENTS.mdanddocs/typing-residuals.md.Why
The codebase had grown into large monolith modules that mixed HTTP wiring, scrape orchestration, and engine internals. Shallow pass-through surfaces (route-level validation, duplicated budget math, scattered logger names) made isolation guarantees hard to audit and blocked strict typing on driver seams. Ops telemetry could not distinguish queue waits, browser boot, and navigation/work timeouts; queue time previously could still leave a full work budget; stale or half-created runtime dirs on tmpfs could exhaust disk or leave ENOSPC retries non-recreatable.
Risk
GET /health,POST /scrapewire shapes, error envelope, status codes).openapi-verifyis part ofmake check; OpenAPI snapshot only has small cosmetic deltas (examples / default docs).AGENTS.md).navigation_errorwithtimeout_phase=bootwhen retry fails."timeout" in str(exc)); documented wire behavior, not changed in this branch.Review map
tests/api/test_http_contract.py— SSRF guard 403 pins at the HTTP seam, request-id contract, error envelopestests/api/test_timeout_http.py+tests/engine/test_timeout_progress.py—timeout_phaseat 504 handler and engine tier markstests/engine/test_isolation.py+tests/engine/test_scraper_engine.py— shared engine/executor vs per-request isolation, collision guard, submission-deadline budget, Driver boot → 502tests/engine/test_budget.py— wall-clock budget clamps (floor at 1s, tighter-constraint min) and timeout-substring classificationapp/domain/scrape_service.py—process()guardrails, threadpool orchestration, timeout outcome assemblyapp/engine/budget.py+app/engine/orchestrator.py+app/engine/browser_tier.py+app/engine/request_tier.py— deadline-aware budget and tier executionapp/engine/session.py— prepare failure unregister, lock-scoped prune, ENOSPC partial-dir removal before retryapp/main.py+app/api/—create_app()lifespan,create_router()afterconfigure_openapi, thin routes, OpenAPI metadataValidation
make check— ruff, hadolint, spectral, unit tests, pyright strict, openapi-verify (reported green on this branch)pyrightfromrequirements-dev.txtand runs typecheck in.github/workflows/ci.yml