keble-scraper-api 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. keble_scraper_api/__init__.py +3 -0
  2. keble_scraper_api/api/dependencies.py +100 -0
  3. keble_scraper_api/api/errors.py +72 -0
  4. keble_scraper_api/api/router.py +473 -0
  5. keble_scraper_api/api/schemas.py +26 -0
  6. keble_scraper_api/application/admin.py +285 -0
  7. keble_scraper_api/application/artifacts.py +393 -0
  8. keble_scraper_api/application/auth.py +324 -0
  9. keble_scraper_api/application/failures.py +427 -0
  10. keble_scraper_api/application/fetching.py +72 -0
  11. keble_scraper_api/application/jobs.py +891 -0
  12. keble_scraper_api/application/proxy_health.py +126 -0
  13. keble_scraper_api/application/scheduling.py +24 -0
  14. keble_scraper_api/artifact_namespace.py +76 -0
  15. keble_scraper_api/container.py +361 -0
  16. keble_scraper_api/errors.py +25 -0
  17. keble_scraper_api/infrastructure/artifact_store.py +585 -0
  18. keble_scraper_api/infrastructure/callbacks.py +285 -0
  19. keble_scraper_api/infrastructure/celery_factory.py +63 -0
  20. keble_scraper_api/infrastructure/cloudflare.py +259 -0
  21. keble_scraper_api/infrastructure/credentials.py +75 -0
  22. keble_scraper_api/infrastructure/dispatcher.py +57 -0
  23. keble_scraper_api/infrastructure/http_fetcher.py +351 -0
  24. keble_scraper_api/infrastructure/mongo.py +2345 -0
  25. keble_scraper_api/infrastructure/proxy_router.py +460 -0
  26. keble_scraper_api/infrastructure/request_profiles.py +220 -0
  27. keble_scraper_api/infrastructure/url_policy.py +341 -0
  28. keble_scraper_api/main.py +162 -0
  29. keble_scraper_api/maintenance/__init__.py +1 -0
  30. keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
  31. keble_scraper_api/models.py +473 -0
  32. keble_scraper_api/protocols.py +947 -0
  33. keble_scraper_api/settings.py +484 -0
  34. keble_scraper_api/telemetry.py +109 -0
  35. keble_scraper_api/workers/celery_app.py +48 -0
  36. keble_scraper_api/workers/cli.py +106 -0
  37. keble_scraper_api/workers/runtime.py +78 -0
  38. keble_scraper_api/workers/tasks.py +97 -0
  39. keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
  40. keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
  41. keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
  42. keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,427 @@
1
+ """Canonical conversion from scraper runtime evidence to wire-safe failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from keble_helpers import (
8
+ UpstreamFailureDisposition,
9
+ UpstreamFailureKind,
10
+ UpstreamOperatorAction,
11
+ )
12
+ from keble_scraper_contract import (
13
+ ScrapeAttemptOutcome,
14
+ ScrapeFailure,
15
+ ScrapeLocalFailure,
16
+ ScrapeLocalFailureCode,
17
+ ScrapeLocalFailureOrigin,
18
+ ScrapeTransportRoute,
19
+ ScrapeUpstreamFailure,
20
+ ScrapeUpstreamOrigin,
21
+ )
22
+
23
+ from keble_scraper_api.application.fetching import FetchResult
24
+ from keble_scraper_api.application.scheduling import ceil_persistence_millisecond
25
+ from keble_scraper_api.errors import ScraperDomainError
26
+
27
+
28
+ _LOCAL_RECOVERY = {
29
+ ScrapeLocalFailureCode.AUTH_INVALID: (
30
+ UpstreamFailureDisposition.OPERATOR_ACTION,
31
+ UpstreamOperatorAction.ROTATE_CREDENTIAL,
32
+ ),
33
+ ScrapeLocalFailureCode.AUTH_FORBIDDEN: (
34
+ UpstreamFailureDisposition.OPERATOR_ACTION,
35
+ UpstreamOperatorAction.REQUEST_PERMISSION,
36
+ ),
37
+ ScrapeLocalFailureCode.NOT_FOUND: (
38
+ UpstreamFailureDisposition.PERMANENT,
39
+ UpstreamOperatorAction.NONE,
40
+ ),
41
+ ScrapeLocalFailureCode.REVISION_CONFLICT: (
42
+ UpstreamFailureDisposition.PERMANENT,
43
+ UpstreamOperatorAction.FIX_REQUEST,
44
+ ),
45
+ ScrapeLocalFailureCode.INVALID_TRANSITION: (
46
+ UpstreamFailureDisposition.PERMANENT,
47
+ UpstreamOperatorAction.FIX_REQUEST,
48
+ ),
49
+ ScrapeLocalFailureCode.VALIDATION_ERROR: (
50
+ UpstreamFailureDisposition.PERMANENT,
51
+ UpstreamOperatorAction.FIX_REQUEST,
52
+ ),
53
+ ScrapeLocalFailureCode.URL_DENIED: (
54
+ UpstreamFailureDisposition.PERMANENT,
55
+ UpstreamOperatorAction.FIX_REQUEST,
56
+ ),
57
+ ScrapeLocalFailureCode.DNS_FAILED: (
58
+ UpstreamFailureDisposition.PERMANENT,
59
+ UpstreamOperatorAction.FIX_REQUEST,
60
+ ),
61
+ ScrapeLocalFailureCode.REDIRECT_LIMIT: (
62
+ UpstreamFailureDisposition.PERMANENT,
63
+ UpstreamOperatorAction.FIX_REQUEST,
64
+ ),
65
+ ScrapeLocalFailureCode.TIMEOUT: (
66
+ UpstreamFailureDisposition.RETRYABLE,
67
+ UpstreamOperatorAction.WAIT,
68
+ ),
69
+ ScrapeLocalFailureCode.NETWORK_ERROR: (
70
+ UpstreamFailureDisposition.RETRYABLE,
71
+ UpstreamOperatorAction.WAIT,
72
+ ),
73
+ ScrapeLocalFailureCode.RESPONSE_TOO_LARGE: (
74
+ UpstreamFailureDisposition.PERMANENT,
75
+ UpstreamOperatorAction.FIX_REQUEST,
76
+ ),
77
+ ScrapeLocalFailureCode.CONTENT_TYPE_REJECTED: (
78
+ UpstreamFailureDisposition.PERMANENT,
79
+ UpstreamOperatorAction.FIX_REQUEST,
80
+ ),
81
+ ScrapeLocalFailureCode.BUDGET_EXHAUSTED: (
82
+ UpstreamFailureDisposition.RETRYABLE,
83
+ UpstreamOperatorAction.WAIT,
84
+ ),
85
+ ScrapeLocalFailureCode.ROUTE_UNAVAILABLE: (
86
+ UpstreamFailureDisposition.RETRYABLE,
87
+ UpstreamOperatorAction.WAIT,
88
+ ),
89
+ ScrapeLocalFailureCode.REPLAY_REJECTED: (
90
+ UpstreamFailureDisposition.PERMANENT,
91
+ UpstreamOperatorAction.FIX_REQUEST,
92
+ ),
93
+ ScrapeLocalFailureCode.CONFIGURATION_INVALID: (
94
+ UpstreamFailureDisposition.OPERATOR_ACTION,
95
+ UpstreamOperatorAction.CONTACT_PROVIDER,
96
+ ),
97
+ ScrapeLocalFailureCode.CACHE_ARTIFACT_MISSING: (
98
+ UpstreamFailureDisposition.RETRYABLE,
99
+ UpstreamOperatorAction.WAIT,
100
+ ),
101
+ ScrapeLocalFailureCode.CALLBACK_DELIVERY_FAILED: (
102
+ UpstreamFailureDisposition.RETRYABLE,
103
+ UpstreamOperatorAction.WAIT,
104
+ ),
105
+ ScrapeLocalFailureCode.ATTEMPT_BUDGET_EXHAUSTED: (
106
+ UpstreamFailureDisposition.OPERATOR_ACTION,
107
+ UpstreamOperatorAction.CONTACT_PROVIDER,
108
+ ),
109
+ ScrapeLocalFailureCode.JOB_DEADLINE_EXCEEDED: (
110
+ UpstreamFailureDisposition.OPERATOR_ACTION,
111
+ UpstreamOperatorAction.CONTACT_PROVIDER,
112
+ ),
113
+ ScrapeLocalFailureCode.OPERATOR_CANCELLED: (
114
+ UpstreamFailureDisposition.PERMANENT,
115
+ UpstreamOperatorAction.NONE,
116
+ ),
117
+ ScrapeLocalFailureCode.UNKNOWN_RUNTIME: (
118
+ UpstreamFailureDisposition.OPERATOR_ACTION,
119
+ UpstreamOperatorAction.CONTACT_PROVIDER,
120
+ ),
121
+ }
122
+
123
+ _LOCAL_ORIGIN = {
124
+ ScrapeLocalFailureCode.AUTH_INVALID: ScrapeLocalFailureOrigin.CALLER_AUTH,
125
+ ScrapeLocalFailureCode.AUTH_FORBIDDEN: ScrapeLocalFailureOrigin.CALLER_AUTH,
126
+ ScrapeLocalFailureCode.VALIDATION_ERROR: ScrapeLocalFailureOrigin.REQUEST_POLICY,
127
+ ScrapeLocalFailureCode.URL_DENIED: ScrapeLocalFailureOrigin.REQUEST_POLICY,
128
+ ScrapeLocalFailureCode.DNS_FAILED: ScrapeLocalFailureOrigin.REQUEST_POLICY,
129
+ ScrapeLocalFailureCode.REDIRECT_LIMIT: ScrapeLocalFailureOrigin.REQUEST_POLICY,
130
+ ScrapeLocalFailureCode.RESPONSE_TOO_LARGE: ScrapeLocalFailureOrigin.REQUEST_POLICY,
131
+ ScrapeLocalFailureCode.CONTENT_TYPE_REJECTED: ScrapeLocalFailureOrigin.REQUEST_POLICY,
132
+ ScrapeLocalFailureCode.OPERATOR_CANCELLED: ScrapeLocalFailureOrigin.OPERATOR,
133
+ }
134
+
135
+ _FETCH_LOCAL_CODES = {
136
+ "URL_DENIED": ScrapeLocalFailureCode.URL_DENIED,
137
+ "DNS_FAILED": ScrapeLocalFailureCode.DNS_FAILED,
138
+ "REDIRECT_LIMIT": ScrapeLocalFailureCode.REDIRECT_LIMIT,
139
+ "RESPONSE_TOO_LARGE": ScrapeLocalFailureCode.RESPONSE_TOO_LARGE,
140
+ "CONTENT_TYPE_REJECTED": ScrapeLocalFailureCode.CONTENT_TYPE_REJECTED,
141
+ "ROUTE_UNAVAILABLE": ScrapeLocalFailureCode.ROUTE_UNAVAILABLE,
142
+ "BUDGET_EXHAUSTED": ScrapeLocalFailureCode.BUDGET_EXHAUSTED,
143
+ "CACHE_VALIDATOR_WITHOUT_ENTRY": ScrapeLocalFailureCode.CACHE_ARTIFACT_MISSING,
144
+ }
145
+
146
+
147
+ def build_local_failure(
148
+ *,
149
+ code: ScrapeLocalFailureCode,
150
+ safe_message: str,
151
+ observed_at: datetime,
152
+ physical_attempt_count: int,
153
+ retry_at: datetime | None = None,
154
+ ) -> ScrapeLocalFailure:
155
+ """Build one local failure from the exhaustive recovery policy tables.
156
+
157
+ Steps:
158
+ 1. select the code's finite disposition and operator action;
159
+ 2. select its local origin or default to scraper runtime;
160
+ 3. retain a future horizon only for retryable local capacity;
161
+ 4. construct the sanitized versioned contract payload.
162
+
163
+ Side effects if changes:
164
+ - API errors, job attempts, cancellation, and callbacks share this mapping;
165
+ - adding a contract code requires an explicit recovery entry above.
166
+ """
167
+
168
+ disposition, action = _LOCAL_RECOVERY[code]
169
+ origin = _LOCAL_ORIGIN.get(code, ScrapeLocalFailureOrigin.SCRAPER_RUNTIME)
170
+ durable_retry_at = (
171
+ ceil_persistence_millisecond(value=retry_at)
172
+ if retry_at is not None and disposition is UpstreamFailureDisposition.RETRYABLE
173
+ else None
174
+ )
175
+ return ScrapeLocalFailure(
176
+ origin=origin,
177
+ code=code,
178
+ disposition=disposition,
179
+ operator_action=action,
180
+ safe_message=safe_message,
181
+ observed_at=observed_at,
182
+ retry_at=durable_retry_at,
183
+ mapping_version="scraper-local-v1",
184
+ physical_attempt_count=physical_attempt_count,
185
+ )
186
+
187
+
188
+ def build_domain_failure(
189
+ *,
190
+ error: ScraperDomainError,
191
+ observed_at: datetime,
192
+ ) -> ScrapeLocalFailure:
193
+ """Convert one API-local domain exception into the public failure type.
194
+
195
+ Steps:
196
+ 1. retain the already finite contract-owned local code;
197
+ 2. retain only the domain error's sanitized message;
198
+ 3. mark that no target/provider request occurred.
199
+
200
+ Side effects if changes:
201
+ - FastAPI error envelopes and preflight attempts use the same conversion;
202
+ - raw exception types and tracebacks never cross the contract boundary.
203
+ """
204
+
205
+ return build_local_failure(
206
+ code=error.code,
207
+ safe_message=error.message,
208
+ observed_at=observed_at,
209
+ physical_attempt_count=0,
210
+ )
211
+
212
+
213
+ def _upstream_origin(*, route: ScrapeTransportRoute) -> ScrapeUpstreamOrigin:
214
+ """Map physical browser calls separately from target HTTP responses.
215
+
216
+ Steps:
217
+ 1. classify Cloudflare browser rendering as a paid browser provider;
218
+ 2. classify ordinary direct/proxy HTTP status as target-resource evidence;
219
+ 3. avoid treating a target response through Bright as Bright billing state.
220
+
221
+ Side effects if changes:
222
+ - payment evidence and supplier cost attribution depend on this distinction.
223
+ """
224
+
225
+ if route is ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN:
226
+ return ScrapeUpstreamOrigin.BROWSER_PROVIDER
227
+ return ScrapeUpstreamOrigin.TARGET_RESOURCE
228
+
229
+
230
+ def _http_classification(
231
+ *,
232
+ status_code: int,
233
+ ) -> tuple[
234
+ UpstreamFailureKind,
235
+ UpstreamFailureDisposition,
236
+ UpstreamOperatorAction,
237
+ str,
238
+ ]:
239
+ """Classify one target/provider HTTP status without inferring payment.
240
+
241
+ Steps:
242
+ 1. distinguish auth, permission, not-found, rate, timeout, and availability;
243
+ 2. keep ambiguous 402 as invalid request rather than supplier payment;
244
+ 3. return a bounded safe message with the finite recovery decision.
245
+
246
+ Side effects if changes:
247
+ - job retry scheduling and Shopify recovery behavior use this classification;
248
+ - supplier payment still requires a separate documented native-code mapper.
249
+ """
250
+
251
+ if status_code == 401:
252
+ return (
253
+ UpstreamFailureKind.AUTHENTICATION_REQUIRED,
254
+ UpstreamFailureDisposition.PERMANENT,
255
+ UpstreamOperatorAction.FIX_REQUEST,
256
+ "Target resource requires authentication not present in this request profile.",
257
+ )
258
+ if status_code == 403:
259
+ return (
260
+ UpstreamFailureKind.TEMPORARILY_UNAVAILABLE,
261
+ UpstreamFailureDisposition.RETRYABLE,
262
+ UpstreamOperatorAction.WAIT,
263
+ "Target or browser provider temporarily refused the request.",
264
+ )
265
+ if status_code in {404, 410}:
266
+ return (
267
+ UpstreamFailureKind.NOT_FOUND,
268
+ UpstreamFailureDisposition.PERMANENT,
269
+ UpstreamOperatorAction.NONE,
270
+ "Target resource is not available.",
271
+ )
272
+ if status_code == 408:
273
+ return (
274
+ UpstreamFailureKind.TIMEOUT,
275
+ UpstreamFailureDisposition.RETRYABLE,
276
+ UpstreamOperatorAction.WAIT,
277
+ "Target request timed out.",
278
+ )
279
+ if status_code in {425, 429}:
280
+ return (
281
+ UpstreamFailureKind.RATE_LIMITED,
282
+ UpstreamFailureDisposition.RETRYABLE,
283
+ UpstreamOperatorAction.WAIT,
284
+ "Target or browser provider requested a bounded delay.",
285
+ )
286
+ if status_code >= 500:
287
+ return (
288
+ UpstreamFailureKind.TEMPORARILY_UNAVAILABLE,
289
+ UpstreamFailureDisposition.RETRYABLE,
290
+ UpstreamOperatorAction.WAIT,
291
+ "Target or browser provider is temporarily unavailable.",
292
+ )
293
+ if 400 <= status_code < 500:
294
+ return (
295
+ UpstreamFailureKind.INVALID_REQUEST,
296
+ UpstreamFailureDisposition.PERMANENT,
297
+ UpstreamOperatorAction.FIX_REQUEST,
298
+ "Target or browser provider rejected the request.",
299
+ )
300
+ return (
301
+ UpstreamFailureKind.INVALID_RESPONSE,
302
+ UpstreamFailureDisposition.PERMANENT,
303
+ UpstreamOperatorAction.CONTACT_PROVIDER,
304
+ "Target or browser provider returned an invalid response status.",
305
+ )
306
+
307
+
308
+ def build_fetch_failure(*, result: FetchResult) -> ScrapeFailure:
309
+ """Convert one non-success fetch result into local or upstream evidence.
310
+
311
+ Steps:
312
+ 1. map known policy/capacity codes to finite local failures;
313
+ 2. map HTTP, timeout, and network outcomes to upstream target/provider truth;
314
+ 3. map any remaining runtime outcome to a finite unknown local failure.
315
+
316
+ Side effects if changes:
317
+ - append-only attempts, retry decisions, and terminal snapshots use it;
318
+ - arbitrary fetcher error strings cannot escape onto the public wire.
319
+ """
320
+
321
+ local_code = _FETCH_LOCAL_CODES.get(result.error_code or "")
322
+ if local_code is not None:
323
+ return build_local_failure(
324
+ code=local_code,
325
+ safe_message="Scraper request could not proceed under local policy.",
326
+ observed_at=result.observed_at,
327
+ physical_attempt_count=(
328
+ 1
329
+ if local_code
330
+ in {
331
+ ScrapeLocalFailureCode.RESPONSE_TOO_LARGE,
332
+ ScrapeLocalFailureCode.CONTENT_TYPE_REJECTED,
333
+ }
334
+ else 0
335
+ ),
336
+ retry_at=result.retry_at,
337
+ )
338
+
339
+ if result.status_code is not None and result.status_code >= 400:
340
+ kind, disposition, action, message = _http_classification(
341
+ status_code=result.status_code
342
+ )
343
+ return ScrapeUpstreamFailure(
344
+ origin=_upstream_origin(route=result.route),
345
+ kind=kind,
346
+ disposition=disposition,
347
+ operator_action=action,
348
+ safe_message=message,
349
+ observed_at=result.observed_at,
350
+ mapping_version="scraper-http-v1",
351
+ physical_attempt_count=1,
352
+ retry_at=(
353
+ ceil_persistence_millisecond(value=result.retry_at)
354
+ if result.retry_at is not None
355
+ and disposition is UpstreamFailureDisposition.RETRYABLE
356
+ else None
357
+ ),
358
+ upstream_http_status=result.status_code,
359
+ upstream_code=result.upstream_code or f"HTTP_{result.status_code}",
360
+ )
361
+
362
+ if result.outcome is ScrapeAttemptOutcome.TIMEOUT:
363
+ kind = UpstreamFailureKind.TIMEOUT
364
+ message = "Target request timed out."
365
+ elif result.outcome is ScrapeAttemptOutcome.NETWORK_ERROR:
366
+ kind = UpstreamFailureKind.TEMPORARILY_UNAVAILABLE
367
+ message = "Target resource is temporarily unreachable."
368
+ else:
369
+ return build_local_failure(
370
+ code=ScrapeLocalFailureCode.UNKNOWN_RUNTIME,
371
+ safe_message="Scraper attempt failed without classified upstream evidence.",
372
+ observed_at=result.observed_at,
373
+ physical_attempt_count=0,
374
+ )
375
+ return ScrapeUpstreamFailure(
376
+ origin=_upstream_origin(route=result.route),
377
+ kind=kind,
378
+ disposition=UpstreamFailureDisposition.RETRYABLE,
379
+ operator_action=UpstreamOperatorAction.WAIT,
380
+ safe_message=message,
381
+ observed_at=result.observed_at,
382
+ mapping_version="scraper-transport-v1",
383
+ physical_attempt_count=1,
384
+ retry_at=(
385
+ ceil_persistence_millisecond(value=result.retry_at)
386
+ if result.retry_at is not None
387
+ else None
388
+ ),
389
+ )
390
+
391
+
392
+ def terminalize_failure(*, failure: ScrapeFailure) -> ScrapeFailure:
393
+ """Convert a retryable cause into terminal attempt-budget evidence.
394
+
395
+ Steps:
396
+ 1. preserve already terminal/operator failures unchanged;
397
+ 2. retain upstream origin/kind/evidence while clearing retry timing;
398
+ 3. require operator inspection after the bounded attempt budget is spent.
399
+
400
+ Side effects if changes:
401
+ - dead-letter job/callback snapshots remain honest about their final cause;
402
+ - immutable attempt rows keep the original retryable classification.
403
+ """
404
+
405
+ if failure.disposition is not UpstreamFailureDisposition.RETRYABLE:
406
+ return failure
407
+ if isinstance(failure, ScrapeLocalFailure):
408
+ return build_local_failure(
409
+ code=ScrapeLocalFailureCode.ATTEMPT_BUDGET_EXHAUSTED,
410
+ safe_message="Automatic scraper attempt budget is exhausted.",
411
+ observed_at=failure.observed_at,
412
+ physical_attempt_count=failure.physical_attempt_count,
413
+ )
414
+ return ScrapeUpstreamFailure(
415
+ origin=failure.origin,
416
+ kind=failure.kind,
417
+ disposition=UpstreamFailureDisposition.OPERATOR_ACTION,
418
+ operator_action=UpstreamOperatorAction.CONTACT_PROVIDER,
419
+ safe_message="Automatic scraper attempt budget is exhausted after upstream failure.",
420
+ observed_at=failure.observed_at,
421
+ mapping_version=failure.mapping_version,
422
+ physical_attempt_count=failure.physical_attempt_count,
423
+ capacity=failure.capacity,
424
+ upstream_http_status=failure.upstream_http_status,
425
+ upstream_code=failure.upstream_code,
426
+ upstream_request_id=failure.upstream_request_id,
427
+ )
@@ -0,0 +1,72 @@
1
+ """Application-owned prepared request and fetch-result types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+
7
+ from keble_helpers.typings import PydanticModelConfig
8
+ from pydantic import AwareDatetime, BaseModel, Field
9
+
10
+ from keble_scraper_contract import (
11
+ ScrapeArtifactTrust,
12
+ ScrapeAttemptOutcome,
13
+ ScrapeTransportRoute,
14
+ )
15
+
16
+
17
+ class PreparedFetch(BaseModel):
18
+ """Server-assembled outbound request with no caller credential surface.
19
+
20
+ Side effects if changes:
21
+ - URL policy, HTTP fetching, cache identity, and Shopify GraphQL safety.
22
+ """
23
+
24
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
25
+
26
+ url: str
27
+ method: str
28
+ headers: dict[str, str] = Field(default_factory=dict)
29
+ body: bytes | None = None
30
+ request_identity: str
31
+
32
+
33
+ class FetchResult(BaseModel):
34
+ """Typed expected transport outcome with unexpected faults left to supervision.
35
+
36
+ Steps:
37
+ 1. retain the physical route/status/body and measured duration/cost units;
38
+ 2. retain only bounded local/native codes plus a parsed future retry horizon;
39
+ 3. convert expected evidence into the public discriminated failure contract;
40
+ 4. let unexpected client/runtime faults propagate without becoming result data.
41
+
42
+ Side effects if changes:
43
+ - retry scheduling, immutable attempts, cache writes, and artifact creation.
44
+ """
45
+
46
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
47
+
48
+ outcome: ScrapeAttemptOutcome
49
+ route: ScrapeTransportRoute
50
+ trust: ScrapeArtifactTrust
51
+ final_url: str
52
+ status_code: int | None = None
53
+ content_type: str | None = None
54
+ body: bytes = b""
55
+ etag: str | None = None
56
+ last_modified: str | None = None
57
+ elapsed_ms: int = Field(ge=0)
58
+ browser_seconds: Decimal = Field(default=Decimal("0"), ge=0)
59
+ error_code: str | None = None
60
+ upstream_code: str | None = Field(default=None, max_length=128)
61
+ retry_at: AwareDatetime | None = None
62
+ observed_at: AwareDatetime
63
+
64
+ @property
65
+ def succeeded(self) -> bool:
66
+ """Return whether content/metadata may proceed to artifact handling.
67
+
68
+ Side effects if changes:
69
+ - worker success, cache update, and retry behavior.
70
+ """
71
+
72
+ return self.outcome is ScrapeAttemptOutcome.SUCCEEDED