tilearray 0.1.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.
- tilearray/__init__.py +89 -0
- tilearray/_version.py +3 -0
- tilearray/array.py +899 -0
- tilearray/decode.py +353 -0
- tilearray/errors.py +41 -0
- tilearray/fetch.py +772 -0
- tilearray/fetch_presets.py +77 -0
- tilearray/pressure.py +129 -0
- tilearray/py.typed +0 -0
- tilearray/service/__init__.py +33 -0
- tilearray/service/base.py +282 -0
- tilearray/service/config.py +455 -0
- tilearray/service/requests.py +70 -0
- tilearray/service/wcs.py +657 -0
- tilearray/service/wms.py +127 -0
- tilearray/service/wmts.py +590 -0
- tilearray/service/xyz.py +145 -0
- tilearray/tiles.py +142 -0
- tilearray/types.py +235 -0
- tilearray-0.1.0.dist-info/METADATA +345 -0
- tilearray-0.1.0.dist-info/RECORD +23 -0
- tilearray-0.1.0.dist-info/WHEEL +4 -0
- tilearray-0.1.0.dist-info/licenses/LICENSE +201 -0
tilearray/fetch.py
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
"""Centralised tile HTTP fetching with bounded concurrency and retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Protocol, runtime_checkable
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from tenacity import (
|
|
15
|
+
RetryCallState,
|
|
16
|
+
retry,
|
|
17
|
+
retry_if_exception_type,
|
|
18
|
+
stop_after_attempt,
|
|
19
|
+
wait_exponential_jitter,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .errors import NetworkError
|
|
23
|
+
from .pressure import (
|
|
24
|
+
DEFAULT_PRESSURE_CLASSIFIER,
|
|
25
|
+
PressureClassifier,
|
|
26
|
+
ResponseClassification,
|
|
27
|
+
classify_response,
|
|
28
|
+
)
|
|
29
|
+
from .types import TileRequest, TileResponse
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
ProgressCallback = Callable[[int, int, TileRequest, TileResponse], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_DEFAULT_MAX_CONCURRENT = 3
|
|
37
|
+
_DEFAULT_INITIAL_CONCURRENT = 2
|
|
38
|
+
_DEFAULT_MIN_CONCURRENT = 1
|
|
39
|
+
_DEFAULT_RATE_LIMIT_PER_SECOND = 2.0
|
|
40
|
+
_DEFAULT_MULTIPLICATIVE_DECREASE = 0.5
|
|
41
|
+
_DEFAULT_ADDITIVE_INCREASE = 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class HostRateLimiter(Protocol):
|
|
46
|
+
"""Hook invoked immediately before each HTTP request for a host."""
|
|
47
|
+
|
|
48
|
+
def before_request(self, host: str) -> None:
|
|
49
|
+
"""Block until the request for ``host`` may proceed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NoOpRateLimiter:
|
|
53
|
+
"""Rate limiter that imposes no delay."""
|
|
54
|
+
|
|
55
|
+
def before_request(self, host: str) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PerHostRateLimiter:
|
|
60
|
+
"""Conservative fixed-interval limiter keyed by request host."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, rate_per_second: float) -> None:
|
|
63
|
+
if rate_per_second <= 0:
|
|
64
|
+
raise ValueError("rate_per_second must be positive")
|
|
65
|
+
self._min_interval = 1.0 / rate_per_second
|
|
66
|
+
self._last_request: dict[str, float] = {}
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
def before_request(self, host: str) -> None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
now = time.monotonic()
|
|
72
|
+
last = self._last_request.get(host, 0.0)
|
|
73
|
+
sleep_for = self._min_interval - (now - last)
|
|
74
|
+
if sleep_for > 0:
|
|
75
|
+
time.sleep(sleep_for)
|
|
76
|
+
self._last_request[host] = time.monotonic()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TokenBucketRateLimiter:
|
|
80
|
+
"""Burst-friendly per-host limiter keyed by request host."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, rate_per_second: float, *, burst: int) -> None:
|
|
83
|
+
if rate_per_second <= 0:
|
|
84
|
+
raise ValueError("rate_per_second must be positive")
|
|
85
|
+
if burst <= 0:
|
|
86
|
+
raise ValueError("burst must be positive")
|
|
87
|
+
self._rate_per_second = rate_per_second
|
|
88
|
+
self._burst = float(burst)
|
|
89
|
+
self._tokens: dict[str, float] = {}
|
|
90
|
+
self._last_refill: dict[str, float] = {}
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
|
|
93
|
+
def before_request(self, host: str) -> None:
|
|
94
|
+
with self._lock:
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
last = self._last_refill.get(host, now)
|
|
97
|
+
tokens = min(
|
|
98
|
+
self._burst,
|
|
99
|
+
self._tokens.get(host, self._burst)
|
|
100
|
+
+ (now - last) * self._rate_per_second,
|
|
101
|
+
)
|
|
102
|
+
if tokens < 1.0:
|
|
103
|
+
sleep_for = (1.0 - tokens) / self._rate_per_second
|
|
104
|
+
time.sleep(sleep_for)
|
|
105
|
+
now = time.monotonic()
|
|
106
|
+
last = self._last_refill.get(host, now)
|
|
107
|
+
tokens = min(
|
|
108
|
+
self._burst,
|
|
109
|
+
self._tokens.get(host, self._burst)
|
|
110
|
+
+ (now - last) * self._rate_per_second,
|
|
111
|
+
)
|
|
112
|
+
self._tokens[host] = tokens - 1.0
|
|
113
|
+
self._last_refill[host] = now
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class _HostConcurrencyState:
|
|
118
|
+
limit: int
|
|
119
|
+
inflight: int = 0
|
|
120
|
+
peak_limit: int = field(init=False)
|
|
121
|
+
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
122
|
+
condition: threading.Condition = field(init=False, repr=False)
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
self.peak_limit = self.limit
|
|
126
|
+
self.condition = threading.Condition(self.lock)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class AdaptiveConcurrencyGate:
|
|
130
|
+
"""Per-host AIMD in-flight limiter (TCP-style additive increase / multiplicative decrease)."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
initial: int,
|
|
136
|
+
minimum: int,
|
|
137
|
+
maximum: int,
|
|
138
|
+
additive_increase: int = _DEFAULT_ADDITIVE_INCREASE,
|
|
139
|
+
multiplicative_decrease: float = _DEFAULT_MULTIPLICATIVE_DECREASE,
|
|
140
|
+
forbidden_window_seconds: float | None = None,
|
|
141
|
+
forbidden_threshold: int = 6,
|
|
142
|
+
forbidden_cooldown_seconds: float = 15.0,
|
|
143
|
+
) -> None:
|
|
144
|
+
if initial <= 0 or minimum <= 0 or maximum <= 0:
|
|
145
|
+
raise ValueError("concurrency limits must be positive")
|
|
146
|
+
if minimum > initial:
|
|
147
|
+
raise ValueError("minimum cannot exceed initial")
|
|
148
|
+
if initial > maximum:
|
|
149
|
+
raise ValueError("initial cannot exceed maximum")
|
|
150
|
+
if not 0.0 < multiplicative_decrease < 1.0:
|
|
151
|
+
raise ValueError("multiplicative_decrease must be between 0 and 1")
|
|
152
|
+
self._initial = initial
|
|
153
|
+
self._minimum = minimum
|
|
154
|
+
self._maximum = maximum
|
|
155
|
+
self._additive_increase = additive_increase
|
|
156
|
+
self._multiplicative_decrease = multiplicative_decrease
|
|
157
|
+
self._forbidden_window_seconds = forbidden_window_seconds
|
|
158
|
+
self._forbidden_threshold = forbidden_threshold
|
|
159
|
+
self._forbidden_cooldown_seconds = forbidden_cooldown_seconds
|
|
160
|
+
self._hosts: dict[str, _HostConcurrencyState] = {}
|
|
161
|
+
self._pressure_events: dict[str, deque[float]] = {}
|
|
162
|
+
self._frozen_until: dict[str, float] = {}
|
|
163
|
+
self._map_lock = threading.Lock()
|
|
164
|
+
self.decrease_count = 0
|
|
165
|
+
self.peak_limit = initial
|
|
166
|
+
self.pressure_event_count = 0
|
|
167
|
+
self.circuit_breaker_trips = 0
|
|
168
|
+
self._policy_key: tuple[Any, ...] | None = None
|
|
169
|
+
|
|
170
|
+
def _remembered_limit(self, host: str) -> int | None:
|
|
171
|
+
if self._policy_key is None:
|
|
172
|
+
return None
|
|
173
|
+
return _HOST_REMEMBERED_LIMITS.get(self._policy_key, {}).get(host)
|
|
174
|
+
|
|
175
|
+
def _remember_limit(self, host: str, limit: int) -> None:
|
|
176
|
+
if self._policy_key is None:
|
|
177
|
+
return
|
|
178
|
+
_HOST_REMEMBERED_LIMITS.setdefault(self._policy_key, {})[host] = limit
|
|
179
|
+
|
|
180
|
+
def _seed_limit(self, host: str) -> int:
|
|
181
|
+
remembered = self._remembered_limit(host)
|
|
182
|
+
if remembered is not None:
|
|
183
|
+
return max(self._minimum, min(self._maximum, remembered))
|
|
184
|
+
return self._initial
|
|
185
|
+
|
|
186
|
+
def _state_for(self, host: str) -> _HostConcurrencyState:
|
|
187
|
+
with self._map_lock:
|
|
188
|
+
state = self._hosts.get(host)
|
|
189
|
+
if state is None:
|
|
190
|
+
state = _HostConcurrencyState(limit=self._seed_limit(host))
|
|
191
|
+
self._hosts[host] = state
|
|
192
|
+
return state
|
|
193
|
+
|
|
194
|
+
def _is_frozen(self, host: str) -> bool:
|
|
195
|
+
frozen_until = self._frozen_until.get(host, 0.0)
|
|
196
|
+
return frozen_until > time.monotonic()
|
|
197
|
+
|
|
198
|
+
def _wait_for_freeze(self, host: str, state: _HostConcurrencyState) -> None:
|
|
199
|
+
while True:
|
|
200
|
+
frozen_until = self._frozen_until.get(host, 0.0)
|
|
201
|
+
now = time.monotonic()
|
|
202
|
+
if frozen_until <= now:
|
|
203
|
+
return
|
|
204
|
+
state.condition.wait(timeout=min(0.05, frozen_until - now))
|
|
205
|
+
|
|
206
|
+
def acquire(self, host: str) -> None:
|
|
207
|
+
state = self._state_for(host)
|
|
208
|
+
with state.condition:
|
|
209
|
+
while True:
|
|
210
|
+
if self._is_frozen(host):
|
|
211
|
+
self._wait_for_freeze(host, state)
|
|
212
|
+
if state.inflight < state.limit:
|
|
213
|
+
state.inflight += 1
|
|
214
|
+
return
|
|
215
|
+
state.condition.wait(timeout=0.05)
|
|
216
|
+
|
|
217
|
+
def _increase_limit(self, host: str, state: _HostConcurrencyState) -> None:
|
|
218
|
+
if self._is_frozen(host):
|
|
219
|
+
return
|
|
220
|
+
if state.limit >= self._maximum:
|
|
221
|
+
return
|
|
222
|
+
state.limit = min(
|
|
223
|
+
self._maximum,
|
|
224
|
+
state.limit + self._additive_increase,
|
|
225
|
+
)
|
|
226
|
+
state.peak_limit = max(state.peak_limit, state.limit)
|
|
227
|
+
self.peak_limit = max(self.peak_limit, state.limit)
|
|
228
|
+
self._remember_limit(host, state.limit)
|
|
229
|
+
|
|
230
|
+
def finish_success(self, host: str) -> None:
|
|
231
|
+
"""Release a slot and apply per-success AIMD increase."""
|
|
232
|
+
|
|
233
|
+
state = self._state_for(host)
|
|
234
|
+
with state.condition:
|
|
235
|
+
state.inflight -= 1
|
|
236
|
+
self._increase_limit(host, state)
|
|
237
|
+
state.condition.notify_all()
|
|
238
|
+
|
|
239
|
+
def finish_pressure(self, host: str) -> None:
|
|
240
|
+
"""Release a slot and apply multiplicative decrease atomically."""
|
|
241
|
+
|
|
242
|
+
state = self._state_for(host)
|
|
243
|
+
with state.condition:
|
|
244
|
+
state.inflight -= 1
|
|
245
|
+
new_limit = max(
|
|
246
|
+
self._minimum,
|
|
247
|
+
int(state.limit * self._multiplicative_decrease),
|
|
248
|
+
)
|
|
249
|
+
if new_limit < state.limit:
|
|
250
|
+
state.limit = new_limit
|
|
251
|
+
self.decrease_count += 1
|
|
252
|
+
self._remember_limit(host, state.limit)
|
|
253
|
+
state.condition.notify_all()
|
|
254
|
+
|
|
255
|
+
def record_success(self, host: str) -> None:
|
|
256
|
+
state = self._state_for(host)
|
|
257
|
+
with state.condition:
|
|
258
|
+
self._increase_limit(host, state)
|
|
259
|
+
state.condition.notify_all()
|
|
260
|
+
|
|
261
|
+
def record_pressure(self, host: str) -> None:
|
|
262
|
+
state = self._state_for(host)
|
|
263
|
+
with state.condition:
|
|
264
|
+
new_limit = max(
|
|
265
|
+
self._minimum,
|
|
266
|
+
int(state.limit * self._multiplicative_decrease),
|
|
267
|
+
)
|
|
268
|
+
if new_limit < state.limit:
|
|
269
|
+
state.limit = new_limit
|
|
270
|
+
self.decrease_count += 1
|
|
271
|
+
self._remember_limit(host, state.limit)
|
|
272
|
+
state.condition.notify_all()
|
|
273
|
+
|
|
274
|
+
def record_circuit_breaker_pressure(self, host: str) -> bool:
|
|
275
|
+
"""Record classified pressure and trip the breaker when events cluster."""
|
|
276
|
+
|
|
277
|
+
if self._forbidden_window_seconds is None:
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
now = time.monotonic()
|
|
281
|
+
with self._map_lock:
|
|
282
|
+
events = self._pressure_events.setdefault(host, deque())
|
|
283
|
+
events.append(now)
|
|
284
|
+
self.pressure_event_count += 1
|
|
285
|
+
cutoff = now - self._forbidden_window_seconds
|
|
286
|
+
while events and events[0] < cutoff:
|
|
287
|
+
events.popleft()
|
|
288
|
+
if len(events) < self._forbidden_threshold:
|
|
289
|
+
return False
|
|
290
|
+
events.clear()
|
|
291
|
+
self._frozen_until[host] = now + self._forbidden_cooldown_seconds
|
|
292
|
+
self.circuit_breaker_trips += 1
|
|
293
|
+
|
|
294
|
+
state = self._state_for(host)
|
|
295
|
+
with state.condition:
|
|
296
|
+
if state.limit > self._minimum:
|
|
297
|
+
state.limit = self._minimum
|
|
298
|
+
self.decrease_count += 1
|
|
299
|
+
self._remember_limit(host, state.limit)
|
|
300
|
+
state.condition.notify_all()
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
def circuit_breaker_frozen(self, host: str) -> bool:
|
|
304
|
+
return self._is_frozen(host)
|
|
305
|
+
|
|
306
|
+
def current_limit(self, host: str) -> int:
|
|
307
|
+
state = self._state_for(host)
|
|
308
|
+
with state.condition:
|
|
309
|
+
return state.limit
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_ADAPTIVE_GATES: dict[tuple[Any, ...], AdaptiveConcurrencyGate] = {}
|
|
313
|
+
_HOST_REMEMBERED_LIMITS: dict[tuple[Any, ...], dict[str, int]] = {}
|
|
314
|
+
_ADAPTIVE_GATES_LOCK = threading.Lock()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _adaptive_gate_key(policy: FetchPolicy) -> tuple[Any, ...]:
|
|
318
|
+
"""Stable key for sharing AIMD state (initial seed is not part of identity)."""
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
policy.min_concurrent,
|
|
322
|
+
policy.max_concurrent,
|
|
323
|
+
policy.multiplicative_decrease,
|
|
324
|
+
policy.additive_increase,
|
|
325
|
+
policy.forbidden_circuit_breaker,
|
|
326
|
+
policy.forbidden_window_seconds,
|
|
327
|
+
policy.forbidden_threshold,
|
|
328
|
+
policy.forbidden_cooldown_seconds,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _shared_adaptive_gate(policy: FetchPolicy) -> AdaptiveConcurrencyGate:
|
|
333
|
+
key = _adaptive_gate_key(policy)
|
|
334
|
+
with _ADAPTIVE_GATES_LOCK:
|
|
335
|
+
gate = _ADAPTIVE_GATES.get(key)
|
|
336
|
+
if gate is None:
|
|
337
|
+
gate = AdaptiveConcurrencyGate(
|
|
338
|
+
initial=policy.initial_concurrent,
|
|
339
|
+
minimum=policy.min_concurrent,
|
|
340
|
+
maximum=policy.max_concurrent,
|
|
341
|
+
additive_increase=policy.additive_increase,
|
|
342
|
+
multiplicative_decrease=policy.multiplicative_decrease,
|
|
343
|
+
forbidden_window_seconds=(
|
|
344
|
+
policy.forbidden_window_seconds
|
|
345
|
+
if policy.forbidden_circuit_breaker
|
|
346
|
+
else None
|
|
347
|
+
),
|
|
348
|
+
forbidden_threshold=policy.forbidden_threshold,
|
|
349
|
+
forbidden_cooldown_seconds=policy.forbidden_cooldown_seconds,
|
|
350
|
+
)
|
|
351
|
+
gate._policy_key = key
|
|
352
|
+
_ADAPTIVE_GATES[key] = gate
|
|
353
|
+
return gate
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def reset_adaptive_gates() -> None:
|
|
357
|
+
"""Discard shared AIMD gate state (primarily for tests)."""
|
|
358
|
+
|
|
359
|
+
with _ADAPTIVE_GATES_LOCK:
|
|
360
|
+
_ADAPTIVE_GATES.clear()
|
|
361
|
+
_HOST_REMEMBERED_LIMITS.clear()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class RetryableHTTPError(Exception):
|
|
365
|
+
"""Raised internally to trigger tenacity retries for retryable HTTP codes."""
|
|
366
|
+
|
|
367
|
+
def __init__(self, response: httpx.Response, retry_after: float | None) -> None:
|
|
368
|
+
self.response = response
|
|
369
|
+
self.retry_after = retry_after
|
|
370
|
+
super().__init__(f"HTTP {response.status_code}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass
|
|
374
|
+
class FetchStats:
|
|
375
|
+
"""Runtime counters observed by :class:`TileFetcher` (for tests and benches)."""
|
|
376
|
+
|
|
377
|
+
max_inflight: int = 0
|
|
378
|
+
request_count: int = 0
|
|
379
|
+
retry_count: int = 0
|
|
380
|
+
peak_concurrency_limit: int = 0
|
|
381
|
+
concurrency_decreases: int = 0
|
|
382
|
+
current_limit: int = 0
|
|
383
|
+
pressure_event_count: int = 0
|
|
384
|
+
circuit_breaker_trips: int = 0
|
|
385
|
+
circuit_breaker_frozen: bool = False
|
|
386
|
+
_current_inflight: int = field(default=0, repr=False)
|
|
387
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
388
|
+
|
|
389
|
+
def reset(self) -> None:
|
|
390
|
+
with self._lock:
|
|
391
|
+
self.max_inflight = 0
|
|
392
|
+
self.request_count = 0
|
|
393
|
+
self.retry_count = 0
|
|
394
|
+
self.peak_concurrency_limit = 0
|
|
395
|
+
self.concurrency_decreases = 0
|
|
396
|
+
self.current_limit = 0
|
|
397
|
+
self.pressure_event_count = 0
|
|
398
|
+
self.circuit_breaker_trips = 0
|
|
399
|
+
self.circuit_breaker_frozen = False
|
|
400
|
+
self._current_inflight = 0
|
|
401
|
+
|
|
402
|
+
def _enter_inflight(self) -> None:
|
|
403
|
+
with self._lock:
|
|
404
|
+
self._current_inflight += 1
|
|
405
|
+
self.max_inflight = max(self.max_inflight, self._current_inflight)
|
|
406
|
+
|
|
407
|
+
def _leave_inflight(self) -> None:
|
|
408
|
+
with self._lock:
|
|
409
|
+
self._current_inflight -= 1
|
|
410
|
+
|
|
411
|
+
def _record_request(self) -> None:
|
|
412
|
+
with self._lock:
|
|
413
|
+
self.request_count += 1
|
|
414
|
+
|
|
415
|
+
def _record_retry(self) -> None:
|
|
416
|
+
with self._lock:
|
|
417
|
+
self.retry_count += 1
|
|
418
|
+
|
|
419
|
+
def _sync_adaptive_stats(
|
|
420
|
+
self, gate: AdaptiveConcurrencyGate, host: str | None = None
|
|
421
|
+
) -> None:
|
|
422
|
+
with self._lock:
|
|
423
|
+
self.peak_concurrency_limit = max(
|
|
424
|
+
self.peak_concurrency_limit, gate.peak_limit
|
|
425
|
+
)
|
|
426
|
+
self.concurrency_decreases = gate.decrease_count
|
|
427
|
+
self.pressure_event_count = gate.pressure_event_count
|
|
428
|
+
self.circuit_breaker_trips = gate.circuit_breaker_trips
|
|
429
|
+
if host is not None:
|
|
430
|
+
self.current_limit = gate.current_limit(host)
|
|
431
|
+
self.circuit_breaker_frozen = gate.circuit_breaker_frozen(host)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@dataclass(frozen=True)
|
|
435
|
+
class FetchPolicy:
|
|
436
|
+
"""Configuration for :class:`TileFetcher`."""
|
|
437
|
+
|
|
438
|
+
max_concurrent: int = _DEFAULT_MAX_CONCURRENT
|
|
439
|
+
max_connections: int | None = None
|
|
440
|
+
retries: int = 3
|
|
441
|
+
timeout: float = 30.0
|
|
442
|
+
rate_limit_per_second: float | None = _DEFAULT_RATE_LIMIT_PER_SECOND
|
|
443
|
+
rate_limiter: HostRateLimiter | None = None
|
|
444
|
+
adaptive_concurrency: bool = False
|
|
445
|
+
initial_concurrent: int = _DEFAULT_INITIAL_CONCURRENT
|
|
446
|
+
min_concurrent: int = _DEFAULT_MIN_CONCURRENT
|
|
447
|
+
multiplicative_decrease: float = _DEFAULT_MULTIPLICATIVE_DECREASE
|
|
448
|
+
additive_increase: int = _DEFAULT_ADDITIVE_INCREASE
|
|
449
|
+
forbidden_circuit_breaker: bool = False
|
|
450
|
+
forbidden_window_seconds: float = 5.0
|
|
451
|
+
forbidden_threshold: int = 6
|
|
452
|
+
forbidden_cooldown_seconds: float = 15.0
|
|
453
|
+
pressure_classifier: PressureClassifier | None = None
|
|
454
|
+
|
|
455
|
+
def cache_key(self) -> tuple[Any, ...]:
|
|
456
|
+
"""Hashable key for sharing fetcher instances."""
|
|
457
|
+
|
|
458
|
+
return (
|
|
459
|
+
self.max_concurrent,
|
|
460
|
+
self.max_connections,
|
|
461
|
+
self.retries,
|
|
462
|
+
self.timeout,
|
|
463
|
+
self.rate_limit_per_second,
|
|
464
|
+
self.adaptive_concurrency,
|
|
465
|
+
self.initial_concurrent,
|
|
466
|
+
self.min_concurrent,
|
|
467
|
+
self.multiplicative_decrease,
|
|
468
|
+
self.additive_increase,
|
|
469
|
+
self.forbidden_circuit_breaker,
|
|
470
|
+
self.forbidden_window_seconds,
|
|
471
|
+
self.forbidden_threshold,
|
|
472
|
+
self.forbidden_cooldown_seconds,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass
|
|
477
|
+
class FetchProgress:
|
|
478
|
+
"""Thread-safe progress tracker for tile acquisition."""
|
|
479
|
+
|
|
480
|
+
total: int
|
|
481
|
+
on_progress: ProgressCallback | None = None
|
|
482
|
+
done: int = field(default=0, init=False)
|
|
483
|
+
errors: list[str] = field(default_factory=list, init=False)
|
|
484
|
+
_aborted: bool = field(default=False, init=False)
|
|
485
|
+
_abort_reason: str | None = field(default=None, init=False)
|
|
486
|
+
_lock: threading.Lock = field(
|
|
487
|
+
default_factory=threading.Lock, init=False, repr=False
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
def abort(self, reason: str) -> None:
|
|
491
|
+
"""Stop sibling tile fetches after a hard failure."""
|
|
492
|
+
|
|
493
|
+
with self._lock:
|
|
494
|
+
if not self._aborted:
|
|
495
|
+
self._aborted = True
|
|
496
|
+
self._abort_reason = reason
|
|
497
|
+
|
|
498
|
+
def is_aborted(self) -> bool:
|
|
499
|
+
with self._lock:
|
|
500
|
+
return self._aborted
|
|
501
|
+
|
|
502
|
+
def check_not_aborted(self) -> None:
|
|
503
|
+
"""Raise :class:`~tilearray.errors.NetworkError` when the session aborted."""
|
|
504
|
+
|
|
505
|
+
with self._lock:
|
|
506
|
+
if self._aborted:
|
|
507
|
+
raise NetworkError(
|
|
508
|
+
self._abort_reason or "Tile fetch aborted after earlier failure"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def tick(self, request: TileRequest, response: TileResponse) -> None:
|
|
512
|
+
with self._lock:
|
|
513
|
+
self.done += 1
|
|
514
|
+
if not response.success:
|
|
515
|
+
message = response.error_message or f"HTTP {response.status_code}"
|
|
516
|
+
self.errors.append(f"{request.url}: {message}")
|
|
517
|
+
logger.warning(
|
|
518
|
+
"Tile fetch failed (%s/%s): %s", self.done, self.total, message
|
|
519
|
+
)
|
|
520
|
+
if self.on_progress is not None:
|
|
521
|
+
self.on_progress(self.done, self.total, request, response)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _parse_retry_after(response: httpx.Response) -> float | None:
|
|
525
|
+
header = response.headers.get("Retry-After")
|
|
526
|
+
if not header:
|
|
527
|
+
return None
|
|
528
|
+
try:
|
|
529
|
+
return float(header)
|
|
530
|
+
except ValueError:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _retry_wait(retry_state: RetryCallState) -> float:
|
|
535
|
+
exc = retry_state.outcome.exception() if retry_state.outcome else None
|
|
536
|
+
if isinstance(exc, RetryableHTTPError) and exc.retry_after is not None:
|
|
537
|
+
return max(exc.retry_after, 0.0)
|
|
538
|
+
jitter_wait = wait_exponential_jitter(initial=1, max=60)
|
|
539
|
+
return float(jitter_wait(retry_state))
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _response_from_httpx(response: httpx.Response, request_url: str) -> TileResponse:
|
|
543
|
+
if response.status_code == 200:
|
|
544
|
+
return TileResponse(
|
|
545
|
+
data=response.content,
|
|
546
|
+
content_type=response.headers.get("content-type", ""),
|
|
547
|
+
status_code=response.status_code,
|
|
548
|
+
headers=dict(response.headers),
|
|
549
|
+
url=str(response.url),
|
|
550
|
+
success=True,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
error_msg = f"HTTP {response.status_code}: {response.text[:200]}"
|
|
554
|
+
return TileResponse(
|
|
555
|
+
data=b"",
|
|
556
|
+
content_type=response.headers.get("content-type", ""),
|
|
557
|
+
status_code=response.status_code,
|
|
558
|
+
headers=dict(response.headers),
|
|
559
|
+
url=str(response.url),
|
|
560
|
+
success=False,
|
|
561
|
+
error_message=error_msg,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class TileFetcher:
|
|
566
|
+
"""Sync tile fetcher backed by a shared :class:`httpx.Client`."""
|
|
567
|
+
|
|
568
|
+
_instances: dict[tuple[Any, ...], TileFetcher] = {}
|
|
569
|
+
_instances_lock = threading.Lock()
|
|
570
|
+
|
|
571
|
+
def __init__(self, policy: FetchPolicy | None = None) -> None:
|
|
572
|
+
self._policy = policy or FetchPolicy()
|
|
573
|
+
self._pressure_classifier = (
|
|
574
|
+
self._policy.pressure_classifier or DEFAULT_PRESSURE_CLASSIFIER
|
|
575
|
+
)
|
|
576
|
+
self.stats = FetchStats()
|
|
577
|
+
pool_size = self._policy.max_connections or self._policy.max_concurrent
|
|
578
|
+
self._client = httpx.Client(
|
|
579
|
+
limits=httpx.Limits(
|
|
580
|
+
max_connections=pool_size,
|
|
581
|
+
max_keepalive_connections=self._policy.max_concurrent,
|
|
582
|
+
),
|
|
583
|
+
timeout=httpx.Timeout(self._policy.timeout),
|
|
584
|
+
)
|
|
585
|
+
self._adaptive_gate: AdaptiveConcurrencyGate | None = None
|
|
586
|
+
self._semaphore: threading.Semaphore | None = None
|
|
587
|
+
if self._policy.adaptive_concurrency:
|
|
588
|
+
self._adaptive_gate = _shared_adaptive_gate(self._policy)
|
|
589
|
+
self.stats.peak_concurrency_limit = self._adaptive_gate.peak_limit
|
|
590
|
+
else:
|
|
591
|
+
self._semaphore = threading.Semaphore(self._policy.max_concurrent)
|
|
592
|
+
if self._policy.rate_limiter is not None:
|
|
593
|
+
self._rate_limiter: HostRateLimiter = self._policy.rate_limiter
|
|
594
|
+
elif self._policy.rate_limit_per_second is not None:
|
|
595
|
+
burst = (
|
|
596
|
+
self._policy.initial_concurrent
|
|
597
|
+
if self._policy.adaptive_concurrency
|
|
598
|
+
else self._policy.max_concurrent
|
|
599
|
+
)
|
|
600
|
+
self._rate_limiter = TokenBucketRateLimiter(
|
|
601
|
+
self._policy.rate_limit_per_second,
|
|
602
|
+
burst=burst,
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
self._rate_limiter = NoOpRateLimiter()
|
|
606
|
+
|
|
607
|
+
@classmethod
|
|
608
|
+
def for_policy(cls, policy: FetchPolicy | None = None) -> TileFetcher:
|
|
609
|
+
"""Return a shared fetcher instance for the given policy."""
|
|
610
|
+
|
|
611
|
+
effective = policy or FetchPolicy()
|
|
612
|
+
key = effective.cache_key()
|
|
613
|
+
with cls._instances_lock:
|
|
614
|
+
fetcher = cls._instances.get(key)
|
|
615
|
+
if fetcher is None:
|
|
616
|
+
fetcher = cls(effective)
|
|
617
|
+
cls._instances[key] = fetcher
|
|
618
|
+
return fetcher
|
|
619
|
+
|
|
620
|
+
def close(self) -> None:
|
|
621
|
+
self._client.close()
|
|
622
|
+
|
|
623
|
+
@classmethod
|
|
624
|
+
def reset_instances(cls) -> None:
|
|
625
|
+
"""Close and discard cached fetcher instances (primarily for tests)."""
|
|
626
|
+
|
|
627
|
+
global _default_fetcher
|
|
628
|
+
with cls._instances_lock:
|
|
629
|
+
for fetcher in cls._instances.values():
|
|
630
|
+
fetcher.close()
|
|
631
|
+
cls._instances.clear()
|
|
632
|
+
reset_adaptive_gates()
|
|
633
|
+
_default_fetcher = TileFetcher.for_policy(FetchPolicy())
|
|
634
|
+
|
|
635
|
+
def _acquire_concurrency(self, host: str) -> None:
|
|
636
|
+
if self._adaptive_gate is not None:
|
|
637
|
+
self._adaptive_gate.acquire(host)
|
|
638
|
+
return
|
|
639
|
+
assert self._semaphore is not None
|
|
640
|
+
self._semaphore.acquire()
|
|
641
|
+
|
|
642
|
+
def _finish_success(self, host: str) -> None:
|
|
643
|
+
if self._adaptive_gate is not None:
|
|
644
|
+
self._adaptive_gate.finish_success(host)
|
|
645
|
+
self.stats._sync_adaptive_stats(self._adaptive_gate, host)
|
|
646
|
+
return
|
|
647
|
+
assert self._semaphore is not None
|
|
648
|
+
self._semaphore.release()
|
|
649
|
+
|
|
650
|
+
def _finish_pressure(self, host: str) -> None:
|
|
651
|
+
if self._adaptive_gate is not None:
|
|
652
|
+
self._adaptive_gate.finish_pressure(host)
|
|
653
|
+
self.stats._sync_adaptive_stats(self._adaptive_gate, host)
|
|
654
|
+
return
|
|
655
|
+
assert self._semaphore is not None
|
|
656
|
+
self._semaphore.release()
|
|
657
|
+
|
|
658
|
+
def _record_pressure(self, host: str) -> None:
|
|
659
|
+
if self._adaptive_gate is not None:
|
|
660
|
+
self._adaptive_gate.record_pressure(host)
|
|
661
|
+
self.stats._sync_adaptive_stats(self._adaptive_gate, host)
|
|
662
|
+
|
|
663
|
+
def _classify_response(self, response: httpx.Response) -> ResponseClassification:
|
|
664
|
+
return classify_response(response, self._pressure_classifier)
|
|
665
|
+
|
|
666
|
+
def _record_circuit_breaker_pressure(self, host: str) -> None:
|
|
667
|
+
if self._adaptive_gate is not None:
|
|
668
|
+
self._adaptive_gate.record_circuit_breaker_pressure(host)
|
|
669
|
+
self.stats._sync_adaptive_stats(self._adaptive_gate, host)
|
|
670
|
+
|
|
671
|
+
def fetch(
|
|
672
|
+
self,
|
|
673
|
+
request: TileRequest,
|
|
674
|
+
*,
|
|
675
|
+
progress: FetchProgress | None = None,
|
|
676
|
+
) -> TileResponse:
|
|
677
|
+
if not request.url:
|
|
678
|
+
raise ValueError("URL is required")
|
|
679
|
+
|
|
680
|
+
if progress is not None:
|
|
681
|
+
progress.check_not_aborted()
|
|
682
|
+
|
|
683
|
+
headers = dict(request.headers or {})
|
|
684
|
+
if request.output_format:
|
|
685
|
+
headers.setdefault("Accept", request.output_format.value)
|
|
686
|
+
|
|
687
|
+
host = urlparse(request.url).netloc or request.url
|
|
688
|
+
attempts = max(request.retries, self._policy.retries) + 1
|
|
689
|
+
|
|
690
|
+
def _before_sleep(retry_state: RetryCallState) -> None:
|
|
691
|
+
if progress is not None:
|
|
692
|
+
progress.check_not_aborted()
|
|
693
|
+
self.stats._record_retry()
|
|
694
|
+
|
|
695
|
+
@retry(
|
|
696
|
+
retry=retry_if_exception_type((httpx.TransportError, RetryableHTTPError)),
|
|
697
|
+
wait=_retry_wait,
|
|
698
|
+
stop=stop_after_attempt(attempts),
|
|
699
|
+
reraise=True,
|
|
700
|
+
before_sleep=_before_sleep,
|
|
701
|
+
)
|
|
702
|
+
def _perform_get() -> httpx.Response:
|
|
703
|
+
if progress is not None:
|
|
704
|
+
progress.check_not_aborted()
|
|
705
|
+
self._acquire_concurrency(host)
|
|
706
|
+
try:
|
|
707
|
+
if progress is not None:
|
|
708
|
+
progress.check_not_aborted()
|
|
709
|
+
self._rate_limiter.before_request(host)
|
|
710
|
+
self.stats._enter_inflight()
|
|
711
|
+
try:
|
|
712
|
+
self.stats._record_request()
|
|
713
|
+
logger.debug("Fetching tile: %s", request.url)
|
|
714
|
+
response = self._client.get(
|
|
715
|
+
request.url,
|
|
716
|
+
params=request.params or None,
|
|
717
|
+
headers=headers,
|
|
718
|
+
)
|
|
719
|
+
finally:
|
|
720
|
+
self.stats._leave_inflight()
|
|
721
|
+
except httpx.TransportError:
|
|
722
|
+
self._finish_pressure(host)
|
|
723
|
+
raise
|
|
724
|
+
|
|
725
|
+
classification = self._classify_response(response)
|
|
726
|
+
if classification.retryable:
|
|
727
|
+
self._finish_pressure(host)
|
|
728
|
+
if classification.circuit_breaker:
|
|
729
|
+
self._record_circuit_breaker_pressure(host)
|
|
730
|
+
raise RetryableHTTPError(response, _parse_retry_after(response))
|
|
731
|
+
|
|
732
|
+
self._finish_success(host)
|
|
733
|
+
return response
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
response = _perform_get()
|
|
737
|
+
except RetryableHTTPError as exc:
|
|
738
|
+
return _response_from_httpx(exc.response, request.url)
|
|
739
|
+
except httpx.TransportError as exc:
|
|
740
|
+
return TileResponse(
|
|
741
|
+
data=b"",
|
|
742
|
+
content_type="",
|
|
743
|
+
status_code=0,
|
|
744
|
+
headers={},
|
|
745
|
+
url=request.url,
|
|
746
|
+
success=False,
|
|
747
|
+
error_message=f"Network error: {exc}",
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
return _response_from_httpx(response, request.url)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
_default_fetcher = TileFetcher.for_policy(FetchPolicy())
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def get_fetcher(policy: FetchPolicy | None = None) -> TileFetcher:
|
|
757
|
+
"""Return the shared :class:`TileFetcher` for ``policy`` (or the default)."""
|
|
758
|
+
|
|
759
|
+
if policy is None:
|
|
760
|
+
return _default_fetcher
|
|
761
|
+
return TileFetcher.for_policy(policy)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def fetch_tile_with_policy(
|
|
765
|
+
request: TileRequest,
|
|
766
|
+
policy: FetchPolicy | None = None,
|
|
767
|
+
*,
|
|
768
|
+
progress: FetchProgress | None = None,
|
|
769
|
+
) -> TileResponse:
|
|
770
|
+
"""Fetch a tile using the configured :class:`TileFetcher`."""
|
|
771
|
+
|
|
772
|
+
return get_fetcher(policy).fetch(request, progress=progress)
|