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.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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}")
|