@@ -1517,67 +1517,60 @@ async def fetch_async(args):
15171517 return fetch_async
15181518
15191519
1520- def test_fan_out_in_flight_sub_requests_never_exceed_cap (monkeypatch ):
1521- """At most ``API_USGS_CONCURRENT`` sub-requests are in flight at once,
1522- while still running genuinely in parallel up to that cap.
1520+ @pytest .mark .parametrize (
1521+ ("cap" , "expected_high_water" ),
1522+ [
1523+ pytest .param (2 , 2 , id = "capped" ),
1524+ pytest .param ("unbounded" , len (_EIGHT_SINGLETON_SITES ), id = "unbounded" ),
1525+ ],
1526+ )
1527+ def test_fan_out_in_flight_high_water_mark_is_the_cap (
1528+ monkeypatch , cap , expected_high_water
1529+ ):
1530+ """The fetch-level high-water mark of simultaneous sub-requests IS the
1531+ ``API_USGS_CONCURRENT`` cap — genuine parallelism up to it, never past
1532+ it — and ``unbounded`` degenerates to every sub-request at once.
15231533
15241534 Regression: the cap used to be enforced only by the shared client's
1525- connection-pool size, so every sub-request beyond it queued on
1526- connection *acquisition* — subject to the client's pool-acquire
1527- timeout and httpcore's thundering-herd reassignment (see
1528- ``ChunkedCall._run``). The semaphore parks excess sub-requests
1529- before they touch the pool, which this test observes directly: the
1530- fetch-level high-water mark IS the cap, not the plan's total.
1535+ connection-pool size, so sub-requests beyond it queued on connection
1536+ *acquisition*, subject to the client's pool-acquire timeout and
1537+ httpcore's thundering-herd reassignment (see ``ChunkedCall._run``).
1538+ The semaphore parks excess sub-requests before they touch the pool.
15311539 """
15321540 in_flight = {"now" : 0 , "max" : 0 }
15331541 fetch = _async_chunked_fetch (
1534- monkeypatch , _concurrency_probe (in_flight ), max_concurrent = 2
1542+ monkeypatch , _concurrency_probe (in_flight ), max_concurrent = cap
15351543 )
15361544
15371545 df , _ = fetch ({"sites" : list (_EIGHT_SINGLETON_SITES )})
15381546
15391547 assert len (df ) == len (_EIGHT_SINGLETON_SITES ) # all sub-requests completed
1540- assert in_flight ["max" ] == 2 # parallel, but never beyond the cap
1541-
1542-
1543- def test_fan_out_unbounded_dispatches_every_sub_request_at_once (monkeypatch ):
1544- """``API_USGS_CONCURRENT=unbounded`` disables the gate: every pending
1545- sub-request is in flight simultaneously."""
1546- in_flight = {"now" : 0 , "max" : 0 }
1547- fetch = _async_chunked_fetch (
1548- monkeypatch , _concurrency_probe (in_flight ), max_concurrent = "unbounded"
1549- )
1550-
1551- df , _ = fetch ({"sites" : list (_EIGHT_SINGLETON_SITES )})
1552-
1553- assert len (df ) == len (_EIGHT_SINGLETON_SITES )
1554- assert in_flight ["max" ] == len (_EIGHT_SINGLETON_SITES )
1548+ assert in_flight ["max" ] == expected_high_water
15551549
15561550
15571551def test_fan_out_outlives_pool_timeout_on_real_transport (monkeypatch ):
15581552 """End-to-end regression for the pool-timeout starvation bug: the
15591553 fan-out must survive every pooled connection staying busy past the
1560- client's pool-acquire timeout.
1554+ client's pool-acquire timeout (the stall mechanism is documented on
1555+ ``ChunkedCall._run``; at production scale think a batch of large,
1556+ slowly-streaming pages).
15611557
15621558 Sub-requests here send real HTTP to a slow localhost server through
1563- the chunker's shared client (fakes can't catch this —
1564- ``MockTransport`` bypasses the connection pool). A queued waiter's
1565- pool-timeout clock only resets when some response completes, so the
1566- repro needs a *stall*: with the pool as the only throttle, 2
1567- connections busy for 0.7 s each and a 0.3 s pool timeout pinned
1568- below, the 4 queued sub-requests sat through 0.3 s with no
1569- completion → ``httpx.PoolTimeout`` → (retries exhausted,
1570- ``API_USGS_RETRIES=0``) a spurious resumable ``ServiceInterrupted``.
1571- Gated by the semaphore, queued sub-requests never touch the pool
1572- and the call completes. (Production scale: 60 s timeout, tripped by
1573- a batch of large, slowly-streaming pages.)
1559+ the chunker's shared client — fakes can't catch this, since
1560+ ``MockTransport`` bypasses the connection pool. With the pool as the
1561+ only throttle, 2 connections busy for 0.5 s each and the 0.25 s pool
1562+ timeout pinned below, the 2 queued sub-requests sat out the full
1563+ timeout with no completion to reset their clocks →
1564+ ``httpx.PoolTimeout`` → (retries exhausted, ``API_USGS_RETRIES=0``)
1565+ a spurious resumable ``ServiceInterrupted``. Gated by the semaphore,
1566+ queued sub-requests never touch the pool and the call completes.
15741567 """
15751568
15761569 class _SlowHandler (http .server .BaseHTTPRequestHandler ):
15771570 protocol_version = "HTTP/1.1" # keepalive, so pooled connections reuse
15781571
15791572 def do_GET (self ):
1580- time .sleep (0.7 ) # hold the connection busy past the pool timeout
1573+ time .sleep (0.5 ) # hold the connection busy past the pool timeout
15811574 body = b'{"ok": true}'
15821575 self .send_response (200 )
15831576 self .send_header ("Content-Length" , str (len (body )))
@@ -1592,10 +1585,10 @@ def log_message(self, *args): # keep pytest output clean
15921585 thread .start ()
15931586 url = f"http://127.0.0.1:{ server .server_address [1 ]} /"
15941587
1595- # Scale the production 60 s pool timeout down to 0.3 s so the
1596- # pre-semaphore failure mode reproduces in test time.
1588+ # Scale the production pool timeout (see ``HTTPX_DEFAULTS``) down to
1589+ # 0.25 s so the pre-semaphore failure mode reproduces in test time.
15971590 monkeypatch .setitem (
1598- HTTPX_DEFAULTS , "timeout" , httpx .Timeout (5.0 , connect = 1.0 , pool = 0.3 )
1591+ HTTPX_DEFAULTS , "timeout" , httpx .Timeout (5.0 , connect = 1.0 , pool = 0.25 )
15991592 )
16001593
16011594 async def fetch_async (args ):
@@ -1605,7 +1598,7 @@ async def fetch_async(args):
16051598 assert resp .status_code == 200
16061599 return pd .DataFrame ({"id" : [_atom_id (args )]}), resp
16071600
1608- sites = _EIGHT_SINGLETON_SITES [:6 ] # 2 in flight + 4 queued, 3 waves
1601+ sites = _EIGHT_SINGLETON_SITES [:4 ] # 2 in flight + 2 queued, 2 waves
16091602 try :
16101603 fetch = _async_chunked_fetch (monkeypatch , fetch_async , max_concurrent = 2 )
16111604 df , _ = fetch ({"sites" : sites })
0 commit comments