stage-cli 1.0.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 (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
stage/http/client.py ADDED
@@ -0,0 +1,725 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ import time
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from dataclasses import replace as dataclass_replace
8
+ from datetime import UTC, datetime, timedelta
9
+ from email.utils import parsedate_to_datetime
10
+ from types import TracebackType
11
+ from typing import Any, Self
12
+ from urllib.parse import urlsplit
13
+
14
+ import httpx
15
+ from tenacity import AsyncRetrying, RetryCallState, stop_after_attempt, wait_exponential_jitter
16
+
17
+ from stage import __version__
18
+ from stage.domain import RateState, block_duration, decay
19
+ from stage.http.breaker import CircuitBreaker
20
+ from stage.http.cache import ValidatorCache
21
+ from stage.http.profiles import RatePosture
22
+
23
+ USER_AGENT = (
24
+ f"stage-cli/{__version__} (+https://github.com/NicholasXydis/Stage; internship aggregator)"
25
+ )
26
+ TEXT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
27
+ MAX_RESPONSE_BYTES = 64 * 1024 * 1024
28
+ MAX_RETRY_AFTER_S = 60.0
29
+ MAX_DEFERRAL_S = 86400.0
30
+ RESET_AS_EPOCH_S = 1_000_000.0
31
+ RESET_AS_EPOCH_MS = 100_000_000_000.0
32
+ MAX_INTERVAL_S = 10.0
33
+ MIN_TIGHTEN_FLOOR_S = 0.25
34
+ MAX_ATTEMPTS = 3
35
+ JITTER_MAX_S = 0.5
36
+ STRIDE_JITTER_FRACTION = 0.35
37
+ RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
38
+ BLOCKING_STATUSES = frozenset({401, 403})
39
+ BLOCKED_COOLDOWN_S = 1800.0
40
+ DENIED_HOSTS_BEFORE_BLOCK = 2
41
+ MAX_REDIRECTS = 3
42
+ ORIGIN_BOUND_HEADERS = frozenset(
43
+ {"authorization", "cookie", "origin", "referer", "proxy-authorization", "x-csrf-token"}
44
+ )
45
+ DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
46
+
47
+
48
+ def request_url(url: str, params: dict[str, str] | None = None) -> httpx.URL:
49
+ target = httpx.URL(url)
50
+ return target.copy_merge_params(params) if params else target
51
+
52
+
53
+ class HttpError(Exception):
54
+ pass
55
+
56
+
57
+ class HttpStatusError(HttpError):
58
+ def __init__(self, bucket: str, response: httpx.Response) -> None:
59
+ super().__init__(f"{bucket} returned {response.status_code}")
60
+ self.response = response
61
+ self.status = response.status_code
62
+
63
+
64
+ class HostNotAllowedError(HttpError):
65
+ pass
66
+
67
+
68
+ class HostBudgetExceededError(HttpError):
69
+ pass
70
+
71
+
72
+ class UnreadablePayloadError(HttpError, ValueError):
73
+ pass
74
+
75
+
76
+ class ResponseTooLargeError(HttpError):
77
+ pass
78
+
79
+
80
+ class BreakerOpenError(HttpError):
81
+ pass
82
+
83
+
84
+ class ForbiddenError(HttpError):
85
+ pass
86
+
87
+
88
+ class RedirectNotAllowedError(HttpError):
89
+ pass
90
+
91
+
92
+ class BucketBlockedError(HttpError):
93
+ pass
94
+
95
+
96
+ class RetryableStatusError(HttpError):
97
+ def __init__(self, message: str, status: int, retry_after: float | None = None) -> None:
98
+ super().__init__(message)
99
+ self.status = status
100
+ self.retry_after = retry_after
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class JsonResponse:
105
+ status: int
106
+ payload: Any
107
+ not_modified: bool
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class TextResponse:
112
+ status: int
113
+ text: str
114
+ not_modified: bool
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class RequestRecord:
119
+ method: str
120
+ url: str
121
+ status: int | None
122
+ elapsed_ms: float
123
+ attempt: int
124
+ error: str = ""
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class HostMetrics:
129
+ requests: int = 0
130
+ not_modified: int = 0
131
+ retries: int = 0
132
+ tightenings: int = 0
133
+ failures: int = 0
134
+ latencies: list[float] = field(default_factory=list)
135
+
136
+ def percentile(self, fraction: float) -> float:
137
+ if not self.latencies:
138
+ return 0.0
139
+ ordered = sorted(self.latencies)
140
+ index = min(len(ordered) - 1, max(0, round(fraction * (len(ordered) - 1))))
141
+ return ordered[index]
142
+
143
+
144
+ @dataclass(slots=True)
145
+ class HostBudget:
146
+ posture: RatePosture
147
+ seed: RateState | None = None
148
+ metrics: HostMetrics = field(default_factory=HostMetrics)
149
+ breaker: CircuitBreaker = field(default_factory=CircuitBreaker)
150
+ requests: int = 0
151
+ min_interval_s: float = 0.0
152
+ next_allowed_at: float = 0.0
153
+ rejections: int = 0
154
+ seeded_interval_s: float = 0.0
155
+ last_error: str = ""
156
+ deferred_s: float = 0.0
157
+ deferred_reason: str = ""
158
+ denied_hosts: set[str] = field(default_factory=set)
159
+ semaphore: asyncio.Semaphore = field(init=False)
160
+ gate: asyncio.Lock = field(init=False)
161
+
162
+ def __post_init__(self) -> None:
163
+ self.semaphore = asyncio.Semaphore(self.posture.concurrency)
164
+ self.gate = asyncio.Lock()
165
+ self.min_interval_s = self.posture.min_interval_s
166
+ if self.seed is not None:
167
+ if self.seed.min_interval_override is not None:
168
+ self.min_interval_s = max(self.min_interval_s, self.seed.min_interval_override)
169
+ self.breaker.consecutive_failures = self.seed.consecutive_failures
170
+ self.seeded_interval_s = self.min_interval_s
171
+
172
+ @property
173
+ def stride(self) -> float:
174
+ return self.min_interval_s / max(1, self.posture.concurrency)
175
+
176
+ def defer_for(self, seconds: float, reason: str) -> None:
177
+ capped = min(seconds, MAX_DEFERRAL_S)
178
+ if capped > self.deferred_s:
179
+ self.deferred_s = capped
180
+ self.deferred_reason = reason
181
+
182
+ def tighten(self, factor: float, *, rejected: bool = False) -> None:
183
+ established = max(self.min_interval_s, MIN_TIGHTEN_FLOOR_S)
184
+ self.min_interval_s = min(MAX_INTERVAL_S, established * factor)
185
+ self.metrics.tightenings += 1
186
+ if rejected:
187
+ self.rejections += 1
188
+
189
+ def settle(self, bucket: str, now: datetime) -> RateState:
190
+ baseline = self.posture.min_interval_s
191
+ override = (
192
+ self.min_interval_s if self.rejections else decay(self.seeded_interval_s, baseline)
193
+ )
194
+ if override is not None and override <= baseline:
195
+ override = None
196
+
197
+ state = RateState(
198
+ bucket=bucket,
199
+ updated_at=now,
200
+ min_interval_override=override,
201
+ rotation_cursor=self.seed.rotation_cursor if self.seed is not None else "",
202
+ )
203
+ escalated = (
204
+ block_duration(self.breaker.consecutive_failures)
205
+ if self.breaker.opened_at is not None
206
+ else 0.0
207
+ )
208
+ if self.deferred_s > 0 or escalated > 0:
209
+ longest = max(self.deferred_s, escalated)
210
+ reason = (
211
+ self.deferred_reason
212
+ if self.deferred_s >= escalated
213
+ else (self.last_error or "circuit breaker tripped")
214
+ )
215
+ return dataclass_replace(
216
+ state,
217
+ blocked_until=now + timedelta(seconds=longest),
218
+ consecutive_failures=self.breaker.consecutive_failures,
219
+ last_failure_at=now,
220
+ reason=reason,
221
+ )
222
+ if self.breaker.consecutive_failures:
223
+ return dataclass_replace(
224
+ state,
225
+ consecutive_failures=self.breaker.consecutive_failures,
226
+ last_failure_at=now,
227
+ reason=self.last_error,
228
+ )
229
+ return state
230
+
231
+
232
+ def _origin_of(url: httpx.URL) -> tuple[str, str, int | None]:
233
+ return (url.scheme, (url.host or "").lower(), url.port)
234
+
235
+
236
+ class HttpClient:
237
+ def __init__(
238
+ self,
239
+ *,
240
+ allowed_hosts: frozenset[str],
241
+ posture: RatePosture | None = None,
242
+ cache: ValidatorCache | None = None,
243
+ timeout: httpx.Timeout | None = None,
244
+ transport: httpx.AsyncBaseTransport | None = None,
245
+ rng: random.Random | None = None,
246
+ jitter: bool = True,
247
+ bucket_key: str = "",
248
+ rate_state: Mapping[str, RateState] | None = None,
249
+ now: datetime | None = None,
250
+ budgets: dict[str, HostBudget] | None = None,
251
+ postures: Mapping[str, RatePosture] | None = None,
252
+ ) -> None:
253
+ self._allowed_hosts = frozenset(host.lower() for host in allowed_hosts)
254
+ self._posture = posture or RatePosture()
255
+ self._cache = cache or ValidatorCache()
256
+ self._budgets: dict[str, HostBudget] = {} if budgets is None else budgets
257
+ self._postures = dict(postures or {})
258
+ self._touched: set[str] = set()
259
+ self._tighten_baseline: dict[str, int] = {}
260
+ self._own = HostMetrics()
261
+ self._log: list[RequestRecord] = []
262
+ self._rng = rng or random.Random()
263
+ self._jitter = jitter
264
+ self._bucket_key = bucket_key
265
+ self._rate_state = dict(rate_state or {})
266
+ self._now = now or datetime.now(UTC)
267
+ self._blocked = {
268
+ bucket: state
269
+ for bucket, state in self._rate_state.items()
270
+ if state.is_blocked(self._now)
271
+ }
272
+ self._client = httpx.AsyncClient(
273
+ timeout=timeout or DEFAULT_TIMEOUT,
274
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
275
+ follow_redirects=False,
276
+ transport=transport,
277
+ )
278
+
279
+ async def __aenter__(self) -> Self:
280
+ return self
281
+
282
+ async def __aexit__(
283
+ self,
284
+ exc_type: type[BaseException] | None,
285
+ exc: BaseException | None,
286
+ tb: TracebackType | None,
287
+ ) -> None:
288
+ await self.aclose()
289
+
290
+ async def aclose(self) -> None:
291
+ await self._client.aclose()
292
+
293
+ @property
294
+ def cache(self) -> ValidatorCache:
295
+ return self._cache
296
+
297
+ @property
298
+ def request_count(self) -> int:
299
+ return self._own.requests
300
+
301
+ @property
302
+ def not_modified_count(self) -> int:
303
+ return self._own.not_modified
304
+
305
+ @property
306
+ def retry_count(self) -> int:
307
+ return self._own.retries
308
+
309
+ @property
310
+ def tightening_count(self) -> int:
311
+ return sum(
312
+ self._budgets[bucket].metrics.tightenings - baseline
313
+ for bucket, baseline in self._tighten_baseline.items()
314
+ )
315
+
316
+ def latency_percentiles(self) -> tuple[float, float]:
317
+ if not self._own.latencies:
318
+ return 0.0, 0.0
319
+ return self._own.percentile(0.5), self._own.percentile(0.95)
320
+
321
+ def drain_log(self) -> list[RequestRecord]:
322
+ drained = self._log
323
+ self._log = []
324
+ return drained
325
+
326
+ def bucket_for(self, host: str) -> str:
327
+ return self._bucket_key or host
328
+
329
+ def _budget_for(self, bucket: str) -> HostBudget:
330
+ budget = self._budgets.get(bucket)
331
+ if budget is None:
332
+ posture = self._postures.get(bucket, self._posture)
333
+ budget = HostBudget(posture=posture, seed=self._rate_state.get(bucket))
334
+ if self._jitter and budget.min_interval_s > 0:
335
+ budget.next_allowed_at = time.monotonic() + self._rng.uniform(0.0, JITTER_MAX_S)
336
+ self._budgets[bucket] = budget
337
+ self._tighten_baseline.setdefault(bucket, budget.metrics.tightenings)
338
+ self._touched.add(bucket)
339
+ return budget
340
+
341
+ def _authorize(self, url: str) -> tuple[str, HostBudget]:
342
+ try:
343
+ split = urlsplit(url)
344
+ except ValueError:
345
+ raise HostNotAllowedError(f"{url!r} is not a registry host") from None
346
+ host = (split.hostname or "").lower()
347
+ if host not in self._allowed_hosts:
348
+ raise HostNotAllowedError(f"{host or url!r} is not a registry host")
349
+ if split.scheme != "https":
350
+ raise RedirectNotAllowedError(
351
+ f"{url} is {split.scheme or 'schemeless'}; TLS is never optional (§11)"
352
+ )
353
+ bucket = self.bucket_for(host)
354
+ return bucket, self._budget_for(bucket)
355
+
356
+ def rate_state(self, now: datetime | None = None) -> tuple[RateState, ...]:
357
+ moment = now or datetime.now(UTC)
358
+ return tuple(
359
+ budget.settle(bucket, moment)
360
+ for bucket, budget in sorted(self._budgets.items())
361
+ if bucket in self._touched and budget.requests > 0
362
+ )
363
+
364
+ def _refuse(
365
+ self, bucket: str, budget: HostBudget, *, count: bool, claim_probe: bool = True
366
+ ) -> None:
367
+ blocking = self._blocked.get(bucket)
368
+ if blocking is not None and blocking.is_blocked(self._now):
369
+ raise BucketBlockedError(
370
+ f"{bucket} is blocked for another "
371
+ f"{blocking.blocks_remaining_s(self._now):.0f}s ({blocking.reason})"
372
+ )
373
+ if budget.deferred_s > 0:
374
+ raise BucketBlockedError(
375
+ f"{bucket} asked to be left alone for {budget.deferred_s:.0f}s "
376
+ f"({budget.deferred_reason}); nothing further is sent this run"
377
+ )
378
+ barred = not budget.breaker.allows() if claim_probe else budget.breaker.is_open()
379
+ if barred:
380
+ raise BreakerOpenError(
381
+ f"{bucket} circuit breaker is open after "
382
+ f"{budget.breaker.consecutive_failures} consecutive failures"
383
+ )
384
+ if count and budget.requests >= budget.posture.max_requests_per_run:
385
+ raise HostBudgetExceededError(
386
+ f"{bucket} reached its ceiling of {budget.posture.max_requests_per_run} "
387
+ "requests for this run"
388
+ )
389
+
390
+ def _paced(self, budget: HostBudget) -> float:
391
+ stride = budget.stride
392
+ if not self._jitter or stride <= 0:
393
+ return stride
394
+ return stride + self._rng.uniform(0.0, stride * STRIDE_JITTER_FRACTION)
395
+
396
+ async def _reserve(self, bucket: str, budget: HostBudget) -> None:
397
+ async with budget.gate:
398
+ self._refuse(bucket, budget, count=True)
399
+ budget.requests += 1
400
+ now = time.monotonic()
401
+ wait = budget.next_allowed_at - now
402
+ budget.next_allowed_at = max(now, budget.next_allowed_at) + self._paced(budget)
403
+ if wait > 0:
404
+ await asyncio.sleep(wait)
405
+ try:
406
+ async with budget.gate:
407
+ self._refuse(bucket, budget, count=False, claim_probe=False)
408
+ except HttpError:
409
+ async with budget.gate:
410
+ budget.requests -= 1
411
+ budget.breaker.release_probe()
412
+ raise
413
+
414
+ @staticmethod
415
+ def _reset_seconds(value: float, now: float) -> float | None:
416
+ if value < RESET_AS_EPOCH_S:
417
+ return value
418
+ if value < RESET_AS_EPOCH_MS:
419
+ return value - now
420
+ if value < RESET_AS_EPOCH_MS * 1000:
421
+ return value / 1000 - now
422
+ return None
423
+
424
+ @staticmethod
425
+ def _rate_limit_reset(response: httpx.Response) -> float | None:
426
+ remaining = response.headers.get("x-ratelimit-remaining")
427
+ reset = response.headers.get("x-ratelimit-reset")
428
+ if reset is None or remaining is None or remaining.strip() != "0":
429
+ return None
430
+ try:
431
+ value = float(reset)
432
+ except ValueError:
433
+ return None
434
+ if value < 0:
435
+ return None
436
+ seconds = HttpClient._reset_seconds(value, datetime.now(UTC).timestamp())
437
+ if seconds is None or seconds <= 0:
438
+ return None
439
+ return seconds
440
+
441
+ @staticmethod
442
+ def _retry_after_raw(response: httpx.Response) -> float | None:
443
+ raw = response.headers.get("retry-after")
444
+ if not raw:
445
+ return HttpClient._rate_limit_reset(response)
446
+ try:
447
+ return max(0.0, float(raw))
448
+ except ValueError:
449
+ pass
450
+ try:
451
+ when = parsedate_to_datetime(raw)
452
+ except (TypeError, ValueError):
453
+ return None
454
+ if when.tzinfo is None:
455
+ when = when.replace(tzinfo=UTC)
456
+ return max(0.0, (when - datetime.now(UTC)).total_seconds())
457
+
458
+ def _validate_hop(self, url: httpx.URL) -> None:
459
+ if url.scheme != "https":
460
+ raise RedirectNotAllowedError(
461
+ f"redirect to {url} downgrades to {url.scheme}; TLS is never optional (§11)"
462
+ )
463
+ if (url.host or "").lower() not in self._allowed_hosts:
464
+ raise RedirectNotAllowedError(
465
+ f"redirect to {url.host!r} leaves the registry allow-list — "
466
+ "adapters may only contact hosts the registry names (§11)"
467
+ )
468
+
469
+ async def _send(
470
+ self, target: httpx.URL, headers: dict[str, str], method: str, body: Any
471
+ ) -> httpx.Response:
472
+ current = target
473
+ carried = headers
474
+ for _ in range(MAX_REDIRECTS + 1):
475
+ request = self._client.build_request(
476
+ method, current, headers=carried, json=body if method == "POST" else None
477
+ )
478
+ response = await self._client.send(request, stream=True)
479
+ if not response.is_redirect:
480
+ return response
481
+ location = response.headers.get("location")
482
+ if not location:
483
+ return response
484
+ await response.aclose()
485
+ following = current.join(location)
486
+ self._validate_hop(following)
487
+ if _origin_of(following) != _origin_of(current):
488
+ carried = {
489
+ name: value
490
+ for name, value in carried.items()
491
+ if name.lower() not in ORIGIN_BOUND_HEADERS
492
+ }
493
+ current = following
494
+ method = "GET" if response.status_code in (301, 302, 303) else method
495
+ raise RedirectNotAllowedError(f"{target} exceeded {MAX_REDIRECTS} redirects")
496
+
497
+ async def _read_capped(self, bucket: str, response: httpx.Response) -> bytes:
498
+ declared = response.headers.get("content-length")
499
+ if declared is not None:
500
+ try:
501
+ announced = int(declared)
502
+ except ValueError:
503
+ announced = -1
504
+ if announced > MAX_RESPONSE_BYTES:
505
+ raise ResponseTooLargeError(
506
+ f"{bucket} announced {announced} bytes, over the "
507
+ f"{MAX_RESPONSE_BYTES}-byte ceiling"
508
+ )
509
+ chunks: list[bytes] = []
510
+ total = 0
511
+ async for chunk in response.aiter_bytes():
512
+ total += len(chunk)
513
+ if total > MAX_RESPONSE_BYTES:
514
+ raise ResponseTooLargeError(
515
+ f"{bucket} exceeded the {MAX_RESPONSE_BYTES}-byte ceiling mid-transfer"
516
+ )
517
+ chunks.append(chunk)
518
+ return b"".join(chunks)
519
+
520
+ async def _attempt(
521
+ self,
522
+ bucket: str,
523
+ budget: HostBudget,
524
+ url: str,
525
+ params: dict[str, str] | None,
526
+ attempt: int,
527
+ method: str = "GET",
528
+ body: Any = None,
529
+ revalidate: bool = False,
530
+ decode: str = "json",
531
+ extra_headers: Mapping[str, str] | None = None,
532
+ ) -> JsonResponse:
533
+ if attempt > 1:
534
+ budget.metrics.retries += 1
535
+ self._own.retries += 1
536
+ target = request_url(url, params)
537
+ key = str(target)
538
+ headers = self._cache.conditional_headers(key) if method == "GET" and not revalidate else {}
539
+ if extra_headers:
540
+ headers = {**headers, **dict(extra_headers)}
541
+ try:
542
+ async with budget.semaphore:
543
+ await self._reserve(bucket, budget)
544
+ self._own.requests += 1
545
+ started = time.perf_counter()
546
+ response = await self._send(target, headers, method, body)
547
+ try:
548
+ content = await self._read_capped(bucket, response)
549
+ finally:
550
+ await response.aclose()
551
+ except ResponseTooLargeError as exc:
552
+ self._log.append(
553
+ RequestRecord(
554
+ method=method,
555
+ url=key,
556
+ status=None,
557
+ elapsed_ms=(time.perf_counter() - started) * 1000,
558
+ attempt=attempt,
559
+ error=f"{type(exc).__name__}: {exc}",
560
+ )
561
+ )
562
+ raise
563
+ except httpx.HTTPError as exc:
564
+ elapsed = (time.perf_counter() - started) * 1000
565
+ budget.metrics.failures += 1
566
+ budget.breaker.record_failure()
567
+ budget.last_error = f"{type(exc).__name__}: {exc}"
568
+ self._log.append(
569
+ RequestRecord(
570
+ method=method,
571
+ url=key,
572
+ status=None,
573
+ elapsed_ms=elapsed,
574
+ attempt=attempt,
575
+ error=f"{type(exc).__name__}: {exc}",
576
+ )
577
+ )
578
+ raise
579
+
580
+ elapsed_s = time.perf_counter() - started
581
+ budget.metrics.latencies.append(elapsed_s * 1000)
582
+ self._own.latencies.append(elapsed_s * 1000)
583
+ self._log.append(
584
+ RequestRecord(
585
+ method=method,
586
+ url=key,
587
+ status=response.status_code,
588
+ elapsed_ms=elapsed_s * 1000,
589
+ attempt=attempt,
590
+ )
591
+ )
592
+
593
+ if response.status_code in BLOCKING_STATUSES:
594
+ budget.metrics.failures += 1
595
+ budget.breaker.record_failure()
596
+ reason = f"HTTP {response.status_code}"
597
+ budget.last_error = reason
598
+ host = (target.host or bucket).lower()
599
+ budget.denied_hosts.add(host)
600
+ spans_hosts = len(self._allowed_hosts) > 1
601
+ if not spans_hosts or len(budget.denied_hosts) >= DENIED_HOSTS_BEFORE_BLOCK:
602
+ budget.tighten(2.0, rejected=True)
603
+ budget.defer_for(BLOCKED_COOLDOWN_S, reason)
604
+ raise ForbiddenError(f"{host} returned {response.status_code}")
605
+
606
+ if response.status_code in RETRYABLE_STATUSES:
607
+ budget.metrics.failures += 1
608
+ budget.breaker.record_failure()
609
+ raw_retry_after = self._retry_after_raw(response)
610
+ if response.status_code == 429 or raw_retry_after is not None:
611
+ budget.tighten(2.0, rejected=True)
612
+ if raw_retry_after is not None and raw_retry_after > MAX_RETRY_AFTER_S:
613
+ budget.defer_for(raw_retry_after, f"Retry-After: {raw_retry_after:.0f}s")
614
+ budget.last_error = f"HTTP {response.status_code}"
615
+ raise RetryableStatusError(
616
+ f"{bucket} returned {response.status_code}",
617
+ response.status_code,
618
+ min(raw_retry_after, MAX_RETRY_AFTER_S) if raw_retry_after is not None else None,
619
+ )
620
+
621
+ if response.status_code == 304:
622
+ budget.breaker.record_success()
623
+ budget.metrics.not_modified += 1
624
+ self._own.not_modified += 1
625
+ return JsonResponse(status=304, payload=None, not_modified=True)
626
+
627
+ if response.is_error:
628
+ budget.metrics.failures += 1
629
+ budget.last_error = f"HTTP {response.status_code}"
630
+ raise HttpStatusError(bucket, response)
631
+ try:
632
+ payload = (
633
+ content.decode("utf-8", "replace") if decode == "text" else json.loads(content)
634
+ )
635
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
636
+ budget.metrics.failures += 1
637
+ budget.breaker.record_failure()
638
+ budget.last_error = f"{response.status_code} carried unreadable {decode}"
639
+ raise UnreadablePayloadError(
640
+ f"{bucket} answered {response.status_code} with unreadable {decode}; "
641
+ "a challenge or error page is the usual cause"
642
+ ) from exc
643
+ budget.breaker.record_success()
644
+ if method == "GET":
645
+ self._cache.record(key, response.headers, datetime.now(UTC))
646
+ return JsonResponse(status=response.status_code, payload=payload, not_modified=False)
647
+
648
+ async def get_json(
649
+ self,
650
+ url: str,
651
+ *,
652
+ params: dict[str, str] | None = None,
653
+ revalidate: bool = False,
654
+ ) -> JsonResponse:
655
+ return await self._request("GET", url, params=params, revalidate=revalidate)
656
+
657
+ async def post_json(
658
+ self, url: str, *, body: Any, extra_headers: Mapping[str, str] | None = None
659
+ ) -> JsonResponse:
660
+ return await self._request("POST", url, body=body, extra_headers=extra_headers)
661
+
662
+ async def get_text(self, url: str, *, revalidate: bool = False) -> TextResponse:
663
+ response = await self._request(
664
+ "GET",
665
+ url,
666
+ decode="text",
667
+ revalidate=revalidate,
668
+ extra_headers={"Accept": TEXT_ACCEPT},
669
+ )
670
+ return TextResponse(
671
+ status=response.status,
672
+ text="" if response.payload is None else str(response.payload),
673
+ not_modified=response.not_modified,
674
+ )
675
+
676
+ async def _request(
677
+ self,
678
+ method: str,
679
+ url: str,
680
+ *,
681
+ params: dict[str, str] | None = None,
682
+ body: Any = None,
683
+ revalidate: bool = False,
684
+ decode: str = "json",
685
+ extra_headers: Mapping[str, str] | None = None,
686
+ ) -> JsonResponse:
687
+ bucket, budget = self._authorize(url)
688
+
689
+ def wait(state: RetryCallState) -> float:
690
+ outcome = state.outcome
691
+ exc = outcome.exception() if outcome is not None else None
692
+ if isinstance(exc, RetryableStatusError) and exc.retry_after is not None:
693
+ return exc.retry_after
694
+ return float(wait_exponential_jitter(initial=0.5, max=8.0)(state))
695
+
696
+ def should_retry(state: RetryCallState) -> bool:
697
+ outcome = state.outcome
698
+ if outcome is None or not outcome.failed:
699
+ return False
700
+ if budget.deferred_s > 0:
701
+ return False
702
+ return isinstance(outcome.exception(), RetryableStatusError | httpx.TransportError)
703
+
704
+ attempt = 0
705
+ async for wrapped in AsyncRetrying(
706
+ retry=should_retry,
707
+ wait=wait,
708
+ stop=stop_after_attempt(MAX_ATTEMPTS),
709
+ reraise=True,
710
+ ):
711
+ with wrapped:
712
+ attempt += 1
713
+ return await self._attempt(
714
+ bucket,
715
+ budget,
716
+ url,
717
+ params,
718
+ attempt,
719
+ method,
720
+ body,
721
+ revalidate,
722
+ decode,
723
+ extra_headers,
724
+ )
725
+ raise HttpError(f"{bucket} exhausted retries for {url}")