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,473 @@
|
|
|
1
|
+
"""Persistence-owned mutable models and structural conversions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Literal, TypeAlias
|
|
9
|
+
from uuid import UUID, uuid5
|
|
10
|
+
|
|
11
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
12
|
+
from pydantic import AwareDatetime, BaseModel, Field, SecretStr
|
|
13
|
+
|
|
14
|
+
from keble_scraper_contract import (
|
|
15
|
+
ProxyCandidateSource,
|
|
16
|
+
ProxyCandidateState,
|
|
17
|
+
ProxyHealthObservation,
|
|
18
|
+
ScrapeArtifactRef,
|
|
19
|
+
ScrapeAttemptView,
|
|
20
|
+
ScrapeCompletionEvent,
|
|
21
|
+
ScrapeCost,
|
|
22
|
+
ScrapeFailure,
|
|
23
|
+
ScrapeJobCreate,
|
|
24
|
+
ScrapeJobState,
|
|
25
|
+
ScrapeJobView,
|
|
26
|
+
ScrapeLocalFailureCode,
|
|
27
|
+
ScrapeRetentionClass,
|
|
28
|
+
ScrapeTransportPolicy,
|
|
29
|
+
ScrapeTransportRoute,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_COMPLETION_EVENT_NAMESPACE = UUID("6fe96d16-f8d8-4742-ae72-a20922cf72fd")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PersistenceModel(BaseModel):
|
|
36
|
+
"""Mutable internal model with strict snake-case persistence fields.
|
|
37
|
+
|
|
38
|
+
Side effects if changes:
|
|
39
|
+
- all Mongo document validation and update serialization.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = PydanticModelConfig.default(extra="forbid", validate_default=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ScrapeJobMongoObject(PersistenceModel):
|
|
46
|
+
"""Canonical mutable lifecycle document for `scrape_jobs`.
|
|
47
|
+
|
|
48
|
+
Steps:
|
|
49
|
+
1. retain immutable command, attempt limit, and deadline facts;
|
|
50
|
+
2. retain mutable lease/retry/terminal lifecycle state;
|
|
51
|
+
3. convert with immutable attempts into the public view.
|
|
52
|
+
|
|
53
|
+
Side effects if changes:
|
|
54
|
+
- idempotent creation, leases, optimistic cancellation, and job reads.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
job_id: UUID
|
|
58
|
+
caller_identity: str
|
|
59
|
+
create: ScrapeJobCreate
|
|
60
|
+
request_hash: str
|
|
61
|
+
state: ScrapeJobState
|
|
62
|
+
revision: int = Field(ge=1)
|
|
63
|
+
attempt_count: int = Field(default=0, ge=0)
|
|
64
|
+
attempt_limit: int = Field(ge=1, le=10)
|
|
65
|
+
deadline_at: datetime
|
|
66
|
+
artifact_ids: list[UUID] = Field(default_factory=list)
|
|
67
|
+
total_cost: ScrapeCost = Field(default_factory=ScrapeCost)
|
|
68
|
+
created_at: datetime
|
|
69
|
+
updated_at: datetime
|
|
70
|
+
next_attempt_at: datetime | None = None
|
|
71
|
+
lease_owner: str | None = None
|
|
72
|
+
lease_expires_at: datetime | None = None
|
|
73
|
+
terminal_failure: ScrapeFailure | None = None
|
|
74
|
+
history_expires_at: datetime | None = None
|
|
75
|
+
|
|
76
|
+
def to_view(self, *, attempts: tuple[ScrapeAttemptView, ...]) -> ScrapeJobView:
|
|
77
|
+
"""Convert Mongo lifecycle plus immutable attempts into the wire view.
|
|
78
|
+
|
|
79
|
+
Steps:
|
|
80
|
+
1. copy immutable command and stored budget facts;
|
|
81
|
+
2. attach caller-ordered immutable attempts;
|
|
82
|
+
3. expose terminal/retry/lease state without persistence-only ownership fields.
|
|
83
|
+
|
|
84
|
+
Side effects if changes:
|
|
85
|
+
- API create/read/cancel responses and Shopify polling.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
return ScrapeJobView(
|
|
89
|
+
job_id=self.job_id,
|
|
90
|
+
caller_identity=self.caller_identity,
|
|
91
|
+
create=self.create,
|
|
92
|
+
state=self.state,
|
|
93
|
+
revision=self.revision,
|
|
94
|
+
attempt_limit=self.attempt_limit,
|
|
95
|
+
deadline_at=self.deadline_at,
|
|
96
|
+
attempts=attempts,
|
|
97
|
+
artifact_ids=tuple(self.artifact_ids),
|
|
98
|
+
total_cost=self.total_cost,
|
|
99
|
+
created_at=self.created_at,
|
|
100
|
+
updated_at=self.updated_at,
|
|
101
|
+
next_attempt_at=self.next_attempt_at,
|
|
102
|
+
lease_expires_at=self.lease_expires_at,
|
|
103
|
+
terminal_failure=self.terminal_failure,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ScrapeAttemptMongoObject(PersistenceModel):
|
|
108
|
+
"""Immutable attempt row stored in `scrape_attempts`.
|
|
109
|
+
|
|
110
|
+
Side effects if changes:
|
|
111
|
+
- attempt ordering, retry evidence, and cost reconciliation.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
job_id: UUID
|
|
115
|
+
attempt: ScrapeAttemptView
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ScrapeCacheEntryMongoObject(PersistenceModel):
|
|
119
|
+
"""Validated transport cache pointer stored in `scrape_cache_entries`.
|
|
120
|
+
|
|
121
|
+
Side effects if changes:
|
|
122
|
+
- cache-mode behavior, conditional headers, and artifact retention.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
request_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
126
|
+
artifact_ids: list[UUID]
|
|
127
|
+
etag: str | None = None
|
|
128
|
+
last_modified: str | None = None
|
|
129
|
+
response_status: int = Field(ge=100, le=599)
|
|
130
|
+
content_type: str
|
|
131
|
+
expires_at: datetime
|
|
132
|
+
updated_at: datetime
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ArtifactBatchLookup(PersistenceModel):
|
|
136
|
+
"""Resolve ordered cache artifacts without converting absence into an exception.
|
|
137
|
+
|
|
138
|
+
Steps:
|
|
139
|
+
1. retain artifacts in the exact cache-pointer order;
|
|
140
|
+
2. retain every missing identity separately;
|
|
141
|
+
3. let the execution service invalidate stale cache state before fetching.
|
|
142
|
+
|
|
143
|
+
Side effects if changes:
|
|
144
|
+
- stale cache pointers must never enter paid retry crash loops;
|
|
145
|
+
- cache completion and 304 handling consume this exact result.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
artifacts: tuple[ScrapeArtifactRef, ...]
|
|
149
|
+
missing_artifact_ids: tuple[UUID, ...]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ScrapeArtifactMongoObject(PersistenceModel):
|
|
153
|
+
"""Private object metadata and reference count in `scrape_artifacts`.
|
|
154
|
+
|
|
155
|
+
Side effects if changes:
|
|
156
|
+
- content deduplication, signed downloads, pins, and orphan deletion.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
artifact: ScrapeArtifactRef
|
|
160
|
+
object_key: str
|
|
161
|
+
reference_count: int = Field(default=0, ge=0)
|
|
162
|
+
deletion_lease_id: UUID | None = None
|
|
163
|
+
deletion_lease_expires_at: datetime | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ScrapeArtifactPinMongoObject(PersistenceModel):
|
|
167
|
+
"""Version-fenced downstream retention reference for one artifact.
|
|
168
|
+
|
|
169
|
+
Steps:
|
|
170
|
+
1. Persist one stable artifact-and-owner identity exactly once.
|
|
171
|
+
2. Advance expiry only with a newer retention revision.
|
|
172
|
+
3. Retain creation and last-policy-change timestamps for audit evidence.
|
|
173
|
+
|
|
174
|
+
Side effects if changes:
|
|
175
|
+
- artifact reference counts and orphan eligibility;
|
|
176
|
+
- Shopify latest-to-archive retention synchronization.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
artifact_id: UUID
|
|
180
|
+
owner_identity: str
|
|
181
|
+
retention_class: ScrapeRetentionClass
|
|
182
|
+
retention_revision: int = Field(ge=1)
|
|
183
|
+
expires_at: datetime | None = None
|
|
184
|
+
created_at: datetime
|
|
185
|
+
updated_at: datetime
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ProxyCandidateMongoObject(PersistenceModel):
|
|
189
|
+
"""Encrypted proxy candidate and bounded health summary.
|
|
190
|
+
|
|
191
|
+
Steps:
|
|
192
|
+
1. retain a redacted endpoint beside encrypted credentials;
|
|
193
|
+
2. retain bounded health observations and score;
|
|
194
|
+
3. schedule the next quarantined health check.
|
|
195
|
+
|
|
196
|
+
Side effects if changes:
|
|
197
|
+
- experimental proxy selection, health decay, and credential redaction.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
candidate_id: UUID
|
|
201
|
+
endpoint_redacted: str
|
|
202
|
+
encrypted_proxy_url: str
|
|
203
|
+
source: ProxyCandidateSource
|
|
204
|
+
source_reference: str
|
|
205
|
+
state: ProxyCandidateState
|
|
206
|
+
health_score: Decimal = Field(ge=0, le=1)
|
|
207
|
+
observations: list[ProxyHealthObservation] = Field(
|
|
208
|
+
default_factory=list, max_length=20
|
|
209
|
+
)
|
|
210
|
+
next_health_at: datetime
|
|
211
|
+
created_at: datetime
|
|
212
|
+
updated_at: datetime
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class OutboxState(StrEnum):
|
|
216
|
+
"""Finite callback outbox lifecycle.
|
|
217
|
+
|
|
218
|
+
Side effects if changes:
|
|
219
|
+
- callback retry, replay identity, and stale delivery repair.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
PENDING = "PENDING"
|
|
223
|
+
LEASED = "LEASED"
|
|
224
|
+
DELIVERED = "DELIVERED"
|
|
225
|
+
DEAD_LETTER = "DEAD_LETTER"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ScrapeOutboxMongoObject(PersistenceModel):
|
|
229
|
+
"""Durable signed callback delivery row in `scrape_outbox`.
|
|
230
|
+
|
|
231
|
+
Steps:
|
|
232
|
+
1. retain one deterministic completion event identity;
|
|
233
|
+
2. own immutable attempt limit and mutable lease state;
|
|
234
|
+
3. retain due/delivery timing for recovery and acknowledgement.
|
|
235
|
+
|
|
236
|
+
Side effects if changes:
|
|
237
|
+
- webhook delivery retries and exactly-once event identity.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
event: ScrapeCompletionEvent
|
|
241
|
+
state: OutboxState = OutboxState.PENDING
|
|
242
|
+
attempt_count: int = Field(default=0, ge=0)
|
|
243
|
+
attempt_limit: int = Field(ge=1, le=10)
|
|
244
|
+
next_attempt_at: datetime
|
|
245
|
+
lease_owner: str | None = None
|
|
246
|
+
lease_expires_at: datetime | None = None
|
|
247
|
+
delivered_at: datetime | None = None
|
|
248
|
+
history_expires_at: datetime | None = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class DispatchableJob(PersistenceModel):
|
|
252
|
+
"""Minimal due-job projection required for queue publication.
|
|
253
|
+
|
|
254
|
+
Steps:
|
|
255
|
+
1. retain the opaque job identity;
|
|
256
|
+
2. retain only the queue-selecting transport policy;
|
|
257
|
+
3. exclude attempts, artifacts, callbacks, and request bodies.
|
|
258
|
+
|
|
259
|
+
Side effects if changes:
|
|
260
|
+
- maintenance batch reads and Celery queue selection share this projection.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
job_id: UUID
|
|
264
|
+
transport_policy: ScrapeTransportPolicy
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class LeaseRecoverySummary(PersistenceModel):
|
|
268
|
+
"""Count retryable and terminalized rows from one bounded recovery sweep.
|
|
269
|
+
|
|
270
|
+
Steps:
|
|
271
|
+
1. count leases returned to durable retry state;
|
|
272
|
+
2. count rows terminalized by immutable attempt/deadline policy;
|
|
273
|
+
3. expose no individual job payloads to maintenance metrics.
|
|
274
|
+
|
|
275
|
+
Side effects if changes:
|
|
276
|
+
- maintenance telemetry and recovery acceptance tests use these counters.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
recovered_count: int = Field(default=0, ge=0)
|
|
280
|
+
terminalized_count: int = Field(default=0, ge=0)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class HistoryRetentionSummary(PersistenceModel):
|
|
284
|
+
"""Report one singleton, bounded history-retention transaction.
|
|
285
|
+
|
|
286
|
+
Steps:
|
|
287
|
+
1. disclose whether this caller acquired the retention lease;
|
|
288
|
+
2. count deleted terminal parents and immutable attempt children;
|
|
289
|
+
3. count independently expired delivered callbacks and audit facts.
|
|
290
|
+
|
|
291
|
+
Side effects if changes:
|
|
292
|
+
- maintenance telemetry and retention-capacity tests consume these counters.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
lease_acquired: bool
|
|
296
|
+
jobs_deleted: int = Field(default=0, ge=0)
|
|
297
|
+
attempts_deleted: int = Field(default=0, ge=0)
|
|
298
|
+
delivered_outbox_deleted: int = Field(default=0, ge=0)
|
|
299
|
+
audit_events_deleted: int = Field(default=0, ge=0)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AuditAction(StrEnum):
|
|
303
|
+
"""Audited operator/service side-effect family.
|
|
304
|
+
|
|
305
|
+
Side effects if changes:
|
|
306
|
+
- compliance exports and raw-download accountability.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
JOB_CREATE = "JOB_CREATE"
|
|
310
|
+
JOB_CANCEL = "JOB_CANCEL"
|
|
311
|
+
ARTIFACT_DOWNLOAD = "ARTIFACT_DOWNLOAD"
|
|
312
|
+
ARTIFACT_PIN = "ARTIFACT_PIN"
|
|
313
|
+
PROXY_IMPORT = "PROXY_IMPORT"
|
|
314
|
+
CLEANUP_RUN = "CLEANUP_RUN"
|
|
315
|
+
BUDGET_UPDATE = "BUDGET_UPDATE"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ScrapeAuditEventMongoObject(PersistenceModel):
|
|
319
|
+
"""Immutable operator/service audit fact in `scrape_audit_events`.
|
|
320
|
+
|
|
321
|
+
Steps:
|
|
322
|
+
1. retain the server-owned actor/action/target evidence;
|
|
323
|
+
2. retain one occurrence time and absolute history-expiry clock;
|
|
324
|
+
3. expose no credential or arbitrary provider payload.
|
|
325
|
+
|
|
326
|
+
Side effects if changes:
|
|
327
|
+
- security investigations, operations UI history, and bounded audit storage.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
audit_id: UUID
|
|
331
|
+
actor_id: str
|
|
332
|
+
actor_role: str
|
|
333
|
+
action: AuditAction
|
|
334
|
+
target_identity: str
|
|
335
|
+
reason: str
|
|
336
|
+
request_id: str
|
|
337
|
+
occurred_at: datetime
|
|
338
|
+
history_expires_at: datetime | None = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class FetchLease(PersistenceModel):
|
|
342
|
+
"""Worker-owned job lease and immutable attempt ordinal.
|
|
343
|
+
|
|
344
|
+
Side effects if changes:
|
|
345
|
+
- late acknowledgement and lease-expiry recovery semantics.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
job: ScrapeJobMongoObject
|
|
349
|
+
attempt_number: int = Field(ge=1)
|
|
350
|
+
lease_owner: str
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class RouteReservation(PersistenceModel):
|
|
354
|
+
"""Secret-bearing internal transport decision excluded from API models.
|
|
355
|
+
|
|
356
|
+
Side effects if changes:
|
|
357
|
+
- fetch egress, affinity, trust, and budget accounting.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
route: ScrapeTransportRoute
|
|
361
|
+
proxy_url: SecretStr | None = None
|
|
362
|
+
session_id: str | None = None
|
|
363
|
+
reserved_cost_usd: Decimal = Field(default=Decimal("0"), ge=0)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class RouteReservationGranted(PersistenceModel):
|
|
367
|
+
"""Successful preflight decision containing one priced route reservation.
|
|
368
|
+
|
|
369
|
+
Steps:
|
|
370
|
+
1. discriminate the granted path explicitly;
|
|
371
|
+
2. retain the secret-bearing reservation only inside the worker process;
|
|
372
|
+
3. exclude failure/error objects from normal control flow.
|
|
373
|
+
|
|
374
|
+
Side effects if changes:
|
|
375
|
+
- execution and route tests consume this exact granted branch.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
decision: Literal["GRANTED"] = "GRANTED"
|
|
379
|
+
reservation: RouteReservation
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class RouteReservationDenied(PersistenceModel):
|
|
383
|
+
"""Expected local route/cost denial represented without an exception object.
|
|
384
|
+
|
|
385
|
+
Steps:
|
|
386
|
+
1. discriminate the denied path explicitly;
|
|
387
|
+
2. retain the attempted route and finite local failure code;
|
|
388
|
+
3. retain one bounded safe message for the typed attempt conversion.
|
|
389
|
+
|
|
390
|
+
Side effects if changes:
|
|
391
|
+
- zero-provider-attempt audit rows and bounded retry decisions depend on this branch.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
decision: Literal["DENIED"] = "DENIED"
|
|
395
|
+
route: ScrapeTransportRoute
|
|
396
|
+
code: ScrapeLocalFailureCode
|
|
397
|
+
safe_message: str = Field(min_length=1, max_length=512)
|
|
398
|
+
retry_at: AwareDatetime | None = None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
RouteReservationOutcome: TypeAlias = RouteReservationGranted | RouteReservationDenied
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class CallbackTransportDisposition(StrEnum):
|
|
405
|
+
"""Finite registered-callback HTTP outcome returned without exceptions-as-data.
|
|
406
|
+
|
|
407
|
+
Steps:
|
|
408
|
+
1. distinguish acknowledged delivery from HTTP retryability;
|
|
409
|
+
2. keep permanent receiver rejection separate from transient capacity;
|
|
410
|
+
3. leave client/runtime exceptions outside this expected-result vocabulary.
|
|
411
|
+
|
|
412
|
+
Side effects if changes:
|
|
413
|
+
- outbox acknowledgement, backoff, and dead-letter timing.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
DELIVERED = "DELIVERED"
|
|
417
|
+
RETRYABLE = "RETRYABLE"
|
|
418
|
+
PERMANENT = "PERMANENT"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class CallbackTransportResult(PersistenceModel):
|
|
422
|
+
"""One sanitized HTTP callback result with an optional provider horizon.
|
|
423
|
+
|
|
424
|
+
Steps:
|
|
425
|
+
1. retain only a finite disposition and bounded status evidence;
|
|
426
|
+
2. retain a parsed future retry time when the receiver supplies one;
|
|
427
|
+
3. omit response bodies, URLs, and credentials from durable control flow.
|
|
428
|
+
|
|
429
|
+
Side effects if changes:
|
|
430
|
+
- callback retries consume this exact expected transport contract.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
disposition: CallbackTransportDisposition
|
|
434
|
+
status_code: int = Field(ge=100, le=599)
|
|
435
|
+
retry_at: datetime | None = None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def build_completion_event(
|
|
439
|
+
*,
|
|
440
|
+
job: ScrapeJobMongoObject,
|
|
441
|
+
state: ScrapeJobState,
|
|
442
|
+
artifacts: tuple[ScrapeArtifactRef, ...],
|
|
443
|
+
failure: ScrapeFailure | None,
|
|
444
|
+
occurred_at: datetime,
|
|
445
|
+
) -> ScrapeCompletionEvent | None:
|
|
446
|
+
"""Convert one terminal stored job into its deterministic callback event.
|
|
447
|
+
|
|
448
|
+
Steps:
|
|
449
|
+
1. return no event when the command registered no callback audience;
|
|
450
|
+
2. derive one stable event identity from job and terminal state;
|
|
451
|
+
3. copy only registered correlation, artifact, and typed-failure evidence.
|
|
452
|
+
|
|
453
|
+
Side effects if changes:
|
|
454
|
+
- success, retry exhaustion, and lease recovery must emit the same outbox identity;
|
|
455
|
+
- callback HMAC replay protection depends on deterministic event IDs.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
command = job.create
|
|
459
|
+
if command.callback_identity is None or command.callback_correlation_id is None:
|
|
460
|
+
return None
|
|
461
|
+
return ScrapeCompletionEvent(
|
|
462
|
+
event_id=uuid5(
|
|
463
|
+
_COMPLETION_EVENT_NAMESPACE,
|
|
464
|
+
f"{job.job_id}:{state.value}",
|
|
465
|
+
),
|
|
466
|
+
audience=command.callback_identity,
|
|
467
|
+
job_id=job.job_id,
|
|
468
|
+
correlation_id=command.callback_correlation_id,
|
|
469
|
+
state=state,
|
|
470
|
+
artifacts=artifacts,
|
|
471
|
+
failure=failure,
|
|
472
|
+
occurred_at=occurred_at,
|
|
473
|
+
)
|