-
Notifications
You must be signed in to change notification settings - Fork 60
Expand file tree
/
Copy pathchunking.py
More file actions
1750 lines (1523 loc) · 68.4 KB
/
Copy pathchunking.py
File metadata and controls
1750 lines (1523 loc) · 68.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Joint URL-byte chunking for the Water Data OGC getters.
A Water Data query has several chunkable axes: every multi-value list
parameter (sites, parameter codes, …) plus the cql-text ``filter``,
which splits along its top-level OR clauses. Any of them can fan the
URL past the server's ~8 KB byte limit. ``ChunkPlan`` picks a fan-out
for each axis that minimizes total sub-requests while keeping every
sub-request URL under the budget; ``ChunkedCall`` fetches the resulting
cartesian product of chunks. Requests that already fit get a trivial
single-step plan — ``ChunkedCall`` has one code path either way.
Concurrency: ``multi_value_chunked`` fans every pending sub-request out
under one ``asyncio.gather`` sharing a single ``httpx.AsyncClient``. An
``asyncio.Semaphore`` — not the client's connection pool, which is
merely sized to match — caps the sub-requests in flight at ``N``; see
:meth:`ChunkedCall._run` for why the gate must be the semaphore rather
than the pool. ``API_USGS_CONCURRENT`` resolves ``N``: an integer N > 1
allows N sub-requests in flight; ``1`` pins sequential dispatch (one
request at a time); the literal ``unbounded`` lifts the cap. The
default (16) is the server-friendly sweet spot; higher values can trip
USGS burst-protection 5xx in practice. The fan-out runs in a
short-lived worker thread (an ``anyio`` blocking portal), so it works
whether or not the caller is already inside an event loop (Jupyter /
IPython / async apps).
Retries: each sub-request is retried on a transient failure (429,
5xx, connect/read timeout) with exponential backoff + full jitter,
honoring a server ``Retry-After`` when present. ``API_USGS_RETRIES``
sets the cap (default 4; ``0`` disables). A ``Retry-After`` longer
than the per-call ceiling escalates to a resumable interruption.
Interruption: any mid-stream transient failure — 429, 5xx, or a bare
transport error (connect/read timeout, oversize follow-up URL) — surfaces
as a ``ChunkInterrupted`` subclass: ``QuotaExhausted`` for 429,
``ServiceInterrupted`` for the rest. The exception carries ``.call``, a
``ChunkedCall`` handle that owns the already-completed sub-request
state (sparse-indexed, since gathered sub-requests complete out of
order). Call ``.call.resume()`` once the underlying condition clears;
only the still-pending sub-requests are re-issued. ``Retry-After`` (when
the server sets it) is surfaced on the exception as ``.retry_after``.
Dedup: list-axis chunks don't overlap; filter-axis chunks can, so
``_combine_chunk_frames`` dedupes by feature ``id``. ``properties``,
``bbox``, date intervals, ``limit``, ``skip_geometry``, and
``filter``/``filter_lang`` themselves are never sliced as list axes
(the filter is partitioned along its top-level OR axis instead).
"""
from __future__ import annotations
import asyncio
import copy
import functools
import itertools
import math
import os
import random
from collections.abc import Awaitable, Callable, Iterator
from contextlib import contextmanager, suppress
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, ClassVar, cast
from urllib.parse import quote_plus
import httpx
import pandas as pd
from anyio.from_thread import start_blocking_portal
from dataretrieval.exceptions import (
DataRetrievalError,
RateLimited,
ServiceUnavailable,
TransientError,
Unchunkable,
)
from dataretrieval.utils import HTTPX_DEFAULTS
from . import _progress
from .filters import (
_check_numeric_filter_pitfall,
_is_chunkable,
_split_top_level_or,
)
# Empirically the API replies HTTP 414 above ~8200 bytes of full URL —
# matches nginx's default ``large_client_header_buffers`` of 8 KB. 8000
# leaves ~200 bytes for request-line framing and proxy variance.
_WATERDATA_URL_BYTE_LIMIT = 8000
# Any list-shaped kwarg with >1 element is chunked (comma-joined per
# sub-list in the URL); ~90 OGC params qualify, so we denylist the few
# exceptions rather than maintain a growing allowlist. Excluded because:
# ``properties`` defines the column schema; ``bbox`` is a fixed coord
# tuple; date/time params are intervals, not enumerable sets; ``filter``
# is handled as its own OR-axis in ``_extract_axes``; and ``limit`` /
# ``skip_geometry`` / ``filter_lang`` are scalar by contract.
_NEVER_CHUNK = frozenset(
{
"properties",
"bbox",
"datetime",
"last_modified",
"begin",
"begin_utc",
"end",
"end_utc",
"time",
"filter",
"filter_lang",
"limit",
"skip_geometry",
}
)
# Response header USGS uses to advertise remaining hourly quota.
_QUOTA_HEADER = "x-ratelimit-remaining"
# Fan-out concurrency cap, read at call time (not import) so test
# ``monkeypatch.setenv`` applies. Value grammar in :func:`_read_concurrency_env`;
# the concurrency model is in the module docstring.
_CONCURRENCY_ENV = "API_USGS_CONCURRENT"
_CONCURRENCY_DEFAULT = 16
_CONCURRENCY_UNBOUNDED = "unbounded"
def _read_concurrency_env() -> int | None:
"""
Resolve the ``API_USGS_CONCURRENT`` env var to a parallelism cap.
Returns
-------
int or None
``1`` for sequential dispatch (one sub-request at a time); an
integer >1 for bounded concurrency; ``None`` to disable the
per-call cap entirely (``unbounded`` keyword). Unset → default
of ``_CONCURRENCY_DEFAULT``.
"""
raw = os.environ.get(_CONCURRENCY_ENV)
if raw is None:
return _CONCURRENCY_DEFAULT
raw = raw.strip()
if raw == "":
return _CONCURRENCY_DEFAULT
if raw.lower() == _CONCURRENCY_UNBOUNDED:
return None
try:
value = int(raw)
except ValueError as exc:
raise ValueError(
f"{_CONCURRENCY_ENV} must be a positive integer or "
f"'{_CONCURRENCY_UNBOUNDED}'; got {raw!r}."
) from exc
if value < 1:
raise ValueError(
f"{_CONCURRENCY_ENV} must be >= 1 (got {value}); use "
f"'{_CONCURRENCY_UNBOUNDED}' to disable the cap."
)
return value
# Retry-with-backoff defaults for transient sub-request failures (429 /
# 5xx / connect-read timeouts): exponential backoff with full jitter, and
# honor a server ``Retry-After`` up to the cap below before escalating
# to a resumable interruption instead.
_RETRIES_ENV = "API_USGS_RETRIES"
_RETRIES_DEFAULT = 4
_RETRY_BASE_BACKOFF = 0.5
_RETRY_MAX_BACKOFF = 30.0
_RETRY_AFTER_CAP = 60.0
def _read_retries_env() -> int:
"""
Resolve the ``API_USGS_RETRIES`` env var to a max-retry count.
Returns
-------
int
Number of retries after the first attempt; ``0`` disables
retrying. Unset/blank → ``_RETRIES_DEFAULT``.
"""
raw = os.environ.get(_RETRIES_ENV)
if raw is None or raw.strip() == "":
return _RETRIES_DEFAULT
try:
value = int(raw.strip())
except ValueError as exc:
raise ValueError(
f"{_RETRIES_ENV} must be a non-negative integer (got {raw!r})."
) from exc
if value < 0:
raise ValueError(f"{_RETRIES_ENV} must be >= 0 (got {value}).")
return value
@dataclass(frozen=True)
class RetryPolicy:
"""Bounded retry-with-backoff config for transient sub-request failures.
An immutable value object that owns the *timing* decisions; the
exception taxonomy (which failures are retryable) lives in
:func:`_retryable`. Backoff is exponential with **full jitter**
(:func:`random.uniform` over ``[0, ceiling]``) so the concurrent
fan-out's retries don't re-burst in lockstep. A server ``Retry-After``
hint, when present, overrides the computed backoff — unless it exceeds
:attr:`retry_after_cap`, in which case retrying stops and the failure
surfaces as a resumable :class:`ChunkInterrupted` (a multi-minute
quota-window reset shouldn't block the call inline).
Attributes
----------
max_retries : int
Retries attempted after the first try; ``0`` disables retrying.
base_backoff : float
Seconds; the jitter ceiling for the first retry, doubled each
subsequent attempt.
max_backoff : float
Upper bound on any single attempt's backoff ceiling.
retry_after_cap : float
Largest ``Retry-After`` (seconds) honored inline; longer hints
escalate to a resumable interruption.
"""
max_retries: int = _RETRIES_DEFAULT
base_backoff: float = _RETRY_BASE_BACKOFF
max_backoff: float = _RETRY_MAX_BACKOFF
retry_after_cap: float = _RETRY_AFTER_CAP
def __post_init__(self) -> None:
# Catch invalid timing knobs here so a misconfiguration fails at
# construction, not deep in a later ``time.sleep`` (ValueError on
# a negative delay) or silently in ``asyncio.sleep`` (which
# treats negative as zero).
if self.max_retries < 0:
raise ValueError(f"max_retries must be >= 0 (got {self.max_retries}).")
if self.base_backoff < 0 or self.max_backoff < 0 or self.retry_after_cap < 0:
raise ValueError("retry backoff settings must be non-negative.")
@classmethod
def from_env(cls) -> RetryPolicy:
"""
Build a policy from the module-level defaults, resolved now.
Reads ``max_retries`` from ``API_USGS_RETRIES`` and the timing
knobs from the ``_RETRY_*`` module constants at call time — not
the dataclass field defaults (which freeze at class definition)
— so test ``monkeypatch.setattr`` on the constants takes effect.
Returns
-------
RetryPolicy
A policy built from the module-level defaults resolved at
call time.
"""
return cls(
max_retries=_read_retries_env(),
base_backoff=_RETRY_BASE_BACKOFF,
max_backoff=_RETRY_MAX_BACKOFF,
retry_after_cap=_RETRY_AFTER_CAP,
)
def should_retry(self, attempt: int, retry_after: float | None) -> bool:
"""
Whether a just-failed ``attempt`` (1-based) warrants another try.
A ``Retry-After`` longer than ``retry_after_cap`` is *not* slept
off inline — it returns ``False`` so the failure escalates to a
resumable interruption instead of blocking the call for minutes.
Parameters
----------
attempt : int
The just-failed attempt number (1-based).
retry_after : float or None
Seconds the server suggested waiting (``Retry-After`` hint),
or ``None`` when no hint was given.
Returns
-------
bool
``True`` if another try is warranted, ``False`` otherwise.
"""
if attempt > self.max_retries:
return False
return retry_after is None or retry_after <= self.retry_after_cap
def backoff(self, attempt: int, retry_after: float | None) -> float:
"""
Seconds to wait before retry ``attempt`` (1-based).
Parameters
----------
attempt : int
The retry attempt number (1-based).
retry_after : float or None
Seconds the server suggested waiting (``Retry-After`` hint),
or ``None`` to use the computed exponential backoff instead.
Returns
-------
float
Seconds to wait before the retry.
"""
if retry_after is not None:
return retry_after
ceiling = min(self.max_backoff, self.base_backoff * 2 ** (attempt - 1))
return random.uniform(0.0, ceiling)
# Default for direct ``ChunkedCall`` / ``ChunkPlan.execute`` construction
# (and tests): no retrying. The production decorator path explicitly passes
# ``RetryPolicy.from_env()`` so retries are on by default there.
_NO_RETRY = RetryPolicy(max_retries=0)
# Shared per-call ``httpx.AsyncClient``, published via :func:`_publish`
# during ``ChunkedCall._run`` so paginated-loop helpers (``_walk_pages``)
# reuse the same connection pool across every sub-request. ``None``
# outside a chunked call — paginated helpers then open their own
# short-lived client.
_chunked_client: ContextVar[httpx.AsyncClient | None] = ContextVar(
"_chunked_client", default=None
)
@contextmanager
def _publish(client: httpx.AsyncClient) -> Iterator[None]:
"""
Publish ``client`` on the ``_chunked_client`` ContextVar so the
paginated-loop helpers can borrow it via :func:`get_active_client`
for the duration of the ``with`` block.
Parameters
----------
client : httpx.AsyncClient
The client to publish.
Yields
------
None
Yields once, for the duration of the bind.
"""
token = _chunked_client.set(client)
try:
yield
finally:
_chunked_client.reset(token)
def get_active_client() -> httpx.AsyncClient | None:
"""
Return the chunker's currently-published client, or ``None``.
Used by the paginated-loop helpers (e.g.
:func:`dataretrieval.waterdata.utils._client_for`) to reuse the
per-call connection pool.
Returns
-------
httpx.AsyncClient or None
The client published via :func:`_publish` if currently inside a
:class:`ChunkedCall` run; ``None`` otherwise.
"""
return _chunked_client.get()
# Separators the two axis kinds use to join their atoms back into
# URL text. List axes comma-join values (``site=USGS-A,USGS-B``); the
# filter axis OR-joins clauses (``filter=a='1' OR a='2'``).
_LIST_SEP = ","
_OR_SEP = " OR "
# ``_Fetch`` is the per-sub-request fetcher the decorator wraps and
# ``ChunkedCall`` drives: an ``async def fetch(args) -> (df, response)``.
_Fetch = Callable[[dict[str, Any]], Awaitable[tuple[pd.DataFrame, httpx.Response]]]
# Caller-supplied transform applied to the combined chunk result, so a
# resumed call returns the same shape as an un-interrupted one rather than
# the chunker's raw ``(frame, httpx.Response)``. This keeps the chunker
# generic: the OGC getters inject their post-processing (type coercion,
# column arrangement, ``BaseMetadata``) through ``utils._finalize_ogc``.
# The default is identity, so direct ``ChunkedCall`` use is unaffected.
_Finalize = Callable[[pd.DataFrame, httpx.Response], tuple[pd.DataFrame, Any]]
def _passthrough_result(
frame: pd.DataFrame, response: httpx.Response
) -> tuple[pd.DataFrame, Any]:
"""Default :data:`_Finalize`: return the raw combined pair unchanged."""
return frame, response
class ChunkInterrupted(DataRetrievalError):
"""
Base class for mid-stream chunk failures whose completed work is
preserved and resumable.
A ``ChunkInterrupted`` subclass means: a sub-request failed, but
``ChunkedCall`` still owns whatever completed successfully before
the failure. Call ``self.call.resume()`` to pick up where the
failure stopped you — only still-pending sub-requests are
re-issued.
Subclasses describe *why* ``ChunkedCall`` stopped so callers can
pick a retry policy: :class:`QuotaExhausted` for 429 (wait for the
rate-limit window), :class:`ServiceInterrupted` for 5xx (wait for
the upstream to recover). The ``.call`` handle is the same object
across every interruption of a single chunked call — frames
accumulate across retries.
Attributes
----------
call : ChunkedCall or None
Resumable handle into the ``ChunkedCall`` that raised this
exception. ``None`` only on hand-constructed exceptions (test
fixtures), where ``.call``-derived accessors degrade to
empty/``None``.
retry_after : float or None
Seconds the server suggested waiting (``Retry-After`` header).
``None`` when the server gave no hint.
completed_chunks : int
Number of sub-requests successfully completed before the failure.
total_chunks : int
Total sub-requests in the plan.
partial_frame : pandas.DataFrame
Combined frame of work completed by the moment this exception
was raised. Snapshot at raise time — does NOT advance on a
later ``call.resume()`` (use ``exc.call.partial_frame`` for
the live view).
partial_response : httpx.Response or None
Raw aggregate response covering the completed sub-requests at
raise time; ``None`` if nothing had completed yet. Same snapshot
semantics as ``partial_frame``. (Raw, not finalized — use
``exc.call.resume()`` for the finalized ``(df, metadata)`` result.)
Examples
--------
Retry on any transient interruption, honoring the server's
``Retry-After`` hint when present and falling back to a fixed wait
otherwise. Each new interruption keeps the already-completed work
intact — only the still-pending sub-requests are re-issued.
.. code-block:: python
import time
from dataretrieval.waterdata import get_daily
from dataretrieval.waterdata.chunking import ChunkInterrupted
try:
df, md = get_daily(monitoring_location_id=long_list_of_sites)
except ChunkInterrupted as exc:
while True:
time.sleep(exc.retry_after or 5 * 60)
try:
df, md = exc.call.resume()
break
except ChunkInterrupted as next_exc:
exc = next_exc
"""
# Subclasses override with a ``str.format`` template; the format
# call sees ``completed_chunks`` and ``total_chunks`` as kwargs.
_MESSAGE_TEMPLATE: ClassVar[str] = (
"Chunked request interrupted after {completed_chunks}/"
"{total_chunks} sub-requests; call .call.resume() to continue."
)
def __init__(
self,
*,
completed_chunks: int,
total_chunks: int,
call: ChunkedCall | None = None,
retry_after: float | None = None,
cause: BaseException | None = None,
) -> None:
message = self._MESSAGE_TEMPLATE.format(
completed_chunks=completed_chunks, total_chunks=total_chunks
)
if cause is not None:
cause_msg = str(cause) or type(cause).__name__
message = f"{message} Cause: {type(cause).__name__}: {cause_msg}"
super().__init__(message)
self.completed_chunks = completed_chunks
self.total_chunks = total_chunks
self.call = call
self.retry_after = retry_after
# Snapshot partial state at raise time so the exception's view stays
# stable across later ``call.resume()`` advances (the live view is on
# ``call.partial_frame`` / ``.partial_response``). ``.copy()`` guards
# the single-chunk fast path, where the frame may be returned verbatim.
if call is None:
self.partial_frame: pd.DataFrame = pd.DataFrame()
self.partial_response: httpx.Response | None = None
else:
self.partial_frame = call.partial_frame.copy()
self.partial_response = call.partial_response
def __getstate__(self) -> dict[str, Any]:
# Drop the live ChunkedCall before pickling: its ``.fetch`` is an
# undecorated module function pickle can't reference by name, so the
# interruption can't cross a process boundary with ``.call`` attached.
# The degraded ``call=None`` form keeps the counts, retry hint, and
# partial frame / response; only ``.resume()`` is lost (cross-process
# resume was never possible anyway).
return {**super().__getstate__(), "call": None}
class QuotaExhausted(ChunkInterrupted):
"""
A sub-request returned HTTP 429 — the per-key rate-limit window
is exhausted. Subclass of :class:`ChunkInterrupted`.
The completed sub-requests are preserved on ``.call``; once the
rate-limit window resets, ``.call.resume()`` re-issues only the
still-pending work. ``partial_frame`` holds what completed
before the 429.
"""
_MESSAGE_TEMPLATE = (
"HTTP 429 after {completed_chunks}/{total_chunks} sub-requests; "
"catch QuotaExhausted (or ChunkInterrupted) to access "
".partial_frame or .call.resume() once the rate-limit "
"window has rolled over."
)
class ServiceInterrupted(ChunkInterrupted):
"""
A sub-request returned HTTP 5xx — the upstream service failed
transiently. Subclass of :class:`ChunkInterrupted`.
The completed sub-requests are preserved on ``.call``; once the
upstream recovers, ``.call.resume()`` resumes only the
still-pending work.
"""
_MESSAGE_TEMPLATE = (
"Service error after {completed_chunks}/{total_chunks} "
"sub-requests; catch ServiceInterrupted (or ChunkInterrupted) "
"and call .call.resume() once the upstream service recovers."
)
def _request_bytes(req: httpx.Request) -> int:
"""
Return the total bytes of an httpx request: URL + body.
GET routes have empty ``.content`` and reduce to URL length. POST
routes (CQL2 JSON body) need body bytes — the URL stays short
regardless of payload, so URL-only sizing would underestimate the
request and skip chunking when it's needed.
Parameters
----------
req : httpx.Request
The request to size.
Returns
-------
int
``len(str(req.url)) + len(req.content)``. ``httpx.URL`` doesn't
support ``len()`` directly, so the str-coercion is required.
"""
return len(str(req.url)) + len(req.content)
def _safe_request_bytes(
build_request: Callable[..., httpx.Request],
args: dict[str, Any],
url_limit: int,
) -> int:
"""
Size a candidate sub-request, treating ``httpx.InvalidURL`` as
"still too large".
``httpx.URL`` enforces a hard 64 KB cap per URL component
(``MAX_URL_LENGTH``) and raises ``httpx.InvalidURL`` for anything
bigger. We report ``url_limit + 1`` on overflow so the greedy
halving loop in :meth:`ChunkPlan._plan` keeps shrinking the
largest axis until ``httpx.Request`` can be constructed at all.
Parameters
----------
build_request : Callable[..., httpx.Request]
Factory that turns a kwargs dict into a sized request.
args : dict[str, Any]
Per-sub-request kwargs to pass through to ``build_request``.
url_limit : int
The chunker's byte budget; returned + 1 on overflow.
Returns
-------
int
Real byte count when the request builds, otherwise
``url_limit + 1`` so the planner's "too large" branch keeps
halving.
"""
try:
req = build_request(**args)
except httpx.InvalidURL:
return url_limit + 1
return _request_bytes(req)
def _safe_elapsed(response: httpx.Response) -> timedelta:
"""
Read ``response.elapsed``, falling back to ``timedelta(0)`` when
the attribute hasn't been populated.
httpx only writes ``.elapsed`` when a response is closed through
its normal transport path. ``MockTransport`` (used by
``pytest-httpx``) and hand-constructed ``httpx.Response`` objects
leave the attribute unset, so accessing it raises ``RuntimeError``.
Combining responses across chunks needs a defined duration, so we
treat the missing attribute as zero elapsed.
"""
try:
return response.elapsed
except RuntimeError:
return timedelta(0)
def _set_response_url(response: httpx.Response, url: str | httpx.URL) -> None:
"""
Overwrite the URL surfaced by a response without back-propagating
the change into any aliased original.
Try the direct assignment first: on lightweight test mocks ``.url``
is a plain writable attribute. On real ``httpx.Response`` it's
read-only (it resolves through the bound request), so swap in a
fresh :class:`httpx.Request` carrying the new URL — mutating the
existing one would leak through any shallow copy that shares the
same ``.request``.
"""
try:
response.url = url # type: ignore[misc, assignment]
except AttributeError:
target = httpx.URL(str(url))
try:
old = response.request
except RuntimeError:
# No request bound (some hand-built httpx.Response fixtures);
# synthesize a minimal one to hold the URL.
response.request = httpx.Request("GET", target)
return
response.request = httpx.Request(
method=old.method, url=target, headers=old.headers
)
@dataclass(frozen=True)
class _Axis:
"""
A single chunkable axis of one user-level request — a list of
atomic units and the separator that joins them in the URL.
Both multi-value list parameters (``sites=[...]``, joiner ``","``)
and the cql-text ``filter`` (split on top-level ``OR``, joiner
``" OR "``) fit this shape, so a single greedy halving loop in
``ChunkPlan._plan`` handles both — no need for two separate
algorithms.
Attributes
----------
arg_key : str
The args-dict key this axis substitutes back into when a
sub-request is rendered.
atoms : tuple of str
The smallest indivisible units along this axis (one site, one
OR-clause, …). A "chunk" is a contiguous slice of ``atoms``.
joiner : str
Separator placed between atoms when they are joined back into
URL text — ``","`` for list axes, ``" OR "`` for the filter
axis.
"""
arg_key: str
atoms: tuple[str, ...]
joiner: str
def chunk_bytes(self, chunk: list[str]) -> int:
"""
Return the URL-encoded byte count this chunk contributes when
substituted into the request.
``quote_plus`` is faithful to what the real URL builder
produces, so values containing characters that expand under URL
encoding (``%``, ``+``, ``/``, ``&``, …) can't be mis-ranked.
Parameters
----------
chunk : list of str
A contiguous slice of ``self.atoms``.
Returns
-------
int
Length of ``quote_plus(self.joiner.join(chunk))``.
"""
return len(quote_plus(self.joiner.join(map(str, chunk))))
def render(self, chunk: list[str]) -> Any:
"""
Convert a chunk into the form the URL builder expects.
List axes yield a fresh list of atoms (``build_request`` will
comma-join); the filter axis yields a pre-joined string (CQL
doesn't take a list).
Parameters
----------
chunk : list of str
A contiguous slice of ``self.atoms``.
Returns
-------
list of str or str
``list(chunk)`` for list axes, ``self.joiner.join(chunk)``
for the filter axis.
"""
return list(chunk) if self.joiner == _LIST_SEP else self.joiner.join(chunk)
def _extract_axes(args: dict[str, Any]) -> list[_Axis]:
"""
Build the chunkable-axis set from a request's args.
Multi-value list params with more than one element each become an
axis. The cql-text filter (when chunkable and split into more than
one top-level OR-clause) becomes one too. Anything in
``_NEVER_CHUNK`` is excluded except ``filter`` itself, which is
handled separately so its atoms are clauses not characters.
Parameters
----------
args : dict[str, Any]
The user-level request kwargs (the same dict that would be
passed to ``build_request``).
Returns
-------
list[_Axis]
Zero or more axes in insertion order: list axes first (one
per eligible kwarg, in ``args`` order), then the filter axis
if present.
"""
axes: list[_Axis] = []
for key, value in args.items():
if key in _NEVER_CHUNK:
continue
if isinstance(value, (list, tuple)) and len(value) > 1:
axes.append(_Axis(arg_key=key, atoms=tuple(value), joiner=_LIST_SEP))
filter_expr = args.get("filter")
if filter_expr is not None and _is_chunkable(filter_expr, args.get("filter_lang")):
_check_numeric_filter_pitfall(filter_expr)
clauses = _split_top_level_or(filter_expr)
if len(clauses) >= 2:
axes.append(_Axis(arg_key="filter", atoms=tuple(clauses), joiner=_OR_SEP))
return axes
class ChunkPlan:
"""
Strategy for issuing one user-level request as a sequence of
sub-requests whose URLs each fit ``url_limit``.
Constructing a plan *is* planning:
``ChunkPlan(args, build_request, url_limit)`` extracts the
chunkable axes, runs greedy halving on the biggest chunk across
all axes, and stores the result.
Passthrough requests (no chunkable axes, or already fitting) are
represented as a trivial plan with empty ``axes`` / ``chunks`` and
``total == 1``; :meth:`iter_sub_args` yields the original args
unchanged so the ``ChunkedCall`` loop is the same shape either
way.
Parameters
----------
args : dict[str, Any]
The user-level request kwargs.
build_request : Callable[..., httpx.Request]
Factory that turns a kwargs dict into a sized httpx request,
e.g. ``_construct_api_requests``.
url_limit : int
Byte budget for the request (URL + body).
Attributes
----------
args : dict
The original user-level args this plan was built for. Bound to
the plan so :meth:`iter_sub_args` is self-contained.
axes : list[_Axis]
The chunkable axes of ``args``: each multi-value list
parameter, plus the cql-text filter (if any) split on top-level
OR. Empty in the passthrough case.
chunks : dict[str, list[list[str]]]
Per-axis partition: ``chunks[axis.arg_key]`` is the list of
atom-sublists this axis is split into. Empty in passthrough.
canonical_url : str or None
URL of the user's original (un-chunked) request, used to
overwrite a chunked response's ``.url`` so ``BaseMetadata``
reflects the full query. ``None`` on the passthrough path
and when no buildable URL exists.
Raises
------
Unchunkable
If the request needs chunking but even the singleton plan
doesn't fit ``url_limit``.
"""
def __init__(
self,
args: dict[str, Any],
build_request: Callable[..., httpx.Request],
url_limit: int,
) -> None:
self.args = args
self.axes: list[_Axis] = []
self.chunks: dict[str, list[list[str]]] = {}
self.canonical_url: str | None = None
axes = _extract_axes(args)
if not axes:
# No chunkable axis: nothing to split. If the single request fits,
# run it verbatim (the common passthrough). ``_safe_request_bytes``
# treats an un-constructable URL (httpx.InvalidURL, > 64 KB) as over
# budget.
if _safe_request_bytes(build_request, args, url_limit) <= url_limit:
return
# Over budget. A filter the chunker doesn't manage — cql-json — is
# passed through unchanged (chunking applies only to cql-text); the
# server, not us, judges it. Otherwise this is an in-domain shape we
# would normally chunk but can't (a single large CQL ``IN`` clause
# with no top-level ``OR``, or one oversized value), so raise an
# actionable error instead of shipping it for an opaque HTTP 414.
filter_expr = args.get("filter")
if filter_expr is not None and not _is_chunkable(
filter_expr, args.get("filter_lang")
):
return
raise Unchunkable(
f"Request exceeds {url_limit} bytes (URL + body) and has no "
f"chunkable multi-value argument to split (e.g. a single large "
f"CQL `IN` clause, or one oversized value). Narrow the query, "
f"simplify the filter, or split the call manually."
)
# Constructing the initial request can itself trip
# ``httpx.InvalidURL`` (URL > 64 KB) — that's the canonical
# "needs chunking" signal, so swallow it and proceed to plan.
# When the unchunked URL does build, preserve it as
# ``canonical_url`` so ``BaseMetadata.url`` echoes the user's
# original query verbatim; only fall back to a worst-case
# sub-request URL when the URL itself can't be constructed.
try:
initial_request = build_request(**args)
except httpx.InvalidURL:
initial_request = None
if initial_request is not None:
self.canonical_url = str(initial_request.url)
if _request_bytes(initial_request) <= url_limit:
return
self.axes = axes
self.chunks = {axis.arg_key: [list(axis.atoms)] for axis in axes}
self._plan(build_request, url_limit)
if self.canonical_url is None:
# Original URL was un-constructable (httpx.InvalidURL); fall
# back to the worst-case sub-request URL so
# ``BaseMetadata.url`` still surfaces something
# informative. If even that overflows, leave canonical_url
# as None (set above) and let the response's own URL stand.
with suppress(httpx.InvalidURL):
self.canonical_url = str(build_request(**self._worst_case_args()).url)
def _plan(
self,
build_request: Callable[..., httpx.Request],
url_limit: int,
) -> None:
"""
Greedy-halve the biggest chunk across all axes until the
worst-case sub-request URL fits ``url_limit``. Mutates
``self.chunks`` in place; treats list axes and the filter axis
uniformly — each is just a list of atoms joined by its axis's
separator.
Raises
------
Unchunkable
If even the singleton plan (every axis at one atom per
chunk) still exceeds ``url_limit``.
"""
while True:
worst = self._worst_case_args()
if _safe_request_bytes(build_request, worst, url_limit) <= url_limit:
return
biggest_axis: _Axis | None = None
biggest_idx = -1
biggest_size = -1
for axis in self.axes:
for idx, chunk in enumerate(self.chunks[axis.arg_key]):
if len(chunk) <= 1:
continue
size = axis.chunk_bytes(chunk)
if size > biggest_size:
biggest_axis, biggest_idx, biggest_size = axis, idx, size
if biggest_axis is None:
raise Unchunkable(
f"Request exceeds {url_limit} bytes (URL + body) at the "
f"smallest reducible plan (every axis at one atom per "
f"sub-request). Reduce input sizes, shorten or simplify "
f"the filter, or split the call manually."
)
axis_chunks = self.chunks[biggest_axis.arg_key]
chunk = axis_chunks[biggest_idx]
mid = len(chunk) // 2
axis_chunks[biggest_idx : biggest_idx + 1] = [chunk[:mid], chunk[mid:]]
def _worst_case_args(self) -> dict[str, Any]:
"""
Args dict representing the largest sub-request the current
``self.chunks`` partition will issue — each axis's longest
(by URL-encoded bytes) chunk rendered back in.
"""
out = dict(self.args)
for axis in self.axes:
worst = max(self.chunks[axis.arg_key], key=axis.chunk_bytes)
out[axis.arg_key] = axis.render(worst)
return out
@property
def total(self) -> int:
"""
Total sub-request count: product of per-axis chunk counts.
Returns
-------
int
``1`` for the passthrough plan, otherwise the cartesian
product of ``len(chunks[ax.arg_key])`` across all axes.
"""
return math.prod((len(self.chunks[ax.arg_key]) for ax in self.axes), start=1)
def iter_sub_args(self) -> Iterator[dict[str, Any]]:
"""
Yield substituted args for each sub-request, in deterministic
order — cartesian product over axes in extraction order.
The same plan yields the same sub-args sequence on every
invocation, so resume is well-defined.
Yields
------
dict[str, Any]
A copy of ``self.args`` with each axis's current chunk
substituted under its ``arg_key``.
"""
if not self.axes:
yield dict(self.args)
return
chunk_lists = [self.chunks[ax.arg_key] for ax in self.axes]
for combo in itertools.product(*chunk_lists):
sub_args = dict(self.args)
for axis, chunk in zip(self.axes, combo):
sub_args[axis.arg_key] = axis.render(chunk)
yield sub_args
def execute(
self,
fetch: _Fetch,
retry_policy: RetryPolicy = _NO_RETRY,
finalize: _Finalize = _passthrough_result,
) -> tuple[pd.DataFrame, Any]:
"""
Run the plan and return the combined, finalized result.
Thin wrapper around ``ChunkedCall(self, fetch).resume()``;
see :class:`ChunkedCall` for the per-sub-request semantics.
Parameters
----------
fetch : Callable
``async def`` that issues a single sub-request, given the
substituted args dict, and returns ``(frame, response)``.
retry_policy : RetryPolicy, optional
Per-sub-request retry-with-backoff policy. Defaults to
:data:`_NO_RETRY`; the decorator passes ``RetryPolicy.from_env()``.
finalize : Callable, optional
Transform applied to the combined ``(frame, response)`` (see
:data:`_Finalize`). Defaults to :func:`_passthrough_result`.