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,484 @@
1
+ """Environment-only typed runtime configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+
9
+ from keble_helpers.typings import PydanticModelConfig
10
+ from pydantic import (
11
+ AnyHttpUrl,
12
+ BaseModel,
13
+ Field,
14
+ SecretStr,
15
+ TypeAdapter,
16
+ field_validator,
17
+ model_validator,
18
+ )
19
+ from pydantic_settings import BaseSettings, SettingsConfigDict
20
+
21
+ from keble_scraper_contract import OperatorRole
22
+
23
+
24
+ class RuntimeEnvironment(StrEnum):
25
+ """Finite deployment environments with explicit auth behavior.
26
+
27
+ Side effects if changes:
28
+ - production startup validation and development auth bypass.
29
+ """
30
+
31
+ DEVELOPMENT = "development"
32
+ TEST = "test"
33
+ STAGING = "staging"
34
+ PRODUCTION = "production"
35
+
36
+
37
+ class ResponsePolicy(BaseModel):
38
+ """Server-owned content-type and streaming byte limits.
39
+
40
+ Side effects if changes:
41
+ - secure fetcher acceptance and artifact storage bounds.
42
+ """
43
+
44
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
45
+
46
+ max_bytes: int = Field(ge=1, le=1_000_000_000)
47
+ content_type_prefixes: tuple[str, ...]
48
+
49
+
50
+ class ScraperSettings(BaseSettings):
51
+ """Single validated settings source for API, workers, beat, and routing.
52
+
53
+ Steps:
54
+ 1. parse environment/Infisical-compatible values into strong types;
55
+ 2. normalize optional blanks and reject partial credential tuples;
56
+ 3. expose immutable runtime, capacity, cost, and ownership policy.
57
+
58
+ Side effects if changes:
59
+ - Mongo/Redis/Rabbit/OSS ownership and queue names;
60
+ - vendor credentials, spend gates, auth, callbacks, and Sentry startup.
61
+ """
62
+
63
+ model_config = SettingsConfigDict(
64
+ env_prefix="SCRAPER_",
65
+ env_file=".env",
66
+ extra="ignore",
67
+ frozen=True,
68
+ )
69
+
70
+ environment: RuntimeEnvironment = RuntimeEnvironment.DEVELOPMENT
71
+ host: str = "0.0.0.0"
72
+ port: int = Field(default=8080, ge=1, le=65_535)
73
+ frontend_dist: str = "/app/frontend/dist"
74
+ public_base_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8080")
75
+ mongo_uri: SecretStr = SecretStr("mongodb://localhost:27017/?replicaSet=rs0")
76
+ mongo_database: str = "keble_scraper"
77
+ redis_uri: SecretStr = SecretStr("redis://localhost:9736/0")
78
+ redis_namespace: str = "keble-scraper"
79
+ rabbitmq_uri: SecretStr = SecretStr(
80
+ "amqp://guest:guest@localhost:5672/keble-scraper"
81
+ )
82
+ fetch_queue: str = "keble-scraper.fetch"
83
+ render_queue: str = "keble-scraper.render"
84
+ proxy_health_queue: str = "keble-scraper.proxy-health"
85
+ maintenance_queue: str = "keble-scraper.maintenance"
86
+ fetch_worker_concurrency: int = Field(default=8, ge=1, le=128)
87
+ render_worker_concurrency: int = Field(default=1, ge=1, le=16)
88
+ proxy_health_worker_concurrency: int = Field(default=4, ge=1, le=64)
89
+ maintenance_worker_concurrency: int = Field(default=2, ge=1, le=32)
90
+ worker_lease_seconds: int = Field(default=300, ge=30, le=3_600)
91
+ max_attempts: int = Field(default=4, ge=1, le=10)
92
+ job_deadline_seconds: int = Field(default=259_200, ge=300, le=604_800)
93
+ retry_base_seconds: int = Field(default=30, ge=1, le=3_600)
94
+ upstream_rate_limit_default_seconds: int = Field(
95
+ default=60,
96
+ ge=1,
97
+ le=86_400,
98
+ )
99
+ maintenance_dispatch_concurrency: int = Field(default=32, ge=1, le=128)
100
+ terminal_history_retention_days: int = Field(default=90, ge=30, le=3_650)
101
+ delivered_outbox_retention_days: int = Field(default=30, ge=7, le=3_650)
102
+ audit_history_retention_days: int = Field(default=400, ge=90, le=3_650)
103
+ history_retention_batch_size: int = Field(default=1_000, ge=1, le=10_000)
104
+ history_retention_lease_seconds: int = Field(default=300, ge=30, le=3_600)
105
+ connect_timeout_seconds: float = Field(default=10, gt=0, le=120)
106
+ read_timeout_seconds: float = Field(default=30, gt=0, le=600)
107
+ max_redirects: int = Field(default=5, ge=0, le=10)
108
+ identified_user_agent: str = "KebleDataBot/0.1 (+https://keble.ai/data-crawler)"
109
+ document_max_bytes: int = Field(default=10_000_000, ge=1)
110
+ json_max_bytes: int = Field(default=20_000_000, ge=1)
111
+ sitemap_max_bytes: int = Field(default=50_000_000, ge=1)
112
+ media_max_bytes: int = Field(default=100_000_000, ge=1)
113
+ service_key_current: SecretStr = SecretStr("development-service-key-change-me")
114
+ service_key_next: SecretStr | None = None
115
+ service_caller_identity: str = "keble-shopify"
116
+ callback_hmac_current: SecretStr = SecretStr("development-hmac-key-change-me")
117
+ callback_hmac_next: SecretStr | None = None
118
+ callback_replay_seconds: int = Field(default=300, ge=30, le=3_600)
119
+ shopify_callback_url: AnyHttpUrl | None = None
120
+ session_fernet_key: SecretStr | None = None
121
+ session_cookie_name: str = "keble_scraper_session"
122
+ session_hours: int = Field(default=8, ge=1, le=24)
123
+ google_client_id: str | None = None
124
+ truth_base_url: AnyHttpUrl | None = None
125
+ truth_auth_path: str = "/auth/google/workspace/exchange"
126
+ truth_timeout_seconds: float = Field(default=10, gt=0, le=60)
127
+ dev_auth_enabled: bool = False
128
+ dev_auth_email: str = "local-scraper@keble.dev"
129
+ dev_auth_role: OperatorRole = OperatorRole.SUPADMIN
130
+ bright_proxy_host: str | None = None
131
+ bright_proxy_port: int = Field(default=33335, ge=1, le=65_535)
132
+ bright_customer: str | None = None
133
+ bright_zone: str | None = None
134
+ bright_password: SecretStr | None = None
135
+ bright_cost_per_gb_usd: Decimal = Field(default=Decimal("8"), gt=0)
136
+ bright_daily_budget_usd: Decimal = Field(default=Decimal("25"), ge=0)
137
+ bright_requests_per_second: Decimal = Field(default=Decimal("5"), gt=0)
138
+ direct_requests_per_second: Decimal = Field(default=Decimal("2"), gt=0)
139
+ development_http_proxy_url: SecretStr | None = None
140
+ development_dns_over_https_url: AnyHttpUrl | None = None
141
+ cloudflare_account_id: str | None = None
142
+ cloudflare_api_token: SecretStr | None = None
143
+ cloudflare_browser_domain_allowlist: tuple[str, ...] = ()
144
+ cloudflare_browser_cost_per_hour_usd: Decimal = Field(
145
+ default=Decimal("0.09"),
146
+ gt=0,
147
+ )
148
+ cloudflare_daily_budget_usd: Decimal = Field(default=Decimal("5"), ge=0)
149
+ cloudflare_requests_per_second: Decimal = Field(default=Decimal("10"), gt=0)
150
+ public_proxy_experiment_enabled: bool = False
151
+ public_proxy_production_commit_allowed: bool = False
152
+ public_proxy_feed_allowlist: tuple[str, ...] = ()
153
+ proxy_secret_fernet_key: SecretStr | None = None
154
+ oss_endpoint: AnyHttpUrl | None = None
155
+ oss_bucket: str | None = Field(default=None, min_length=3, max_length=255)
156
+ oss_access_key_id: str | None = Field(default=None, min_length=1, max_length=256)
157
+ oss_access_key_secret: SecretStr | None = Field(default=None, min_length=1)
158
+ oss_prefix: str = "keble-scraper"
159
+ artifact_namespace_owner: str | None = Field(
160
+ default=None,
161
+ min_length=3,
162
+ max_length=256,
163
+ pattern=r"^[A-Za-z0-9._:/-]+$",
164
+ )
165
+ local_artifact_directory: str | None = None
166
+ local_artifact_base_url: AnyHttpUrl | None = None
167
+ artifact_download_seconds: int = Field(default=300, ge=30, le=3_600)
168
+ sentry_dsn: SecretStr | None = None
169
+ sentry_traces_sample_rate: float = Field(default=0.1, ge=0, le=1)
170
+ sentry_release: str = "keble-scraper-api@0.2.0"
171
+
172
+ @field_validator(
173
+ "local_artifact_directory",
174
+ "local_artifact_base_url",
175
+ mode="before",
176
+ )
177
+ @classmethod
178
+ def blank_local_artifact_value_is_absent(cls, value: object) -> object | None:
179
+ """Normalize explicit blank local-store overrides to absence.
180
+
181
+ 1. Allow Compose to disable legacy env-file local storage explicitly.
182
+ 2. Preserve every non-blank typed value for normal Pydantic validation.
183
+
184
+ Side effects if changes:
185
+ - private OSS may replace local development artifacts without a dual-store conflict;
186
+ - non-blank local directory/base-URL pairing remains strictly validated.
187
+ """
188
+
189
+ if isinstance(value, str) and not value.strip():
190
+ return None
191
+ return value
192
+
193
+ @field_validator(
194
+ "oss_endpoint",
195
+ "oss_bucket",
196
+ "oss_access_key_id",
197
+ "oss_access_key_secret",
198
+ mode="before",
199
+ )
200
+ @classmethod
201
+ def blank_oss_value_is_absent(cls, value: object) -> object | None:
202
+ """Normalize blank OSS environment overrides to true absence.
203
+
204
+ Steps:
205
+ 1. treat blank strings as an operator-disabled OSS value;
206
+ 2. treat an empty ``SecretStr`` as equally absent;
207
+ 3. preserve every non-blank value for normal URL/length validation.
208
+
209
+ Side effects if changes:
210
+ - local storage may coexist only with a genuinely absent OSS tuple;
211
+ - production and worker startup reject partial OSS credentials before paid work.
212
+ """
213
+
214
+ if isinstance(value, str) and not value.strip():
215
+ return None
216
+ if isinstance(value, SecretStr) and not value.get_secret_value().strip():
217
+ return None
218
+ return value
219
+
220
+ @model_validator(mode="after")
221
+ def validate_environment_guards(self) -> "ScraperSettings":
222
+ """Reject development access and unsafe proxy commits in production.
223
+
224
+ Steps:
225
+ 1. reject development-only auth/proxy behavior outside its allowed scope;
226
+ 2. require a stable artifact namespace owner for staging/production;
227
+ 3. validate complete secure production credentials, URLs, and key rotation.
228
+
229
+ Side effects if changes:
230
+ - production startup, public-proxy quarantine, and console access.
231
+ """
232
+
233
+ if (
234
+ self.environment is not RuntimeEnvironment.DEVELOPMENT
235
+ and self.dev_auth_enabled
236
+ ):
237
+ raise ValueError("SCRAPER_DEV_AUTH_ENABLED is allowed only in development")
238
+ if (
239
+ self.environment is RuntimeEnvironment.PRODUCTION
240
+ and self.public_proxy_production_commit_allowed
241
+ ):
242
+ raise ValueError(
243
+ "public proxy artifacts are forbidden for v0.1 production commits"
244
+ )
245
+ if (
246
+ self.environment
247
+ in {RuntimeEnvironment.STAGING, RuntimeEnvironment.PRODUCTION}
248
+ and self.artifact_namespace_owner is None
249
+ ):
250
+ raise ValueError(
251
+ "SCRAPER_ARTIFACT_NAMESPACE_OWNER is required outside local/test runtimes"
252
+ )
253
+ if self.environment is RuntimeEnvironment.PRODUCTION:
254
+ required = {
255
+ "SCRAPER_GOOGLE_CLIENT_ID": self.google_client_id,
256
+ "SCRAPER_TRUTH_BASE_URL": self.truth_base_url,
257
+ "SCRAPER_SHOPIFY_CALLBACK_URL": self.shopify_callback_url,
258
+ "SCRAPER_SESSION_FERNET_KEY": self.session_fernet_key,
259
+ "SCRAPER_PROXY_SECRET_FERNET_KEY": self.proxy_secret_fernet_key,
260
+ "SCRAPER_BRIGHT_PROXY_HOST": self.bright_proxy_host,
261
+ "SCRAPER_BRIGHT_CUSTOMER": self.bright_customer,
262
+ "SCRAPER_BRIGHT_ZONE": self.bright_zone,
263
+ "SCRAPER_BRIGHT_PASSWORD": self.bright_password,
264
+ "SCRAPER_OSS_ENDPOINT": self.oss_endpoint,
265
+ "SCRAPER_OSS_BUCKET": self.oss_bucket,
266
+ "SCRAPER_OSS_ACCESS_KEY_ID": self.oss_access_key_id,
267
+ "SCRAPER_OSS_ACCESS_KEY_SECRET": self.oss_access_key_secret,
268
+ "SCRAPER_SENTRY_DSN": self.sentry_dsn,
269
+ }
270
+ missing = [name for name, value in required.items() if value is None]
271
+ if missing:
272
+ raise ValueError(
273
+ "production scraper settings are missing: " + ", ".join(missing)
274
+ )
275
+ secure_urls = {
276
+ "SCRAPER_PUBLIC_BASE_URL": self.public_base_url,
277
+ "SCRAPER_TRUTH_BASE_URL": self.truth_base_url,
278
+ "SCRAPER_SHOPIFY_CALLBACK_URL": self.shopify_callback_url,
279
+ "SCRAPER_OSS_ENDPOINT": self.oss_endpoint,
280
+ }
281
+ insecure = [
282
+ name
283
+ for name, value in secure_urls.items()
284
+ if value is not None and value.scheme != "https"
285
+ ]
286
+ if insecure:
287
+ raise ValueError(
288
+ "production scraper URLs must use HTTPS: " + ", ".join(insecure)
289
+ )
290
+ secrets = {
291
+ "SCRAPER_SERVICE_KEY_CURRENT": self.service_key_current,
292
+ "SCRAPER_SERVICE_KEY_NEXT": self.service_key_next,
293
+ "SCRAPER_CALLBACK_HMAC_CURRENT": self.callback_hmac_current,
294
+ "SCRAPER_CALLBACK_HMAC_NEXT": self.callback_hmac_next,
295
+ "SCRAPER_BRIGHT_PASSWORD": self.bright_password,
296
+ "SCRAPER_OSS_ACCESS_KEY_SECRET": self.oss_access_key_secret,
297
+ }
298
+ weak = [
299
+ name
300
+ for name, value in secrets.items()
301
+ if value is not None and len(value.get_secret_value()) < 32
302
+ ]
303
+ if weak:
304
+ raise ValueError(
305
+ "production scraper secrets must contain at least 32 characters: "
306
+ + ", ".join(weak)
307
+ )
308
+ fernet_keys = {
309
+ "SCRAPER_SESSION_FERNET_KEY": self.session_fernet_key,
310
+ "SCRAPER_PROXY_SECRET_FERNET_KEY": self.proxy_secret_fernet_key,
311
+ }
312
+ invalid_fernet = [
313
+ name
314
+ for name, value in fernet_keys.items()
315
+ if value is not None and len(value.get_secret_value()) != 44
316
+ ]
317
+ if invalid_fernet:
318
+ raise ValueError(
319
+ "production Fernet keys must be URL-safe 32-byte keys: "
320
+ + ", ".join(invalid_fernet)
321
+ )
322
+ if (
323
+ self.service_key_next is not None
324
+ and self.service_key_next.get_secret_value()
325
+ == self.service_key_current.get_secret_value()
326
+ ):
327
+ raise ValueError("service current and next keys must differ")
328
+ if (
329
+ self.callback_hmac_next is not None
330
+ and self.callback_hmac_next.get_secret_value()
331
+ == self.callback_hmac_current.get_secret_value()
332
+ ):
333
+ raise ValueError("callback current and next keys must differ")
334
+ cloudflare_pair = (
335
+ self.cloudflare_account_id is not None,
336
+ self.cloudflare_api_token is not None,
337
+ )
338
+ if any(cloudflare_pair) and not all(cloudflare_pair):
339
+ raise ValueError(
340
+ "Cloudflare account and API token must be configured together"
341
+ )
342
+ if all(cloudflare_pair) and not self.cloudflare_browser_domain_allowlist:
343
+ raise ValueError("Cloudflare browser rendering requires a domain allowlist")
344
+ invalid_browser_domains = [
345
+ domain
346
+ for domain in self.cloudflare_browser_domain_allowlist
347
+ if not domain
348
+ or domain != domain.strip().lower().rstrip(".")
349
+ or "." not in domain
350
+ or any(token in domain for token in ("://", "/", "@", "?", "#"))
351
+ ]
352
+ if invalid_browser_domains:
353
+ raise ValueError(
354
+ "Cloudflare browser allowlist must contain bare lowercase domains"
355
+ )
356
+ local_artifact_pair = (
357
+ self.local_artifact_directory is not None,
358
+ self.local_artifact_base_url is not None,
359
+ )
360
+ if any(local_artifact_pair) and not all(local_artifact_pair):
361
+ raise ValueError(
362
+ "local artifact directory and base URL must be configured together"
363
+ )
364
+ if all(local_artifact_pair) and self.environment not in {
365
+ RuntimeEnvironment.DEVELOPMENT,
366
+ RuntimeEnvironment.TEST,
367
+ }:
368
+ raise ValueError(
369
+ "local artifact storage is allowed only in development/test"
370
+ )
371
+ if (
372
+ self.local_artifact_directory is not None
373
+ and not Path(self.local_artifact_directory).expanduser().is_absolute()
374
+ ):
375
+ raise ValueError("local artifact directory must be an absolute path")
376
+ if (
377
+ self.local_artifact_base_url is not None
378
+ and self.local_artifact_base_url.host
379
+ not in {"localhost", "127.0.0.1", "::1"}
380
+ ):
381
+ raise ValueError("local artifact base URL must use a loopback hostname")
382
+ oss_configured = any(
383
+ value is not None
384
+ for value in (
385
+ self.oss_endpoint,
386
+ self.oss_bucket,
387
+ self.oss_access_key_id,
388
+ self.oss_access_key_secret,
389
+ )
390
+ )
391
+ oss_complete = all(
392
+ value is not None
393
+ for value in (
394
+ self.oss_endpoint,
395
+ self.oss_bucket,
396
+ self.oss_access_key_id,
397
+ self.oss_access_key_secret,
398
+ )
399
+ )
400
+ if oss_configured and not oss_complete:
401
+ raise ValueError(
402
+ "OSS endpoint, bucket, access-key ID, and access-key secret must be configured together"
403
+ )
404
+ if all(local_artifact_pair) and oss_configured:
405
+ raise ValueError(
406
+ "configure either local artifact storage or private OSS, not both"
407
+ )
408
+ development_egress_pair = (
409
+ self.development_http_proxy_url is not None,
410
+ self.development_dns_over_https_url is not None,
411
+ )
412
+ if any(development_egress_pair) and not all(development_egress_pair):
413
+ raise ValueError(
414
+ "development HTTP proxy and DNS-over-HTTPS URL must be configured together"
415
+ )
416
+ if all(development_egress_pair) and self.environment not in {
417
+ RuntimeEnvironment.DEVELOPMENT,
418
+ RuntimeEnvironment.TEST,
419
+ }:
420
+ raise ValueError(
421
+ "development proxy egress is allowed only in development/test"
422
+ )
423
+ if self.development_http_proxy_url is not None:
424
+ proxy_url = TypeAdapter(AnyHttpUrl).validate_python(
425
+ self.development_http_proxy_url.get_secret_value()
426
+ )
427
+ if proxy_url.scheme != "http":
428
+ raise ValueError("development HTTP proxy must use the http scheme")
429
+ return self
430
+
431
+ @property
432
+ def resolved_artifact_namespace_owner(self) -> str:
433
+ """Return the explicit deployment owner or an isolated local/test fallback.
434
+
435
+ Steps:
436
+ 1. use the stable configured owner in staging and production;
437
+ 2. derive a deterministic environment/database owner only for local tests.
438
+
439
+ Side effects if changes:
440
+ - object-store canaries and Mongo namespace markers must receive one value;
441
+ - changing this value requires an explicit artifact-namespace migration.
442
+ """
443
+
444
+ return self.artifact_namespace_owner or (
445
+ f"{self.environment.value}/{self.mongo_database}"
446
+ )
447
+
448
+ def response_policy(self, policy_class: str) -> ResponsePolicy:
449
+ """Resolve a finite response policy without caller-controlled limits.
450
+
451
+ Steps:
452
+ 1. map the contract enum value to one server byte ceiling;
453
+ 2. return only reviewed MIME prefixes for that class.
454
+
455
+ Side effects if changes:
456
+ - fetch streaming and typed content/size denial outcomes.
457
+ """
458
+
459
+ policies = {
460
+ "DOCUMENT": ResponsePolicy(
461
+ max_bytes=self.document_max_bytes,
462
+ content_type_prefixes=(
463
+ "text/html",
464
+ "text/plain",
465
+ "application/xhtml+xml",
466
+ ),
467
+ ),
468
+ "JSON": ResponsePolicy(
469
+ max_bytes=self.json_max_bytes,
470
+ content_type_prefixes=(
471
+ "application/json",
472
+ "application/graphql-response+json",
473
+ ),
474
+ ),
475
+ "SITEMAP": ResponsePolicy(
476
+ max_bytes=self.sitemap_max_bytes,
477
+ content_type_prefixes=("application/xml", "text/xml", "text/plain"),
478
+ ),
479
+ "MEDIA": ResponsePolicy(
480
+ max_bytes=self.media_max_bytes,
481
+ content_type_prefixes=("image/", "video/"),
482
+ ),
483
+ }
484
+ return policies[policy_class]
@@ -0,0 +1,109 @@
1
+ """Shared Sentry initialization for API, Celery worker, and beat processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ import sentry_sdk
8
+ from keble_helpers.typings import PydanticModelConfig
9
+ from pydantic import BaseModel, SecretStr
10
+
11
+ from keble_scraper_api.settings import RuntimeEnvironment, ScraperSettings
12
+
13
+
14
+ class TelemetryRuntime(StrEnum):
15
+ """Finite process roles attached to every scraper Sentry event.
16
+
17
+ Steps:
18
+ 1. distinguish request, scheduler, worker-parent, and task-child processes;
19
+ 2. provide a stable low-cardinality tag value;
20
+ 3. keep queue/job identities out of global initialization.
21
+
22
+ Side effects if changes:
23
+ - Sentry issue grouping and runtime-specific alert routing.
24
+ """
25
+
26
+ API = "api"
27
+ CELERY_PARENT = "celery-parent"
28
+ WORKER = "worker"
29
+ BEAT = "beat"
30
+
31
+
32
+ class SentryRuntimeConfiguration(BaseModel):
33
+ """Secret-safe immutable Sentry initialization input for one process role.
34
+
35
+ Steps:
36
+ 1. retain the write-only DSN and stable release/environment;
37
+ 2. retain the bounded trace sample rate;
38
+ 3. retain a finite runtime tag without payload or user data.
39
+
40
+ Side effects if changes:
41
+ - every API/worker/beat event uses these shared telemetry facts.
42
+ """
43
+
44
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
45
+
46
+ dsn: SecretStr
47
+ environment: RuntimeEnvironment
48
+ release: str
49
+ traces_sample_rate: float
50
+ runtime: TelemetryRuntime
51
+
52
+
53
+ def build_sentry_runtime_configuration(
54
+ *,
55
+ settings: ScraperSettings,
56
+ runtime: TelemetryRuntime,
57
+ ) -> SentryRuntimeConfiguration | None:
58
+ """Build one enabled secret-safe configuration or explicit disabled result.
59
+
60
+ Steps:
61
+ 1. treat an absent DSN as telemetry intentionally disabled;
62
+ 2. copy only reviewed structural settings into the immutable model;
63
+ 3. bind the exact process role for tests and alert routing.
64
+
65
+ Side effects if changes:
66
+ - determines whether process initialization calls the Sentry SDK.
67
+ """
68
+
69
+ if settings.sentry_dsn is None:
70
+ return None
71
+ return SentryRuntimeConfiguration(
72
+ dsn=settings.sentry_dsn,
73
+ environment=settings.environment,
74
+ release=settings.sentry_release,
75
+ traces_sample_rate=settings.sentry_traces_sample_rate,
76
+ runtime=runtime,
77
+ )
78
+
79
+
80
+ def configure_sentry(
81
+ *,
82
+ settings: ScraperSettings,
83
+ runtime: TelemetryRuntime,
84
+ ) -> None:
85
+ """Initialize Sentry consistently before one process performs real work.
86
+
87
+ Steps:
88
+ 1. build the common configuration and return when explicitly disabled;
89
+ 2. initialize automatic FastAPI/Celery integrations without default PII;
90
+ 3. attach a stable process-role tag for worker/beat crash-loop visibility.
91
+
92
+ Side effects if changes:
93
+ - initializes the process-global Sentry SDK client and runtime tag.
94
+ """
95
+
96
+ configuration = build_sentry_runtime_configuration(
97
+ settings=settings,
98
+ runtime=runtime,
99
+ )
100
+ if configuration is None:
101
+ return
102
+ sentry_sdk.init(
103
+ dsn=configuration.dsn.get_secret_value(),
104
+ environment=configuration.environment.value,
105
+ release=configuration.release,
106
+ traces_sample_rate=configuration.traces_sample_rate,
107
+ send_default_pii=False,
108
+ )
109
+ sentry_sdk.set_tag("keble.runtime", configuration.runtime.value)
@@ -0,0 +1,48 @@
1
+ """Celery application imported by worker and beat processes."""
2
+
3
+ from celery.signals import worker_process_init, worker_process_shutdown
4
+
5
+ from keble_scraper_api.infrastructure.celery_factory import build_celery
6
+ from keble_scraper_api.settings import ScraperSettings
7
+ from keble_scraper_api.telemetry import TelemetryRuntime, configure_sentry
8
+ from keble_scraper_api.workers.runtime import (
9
+ initialize_worker_runtime,
10
+ shutdown_worker_runtime,
11
+ )
12
+
13
+ settings = ScraperSettings()
14
+ configure_sentry(settings=settings, runtime=TelemetryRuntime.CELERY_PARENT)
15
+ celery_app = build_celery(settings=settings)
16
+
17
+
18
+ @worker_process_init.connect
19
+ def initialize_process(**signal_values: object) -> None:
20
+ """Create one persistent event loop/container per Celery child process.
21
+
22
+ Steps:
23
+ 1. initialize child-local Sentry before dependency startup;
24
+ 2. start the persistent event loop and process container;
25
+ 3. allow startup faults to reach Celery and Sentry unchanged.
26
+
27
+ Side effects if changes:
28
+ - worker Mongo/Redis pools and startup indexes.
29
+ """
30
+
31
+ configure_sentry(settings=settings, runtime=TelemetryRuntime.WORKER)
32
+ initialize_worker_runtime(settings=settings)
33
+
34
+
35
+ @worker_process_shutdown.connect
36
+ def shutdown_process(**signal_values: object) -> None:
37
+ """Close worker pools and event loop on process shutdown.
38
+
39
+ Steps:
40
+ 1. accept the Celery signal payload without dynamic inspection;
41
+ 2. close the child runtime exactly once;
42
+ 3. leave process-global telemetry to normal interpreter shutdown.
43
+
44
+ Side effects if changes:
45
+ - Celery child resource cleanup.
46
+ """
47
+
48
+ shutdown_worker_runtime()