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.
- keble_scraper_api/__init__.py +3 -0
- keble_scraper_api/api/dependencies.py +100 -0
- keble_scraper_api/api/errors.py +72 -0
- keble_scraper_api/api/router.py +473 -0
- keble_scraper_api/api/schemas.py +26 -0
- keble_scraper_api/application/admin.py +285 -0
- keble_scraper_api/application/artifacts.py +393 -0
- keble_scraper_api/application/auth.py +324 -0
- keble_scraper_api/application/failures.py +427 -0
- keble_scraper_api/application/fetching.py +72 -0
- keble_scraper_api/application/jobs.py +891 -0
- keble_scraper_api/application/proxy_health.py +126 -0
- keble_scraper_api/application/scheduling.py +24 -0
- keble_scraper_api/artifact_namespace.py +76 -0
- keble_scraper_api/container.py +361 -0
- keble_scraper_api/errors.py +25 -0
- keble_scraper_api/infrastructure/artifact_store.py +585 -0
- keble_scraper_api/infrastructure/callbacks.py +285 -0
- keble_scraper_api/infrastructure/celery_factory.py +63 -0
- keble_scraper_api/infrastructure/cloudflare.py +259 -0
- keble_scraper_api/infrastructure/credentials.py +75 -0
- keble_scraper_api/infrastructure/dispatcher.py +57 -0
- keble_scraper_api/infrastructure/http_fetcher.py +351 -0
- keble_scraper_api/infrastructure/mongo.py +2345 -0
- keble_scraper_api/infrastructure/proxy_router.py +460 -0
- keble_scraper_api/infrastructure/request_profiles.py +220 -0
- keble_scraper_api/infrastructure/url_policy.py +341 -0
- keble_scraper_api/main.py +162 -0
- keble_scraper_api/maintenance/__init__.py +1 -0
- keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
- keble_scraper_api/models.py +473 -0
- keble_scraper_api/protocols.py +947 -0
- keble_scraper_api/settings.py +484 -0
- keble_scraper_api/telemetry.py +109 -0
- keble_scraper_api/workers/celery_app.py +48 -0
- keble_scraper_api/workers/cli.py +106 -0
- keble_scraper_api/workers/runtime.py +78 -0
- keble_scraper_api/workers/tasks.py +97 -0
- keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
- keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
- keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
- 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
|