Skip to content

Commit 7863926

Browse files
authored
refactor: layered scrape API, timeout_phase telemetry, and isolation hardening (#45)
* fix(obs): tag scrape timeouts with queue/boot/work phase 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. * fix(obs): document timeout_phase and lock engine progress marks 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. * test(obs): assert engine marks BOOT before Driver and WORK on tiers Lock the progress.mark placement that handler-timeout telemetry depends on, so deleting engine marks cannot pass the suite via a faked execute alone. * refactor(obs): densify timeout_phase progress and tests Collapse duplicate terminal-error plumbing and coalesce progress once so tier marks stay unconditional; shrink the regression suite around the same BOOT/WORK locks. * test(obs): lock request-tier timeout category and phase Document that request-tier timeout exceptions mirror browser remapping, and assert envelope timeout_phase plus Sentry fingerprint invariance. * fix(runtime): prune orphan scrape dirs on every request Extract runtime cleanup helpers, prune inactive dirs at session entry, and retry profile creation after ENOSPC with another prune pass. * refactor(api): restructure into layered packages with FastAPI DI. 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. * refactor(api): remove dead DI/tier params and dedupe service identity * fix(engine): singleton engine and executor with isolation regression tests * refactor(config): thread Settings through deps and drop import-time freezes * refactor(api): single-source OpenAPI examples from model instances * refactor(engine): typed DriverProtocol and centralized capability adapter * refactor(config): nest Sentry settings and simplify env layout * refactor(schemas): split into enums/request/response modules * test: reorganize suite by layer and add scrape bench harness 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. * perf(engine): lazy imports and dedupe browser-tier hot path 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). * chore(types): add pyright gate and tighten engine seams Wire pyright into make check, tighten Settings.sentry and DriverProtocol seams, and relax test-only diagnostics via executionEnvironments. * docs(agents): document hardening pass architecture conventions Capture singleton lifecycle, settings threading, isolation invariants, driver capabilities, schemas layout, bench harness, and pyright gate. * refactor(types): use strict pyright (#46) * 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. * chore(cleanup): delete spike script, dead surfaces, and unreachable paths 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. * refactor(logging): single-source the service logger via get_logger() 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. * refactor(engine): own wall-clock budget math in one module 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. * refactor(api): deepen ScrapeService and thin the scrape route 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. * refactor(types): typed Sentry scope seam and single readiness fact 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. * test: layer the suite and shrink blanket pyright directives 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). * docs: sync AGENTS.md and typing residuals with the refactor 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. * test(api): pin SSRF guardrail at the HTTP seam 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. * fix(engine): make ENOSPC retry recreatable; pin budget math with units 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. * fix(engine): honor submission deadline and harden session/boot isolation 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. * fix(api): build routes after OpenAPI configure; live wait_timeout default 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. * chore(ci): install pyright in requirements-dev and typecheck in CI Align HTTP contract tests on test_client() and collapse dual sentry imports.
1 parent 06b1f29 commit 7863926

92 files changed

Lines changed: 5380 additions & 3370 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ jobs:
9898
- name: Execute unit test suite
9999
run: python -m unittest discover -s tests -p "test_*.py" -v
100100

101+
- name: Typecheck
102+
run: make typecheck
103+
101104
- name: Verify OpenAPI snapshot
102105
run: make openapi-verify
103106

AGENTS.md

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,103 @@
55
- Docker-first and Docker-only unless user asks otherwise.
66
- Keep repo focused: stable Botasaurus scrape API wrapper, not generic framework.
77

8+
## Project Layout
9+
10+
```
11+
app/
12+
main.py # create_app() factory; module-level `app` for uvicorn
13+
constants.py # SERVICE_NAME and other shared literals
14+
config.py # Settings + nested SentrySettings; single env source of truth
15+
exceptions.py # domain exceptions (e.g. RequestIdCollisionError)
16+
logging_config.py # setup_logging + get_logger(); single owner of logger name
17+
api/
18+
deps.py # FastAPI Depends: settings, engine, executor, ScrapeService
19+
errors.py # 422 + 500 handlers → scrape error envelope
20+
openapi.py # route OpenAPI metadata (configure_openapi at app creation)
21+
openapi_examples.py # OpenAPI examples built from Pydantic model instances
22+
routes/ # thin HTTP handlers (health, scrape)
23+
domain/
24+
scrape_service.py # request-id resolution, URL guardrails, threadpool execution, status mapping
25+
engine/
26+
orchestrator.py # ScraperEngine.execute
27+
session.py # ScrapeSession lifecycle
28+
budget.py # wall-clock budget math shared across tiers (elapsed_ms, step budgets)
29+
request_tier.py # HTTP/curl_cffi path
30+
browser_tier.py # Chromium path
31+
strategies.py # NavigationMode resolution, driver helpers
32+
driver_capabilities.py # DriverProtocol + call_if_available adapter
33+
envelope.py # success/error builders, UTF-8 HTML normalization
34+
schemas/
35+
enums.py # ExecutionMode, NavigationMode, ErrorCategory, ...
36+
request.py # ScrapeRequest and validators
37+
response.py # ScrapeSuccess, ScrapeError, HealthResponse, ...
38+
infra/ # telemetry, progress, metadata, xhr, runtime cleanup, sentry
39+
security/ # UrlGuard SSRF guardrails
40+
scripts/
41+
bench_scrape.py # TestClient wall-time bench for POST /scrape (request tier)
42+
tests/
43+
api/ # HTTP contract, request schema, 504 timeout envelope tests
44+
domain/ # ScrapeService unit tests (timeout error mapping)
45+
engine/ # ScraperEngine units, isolation regressions, timeout progress
46+
infra/ # challenge, metadata, xhr, progress, sentry, telemetry, request-id, cleanup
47+
security/ # UrlGuard tests
48+
support/
49+
http.py # test_client() context manager + dependency_overrides helper
50+
fakes.py # shared FakeDriver, FakeRequest, fake_request_cls, ...
51+
factories.py # scrape_request(), example_url()
52+
test_bench_regression.py # lightweight guard that bench script completes (root: guards scripts/)
53+
```
54+
55+
Layer rules:
56+
57+
| Layer | May import | Must not import |
58+
| --- | --- | --- |
59+
| `api/routes` | `domain`, `api/deps`, `schemas.*` | `engine` internals, Botasaurus |
60+
| `domain` | `engine`, `security`, `schemas.*`, `infra` | FastAPI, Botasaurus |
61+
| `engine` | `infra`, `security`, `schemas.*`, `config` | FastAPI |
62+
| `infra` | Botasaurus, CDP (lazy at use sites) | FastAPI, routes |
63+
64+
Conventions:
65+
66+
- Use `tests/support/http.test_client()` for HTTP tests; it runs lifespan and manages `dependency_overrides`. No ad-hoc `TestClient(create_app())` in test modules.
67+
- Loggers come from `app.logging_config.get_logger()`; do not call `logging.getLogger` with a literal name.
68+
- Wall-clock/timeout math (elapsed, remaining, step budgets) lives in `app/engine/budget.py`; tiers must not re-derive it.
69+
- Config: add env vars to `Settings` in `config.py`; call `reset_settings_cache()` in tests that patch env.
70+
- Wire types live in `app/schemas/` submodules; import directly (`from app.schemas.request import ScrapeRequest`). No long-lived re-export shim.
71+
- Domain logic stays out of route handlers and Pydantic shells.
72+
- Typed exceptions over string-matching (`RequestIdCollisionError`, not `RuntimeError` message checks).
73+
- `NavigationMode` end-to-end in engine code; no raw strategy strings outside enum conversion boundaries.
74+
- Optional Botasaurus driver methods go through `driver_capabilities.call_if_available` / `resolve_callable` only; do not ad-hoc `getattr(driver, ...)`.
75+
- OpenAPI route examples come from `openapi_examples.py` model instances, not hand-typed dicts.
76+
- Route modules expose `create_router()` factories included from `create_app()` **after** `configure_openapi(settings)`, so timeout-dependent response metadata is not frozen on first import.
77+
- Botasaurus/CDP imports are lazy inside tier entrypoints (`run_request_tier`, `run_browser_tier`, XhrCollector methods), not at app import time.
78+
79+
## Singleton + Settings Threading
80+
81+
- **One** `ScraperEngine` and **one** `ThreadPoolExecutor` are created in `create_app()` lifespan and stored on `app.state`.
82+
- `get_engine` / `get_executor` / `SettingsDep` read from `request.app.state` (not per-request construction).
83+
- Lifespan shutdown calls `executor.shutdown(wait=False, cancel_futures=True)` on the real pool instance.
84+
- Do **not** freeze settings at import time in schemas or OpenAPI modules. `configure_openapi(settings)` runs during `create_app()`; `clamp_wait_timeout_seconds` reads live `get_settings()`.
85+
- `ScraperEngine` and tier functions require an injected `Settings` parameter; no `get_settings()` fallback in the hot path.
86+
87+
## Isolation Invariants
88+
89+
| Resource | Lifetime | Rule |
90+
| --- | --- | --- |
91+
| `ScraperEngine` | process (app.state) | shared |
92+
| `ThreadPoolExecutor` | process (app.state) | shared, sized by `SCRAPE_MAX_WORKERS` |
93+
| `_active_request_ids` | in-process memory | shared; collision guard |
94+
| runtime dir `/tmp/scrape/<request_id>` | per request | isolated; deleted in `finally` |
95+
| browser profile | per request | isolated; no reuse |
96+
| Botasaurus Driver | per request | isolated; closed in `finally` |
97+
98+
Multi-worker uvicorn breaks in-process collision detection unless request ids are sticky to a worker. Default to single-worker for isolation semantics.
99+
8100
## Contract (Do Not Break)
9101

10102
- Endpoints: `GET /health`, `POST /scrape`.
11103
- `openapi.yaml` is generated from `app.openapi()` via `make openapi`. Do not hand-edit. `make openapi-verify` is part of `make check`. Spectral (`make spectral`) lints the snapshot; do not add a post-processor that mutates the dump.
12-
- Wire types live in `app/schemas.py`. Engine imports them. Routes `model_dump()` once into `JSONResponse`.
104+
- Wire types live in `app/schemas/`. Engine imports them. Routes call `ScrapeService.process()` and serialize via `json_response()`.
13105
- OpenAPI `info.version` is `2.0.0`. Schema names: `ScrapeSuccess` (200) and `ScrapeError` (400/403/422/502/504). No `ScrapeResponse` alias.
14106
- Success `/scrape` fields: `url`, `final_url`, `status_code`, `headers`, `html`, `metadata_error`, `xhr_responses`, `diagnostics`.
15107
- When `html` is present, document `headers` `content-type` is `text/html; charset=utf-8` and `html` is UTF-8-normalized.
@@ -36,7 +128,8 @@
36128
- close browser driver
37129
- delete request runtime dir
38130
- remove in-memory active request id
39-
- Keep request-id collision/invariant guard (`_active_request_ids`) intact.
131+
- Before each scrape, prune orphaned runtime dirs not tied to an active request id; ENOSPC on profile creation retries after another prune pass. Optional `SCRAPE_RUNTIME_MIN_FREE_BYTES` (default 256MiB) logs when the runtime filesystem is low.
132+
- Keep request-id collision/invariant guard (`_active_request_ids`) intact; raises `RequestIdCollisionError`.
40133
- `driver.requests.get` metadata is best-effort; metadata failure must not fail HTML success.
41134
- Keep strategy engine behavior:
42135
- `auto` mode attempt order: `google_get` -> `google_get_bypass` -> `get`
@@ -46,14 +139,29 @@
46139
- keep `/usr/bin/google-chrome` symlink to Chromium for compatibility
47140
- If browser install logic changes, re-verify binary path and Botasaurus startup.
48141

142+
## Performance
143+
144+
- Baseline bench: `PYTHONPATH=. .venv/bin/python3 scripts/bench_scrape.py --runs 10` (TestClient, `execution_mode=request`).
145+
- Record p50 wall time when changing hot paths; avoid regressions vs prior baseline.
146+
- On low-RAM hosts (Docker `--memory=768m`, 1–2 vCPU), tune `SCRAPE_MAX_WORKERS` to `1` or `2`; higher values increase queue wait and swap without improving wall time.
147+
- Browser tier skips XHR harvest before Cloudflare bypass; one consolidated `collect_page_state` pass runs after bypass.
148+
149+
## Types
150+
151+
- `make typecheck` runs **`pyright` strict** on `app tests` and is part of `make check`.
152+
- Vendor seams: local stubs in `typings/` (`stubPath` in `pyproject.toml`); CDP shapes in `app/infra/cdp_types.py`.
153+
- Engine driver seams use `DriverProtocol` / `CdpTabProtocol` in `driver_capabilities.py`; cast vendor `Driver` at construction when needed.
154+
- Tests construct `ScrapeRequest` via `tests/support/factories.py` (`scrape_request`, `example_url`); fakes implement protocol shapes in `tests/support/fakes.py`.
155+
- Residual policy and file-level test directives: `docs/typing-residuals.md`.
156+
49157
## Safety
50158

51159
- Keep SSRF guardrails: localhost/domain checks and blocked IP classes (loopback/private/link-local/multicast/reserved/unspecified).
52160
- Do not weaken URL validation without explicit request plus docs/tests updates.
53161

54162
## Done Criteria
55163

56-
- Run `make check` before finish.
164+
- Run `make check` before finish (lint, test, typecheck, openapi-verify).
57165
- When Pydantic models or route response metadata change, run `make openapi` and commit the snapshot with the code change.
58166
- When API contract, Docker behavior, or error semantics change, also run `make smoke`.
59167
- `make smoke` must cover build, boot, `/health`, `/scrape` happy path, strategy override, retry path, isolation check, localhost guardrail.

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: test build serve health scrape-example smoke lint lintfix check ready openapi openapi-verify spectral
1+
.PHONY: test build serve health scrape-example smoke lint lintfix check ready openapi openapi-verify spectral typecheck
22

33
.DEFAULT_GOAL := check
44

@@ -35,13 +35,16 @@ openapi-verify:
3535
$(PYTHON) scripts/export_openapi.py --out $$tmp && \
3636
diff -u $(OPENAPI_FILE) $$tmp
3737

38-
check: lint test openapi-verify
38+
check: lint test typecheck openapi-verify
3939

4040
ready: check
4141

4242
test:
4343
$(PYTHON) -m unittest discover -s tests
4444

45+
typecheck:
46+
$(PYTHON) -m pyright app tests
47+
4548

4649
build:
4750
docker build -t $(IMAGE) .

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ Exception:
312312
- Browser profile/session artifacts are request-scoped only.
313313
- No cache/profile/driver reuse across requests.
314314
- Cleanup is enforced in `finally`: driver close + runtime directory delete + request-id in-memory state scrub.
315+
- Before each scrape, orphaned runtime dirs (not tied to an active request id) are pruned; ENOSPC triggers an extra prune-and-retry. Mount `/tmp/scrape` on tmpfs in production (see html2rss-web `docker-compose.yml`).
315316

316317
## Environment Variables
317318

@@ -321,20 +322,21 @@ Use a **separate Sentry project** from html2rss-web (`BOTASAURUS_SENTRY_DSN` →
321322

322323
| Variable | Default | Description |
323324
| :--- | :--- | :--- |
324-
| `SENTRY_DSN` | _(unset)_ | Project DSN. |
325-
| `SENTRY_ENVIRONMENT` | `production` | Deployment tag (`ENVIRONMENT` fallback). |
326-
| `SENTRY_RELEASE` | _(unset)_ | Release tag on events. |
327-
| `SENTRY_TRACES_SAMPLE_RATE` | `0.0` | APM traces (off by default; enable later if needed). |
328-
| `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Profiling sample rate. |
329-
| `SENTRY_SEND_DEFAULT_PII` | `false` | Send default PII when `true`. |
325+
| `SENTRY_DSN` | _(unset)_ | Project DSN (`Settings.sentry.dsn`). |
326+
| `SENTRY_ENVIRONMENT` | `production` | Deployment tag (`Settings.sentry.environment`; `ENVIRONMENT` fallback). |
327+
| `SENTRY_RELEASE` | _(unset)_ | Release tag on events (`Settings.sentry.release`). |
328+
| `SENTRY_TRACES_SAMPLE_RATE` | `0.0` | APM traces (`Settings.sentry.traces_sample_rate`; off by default). |
329+
| `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Profiling sample rate (`Settings.sentry.profiles_sample_rate`). |
330+
| `SENTRY_SEND_DEFAULT_PII` | `false` | Send default PII when `true` (`Settings.sentry.send_default_pii`). |
330331

331332
**Signal routing:** `navigation_error` and `timeout` → grouped Sentry Issues. `challenge_block` → `scrape.challenge_block` metric only; engine stdout keeps the detailed log line. Traces stay off unless you raise `SENTRY_TRACES_SAMPLE_RATE`.
332333

333334
| Variable | Default | Description |
334335
| :--- | :--- | :--- |
335-
| `SCRAPE_MAX_WORKERS` | `4` | Threadpool worker limit for sync browser execution. |
336+
| `SCRAPE_MAX_WORKERS` | `4` | Threadpool worker limit for sync browser execution. On low-RAM hosts (for example Docker with `--memory=768m` or a 1–2 vCPU VM), use `1` or `2` to limit concurrent Chromium boots; higher values increase queue wait and swap pressure without improving wall time. |
336337
| `SCRAPE_TIMEOUT_SECONDS` | `45` | Handler wall-clock budget in seconds (queue, browser boot, and work). |
337338
| `SCRAPE_WORK_TIMEOUT_SECONDS` | `30` | Post-boot navigate, selector wait, and scroll budget in seconds. |
339+
| `SCRAPE_RUNTIME_MIN_FREE_BYTES` | `268435456` (256 MiB) | Prune orphan runtime dirs when free space drops below this threshold. |
338340

339341
## Example Calls
340342

app/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Botasaurus scrape API application package."""

app/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""HTTP route registration."""

app/api/deps.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""FastAPI dependency providers."""
2+
3+
from __future__ import annotations
4+
5+
from concurrent.futures import ThreadPoolExecutor
6+
from typing import Annotated
7+
8+
from fastapi import Depends, Request
9+
10+
from app.config import Settings
11+
from app.domain.scrape_service import ScrapeService
12+
from app.engine import ScraperEngine
13+
14+
15+
def get_app_settings(request: Request) -> Settings:
16+
return request.app.state.settings
17+
18+
19+
SettingsDep = Annotated[Settings, Depends(get_app_settings)]
20+
21+
22+
def get_executor(request: Request) -> ThreadPoolExecutor:
23+
return request.app.state.executor
24+
25+
26+
ExecutorDep = Annotated[ThreadPoolExecutor, Depends(get_executor)]
27+
28+
29+
def get_engine(request: Request) -> ScraperEngine:
30+
return request.app.state.engine
31+
32+
33+
EngineDep = Annotated[ScraperEngine, Depends(get_engine)]
34+
35+
36+
def get_scrape_service(
37+
settings: SettingsDep,
38+
engine: EngineDep,
39+
executor: ExecutorDep,
40+
) -> ScrapeService:
41+
return ScrapeService(settings=settings, engine=engine, executor=executor)
42+
43+
44+
ScrapeServiceDep = Annotated[ScrapeService, Depends(get_scrape_service)]

app/api/errors.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""HTTP exception handlers returning scrape error envelopes."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, TypedDict, cast
6+
from urllib.parse import urlparse
7+
8+
from fastapi import FastAPI, Request
9+
from fastapi.exceptions import RequestValidationError
10+
from fastapi.responses import JSONResponse
11+
12+
from app.infra.request_id import resolve_request_id
13+
from app.logging_config import get_logger
14+
from app.schemas.response import ScrapeError, ScrapeSuccess, validation_error
15+
16+
logger = get_logger()
17+
18+
_NON_FIELD_LOC = {"body", "query", "path", "header"}
19+
20+
21+
class ValidationErrorItem(TypedDict, total=False):
22+
loc: tuple[Any, ...] | list[Any]
23+
msg: str
24+
type: str
25+
input: Any
26+
27+
28+
def schema_field_from_loc(loc: tuple[Any, ...] | list[Any]) -> str:
29+
for part in loc:
30+
if part not in _NON_FIELD_LOC:
31+
return str(part)
32+
return str(loc[-1]) if loc else "unknown"
33+
34+
35+
def first_schema_field(errors: list[ValidationErrorItem]) -> str:
36+
if not errors:
37+
return "unknown"
38+
return schema_field_from_loc(errors[0].get("loc") or ())
39+
40+
41+
def url_from_validation_body(body: Any) -> str:
42+
if isinstance(body, dict):
43+
url_value = cast(dict[str, Any], body).get("url")
44+
if url_value is not None:
45+
return str(url_value)
46+
return ""
47+
48+
49+
def validation_error_message(errors: list[ValidationErrorItem]) -> str:
50+
if not errors:
51+
return "Request schema validation failed"
52+
parts: list[str] = []
53+
for err in errors:
54+
loc = err.get("loc") or ()
55+
field = schema_field_from_loc(loc)
56+
message = str(err.get("msg") or "invalid")
57+
parts.append(f"{field}: {message}")
58+
return "; ".join(parts)
59+
60+
61+
def json_response(
62+
body: ScrapeSuccess | ScrapeError, *, status_code: int
63+
) -> JSONResponse:
64+
return JSONResponse(status_code=status_code, content=body.model_dump(mode="json"))
65+
66+
67+
async def request_schema_validation_handler(
68+
request: Request, exc: RequestValidationError
69+
) -> JSONResponse:
70+
errors: list[ValidationErrorItem] = list(exc.errors()) # type: ignore[arg-type]
71+
url = url_from_validation_body(exc.body)
72+
field = first_schema_field(errors)
73+
request_id, _ = resolve_request_id(
74+
request.headers.get("X-Request-Id"),
75+
host=urlparse(url).hostname if url else None,
76+
)
77+
logger.info(
78+
"request_schema_422 host=%s field=%s",
79+
urlparse(url).hostname if url else None,
80+
field,
81+
)
82+
return json_response(
83+
validation_error(
84+
url,
85+
validation_error_message(errors),
86+
request_id=request_id,
87+
),
88+
status_code=422,
89+
)
90+
91+
92+
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
93+
del exc
94+
logger.exception("unhandled_exception path=%s", request.url.path)
95+
request_id, _ = resolve_request_id(request.headers.get("X-Request-Id"))
96+
return json_response(
97+
validation_error(
98+
"",
99+
"Internal server error",
100+
request_id=request_id,
101+
),
102+
status_code=500,
103+
)
104+
105+
106+
def register_exception_handlers(app: FastAPI) -> None:
107+
app.add_exception_handler(
108+
RequestValidationError,
109+
cast(Any, request_schema_validation_handler),
110+
)
111+
app.add_exception_handler(Exception, unhandled_exception_handler)

0 commit comments

Comments
 (0)