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/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)