Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,17 @@ promised.

`AsyncMlb` constructs internal adapters for both `v1` and `v1.1` that share
one HTTPX client, mirroring `Mlb`'s shared-Session pattern. Most endpoint
methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. The `v1`
adapter resolves and owns the shared client (library-created when the caller
passes none to `AsyncMlb`, otherwise the caller's own); the `v1.1` adapter
borrows that same client and never closes it directly. Retry eligibility
follows the shared client's ownership on both adapters, not which adapter
version issues a given request, matching `Mlb`'s single retry policy mounted
on the shared `Session`.
methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. `AsyncMlb`
owns the shared client, exactly as `Mlb` owns the shared `Session`: it creates
one when the caller passes none, closes only a client it created, and hands
the same client to both adapters.

Retries are a property of that client, not of either adapter. A
library-created client is built with the library retry transport mounted on
it, the way a library-created `Session` is built with the library retry
adapters mounted on it, so both API versions retry identically without either
adapter holding retry state. A caller-injected client keeps whatever transport
its caller mounted.

### Endpoint methods

Expand Down
165 changes: 165 additions & 0 deletions mlbstatsapi/_async_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Retry-aware HTTPX transport for the async client.

The synchronous side does not implement retries. It *configures* them: ``Mlb``
mounts an ``HTTPAdapter`` carrying the library ``Retry`` policy onto the
Session it creates, and from that point on every ``session.get()`` retries
without any caller — ``MlbDataAdapter`` included — knowing retries exist.

HTTPX has the same seam. ``AsyncClient(transport=...)`` accepts any
``AsyncBaseTransport``, which is the position ``HTTPAdapter`` occupies in
Requests. Putting the retry loop there instead of inside
``AsyncMlbDataAdapter`` gives the async side the sync structure:

* Adapters call ``client.get()`` and are unaware of retries.
* The retry policy travels with the client, so two adapters sharing one client
share one policy by construction. Neither adapter holds retry state, so
neither can disagree with the other about it.
* A caller-injected client keeps whatever transport its caller mounted, so
"the library does not touch an injected client" needs no flag to enforce.

A caller who wants library retry behavior on a client they own mounts this
transport themselves, mirroring the documented sync recipe for
``create_retry_policy()``.
"""

import asyncio

from ._async_support import import_httpx
from .mlb_dataadapter import _build_user_agent, create_retry_policy

httpx = import_httpx()


class MlbAsyncRetryTransport(httpx.AsyncBaseTransport):
"""Wrap an HTTPX transport with the library's bounded retry policy.

Failures spend the same retry budget the sync policy spends:

ReadTimeout -> read budget
ConnectTimeout -> connect budget
ConnectError -> connect budget
other TimeoutException -> total budget
other RequestError -> total budget
retryable HTTP status -> status budget

Exhausting a budget re-raises the underlying HTTPX exception. Translating
those into the library's public exception types stays with the adapter, so
this class satisfies the transport contract HTTPX documents: transports
raise HTTPX errors.
"""

def __init__(
self,
inner: httpx.AsyncBaseTransport | None = None,
*,
retry_policy=None,
):
self._inner = inner if inner is not None else httpx.AsyncHTTPTransport()
self._retry_policy = (
retry_policy if retry_policy is not None else create_retry_policy()
)

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
policy = self._retry_policy

attempt = 0
while True:
attempt += 1
try:
response = await self._inner.handle_async_request(request)

except httpx.ReadTimeout:
if attempt > policy.read:
raise
await self._backoff(attempt=attempt, response=None)
continue

except httpx.ConnectTimeout:
# Caught before httpx.TimeoutException: a connect timeout is a
# timeout for the caller, but it spends the connect budget so
# the retry accounting matches the sync policy.
if attempt > policy.connect:
raise
await self._backoff(attempt=attempt, response=None)
continue

except httpx.ConnectError:
if attempt > policy.connect:
raise
await self._backoff(attempt=attempt, response=None)
continue

except httpx.TimeoutException:
if attempt > policy.total:
raise
await self._backoff(attempt=attempt, response=None)
continue

except httpx.RequestError:
if attempt > policy.total:
raise
await self._backoff(attempt=attempt, response=None)
continue

if (
response.status_code not in policy.status_forcelist
or attempt > policy.status
):
return response

# The response is discarded, so release it before another attempt
# rather than leaving a connection checked out of the pool.
delay = self._delay_for(attempt=attempt, response=response)
await response.aclose()
if delay > 0:
await asyncio.sleep(delay)

async def _backoff(
self,
*,
attempt: int,
response: httpx.Response | None,
) -> None:
delay = self._delay_for(attempt=attempt, response=response)
if delay > 0:
await asyncio.sleep(delay)

def _delay_for(
self,
*,
attempt: int,
response: httpx.Response | None,
) -> float:
policy = self._retry_policy

if policy.respect_retry_after_header and response is not None:
retry_after = policy.get_retry_after(response)
if retry_after:
return retry_after

# Mirrors urllib3's Retry.get_backoff_time(): no delay before the
# first retry, exponential thereafter, capped at backoff_max.
if attempt <= 1:
return 0.0

return min(
policy.backoff_factor * (2 ** (attempt - 1)),
policy.backoff_max,
)

async def aclose(self) -> None:
await self._inner.aclose()


def create_library_async_client() -> httpx.AsyncClient:
"""Build the async client the library creates and owns.

The counterpart of ``_configure_library_session()`` on the sync side:
library defaults are applied here, at creation, and only to clients the
library creates. Passing headers to the constructor replaces just the
User-Agent, so HTTPX's other default headers survive.
"""
return httpx.AsyncClient(
headers={"User-Agent": _build_user_agent()},
transport=MlbAsyncRetryTransport(),
)
36 changes: 21 additions & 15 deletions mlbstatsapi/async_mlb.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from typing import TYPE_CHECKING

from ._async_transport import create_library_async_client
from ._helpers.id_lookup import find_ids_by_key
from ._helpers.schedule import build_schedule_params
from ._parsers.attendance import parse_attendance
Expand Down Expand Up @@ -63,37 +64,42 @@ def __init__(
):
self._logger = logger or logging.getLogger(__name__)

# One client is shared by the v1 and v1.1 adapters, mirroring Mlb's
# shared-Session pattern. The v1 adapter resolves and owns the client
# (library-created when the caller passes none); the v1.1 adapter
# borrows that same client and never closes it itself, but still
# retries exactly when the shared client is library-owned.
# One client is shared by the v1 and v1.1 adapters, and this client
# owns it, mirroring Mlb's shared-Session pattern. The library closes
# only clients it creates; caller-injected clients remain caller-owned.
# The versioned User-Agent and the retry transport are applied only to
# library-created clients.
self._owns_client = client is None
if client is None:
self._client = create_library_async_client()
else:
self._client = client
self._closed = False
self._mlb_adapter_v1 = AsyncMlbDataAdapter(
hostname=hostname,
ver="v1",
logger=self._logger,
timeout=timeout,
client=client,
client=self._client,
strict_http=strict_http,
)
self._mlb_adapter_v1_1 = AsyncMlbDataAdapter(
hostname=hostname,
ver="v1.1",
logger=self._logger,
timeout=timeout,
client=self._mlb_adapter_v1._client,
client=self._client,
strict_http=strict_http,
)
# AsyncMlb, not either adapter, actually owns this shared transport,
# so it is the one that knows whether the client is library-owned.
# The v1.1 adapter received a non-None client above, so it would
# otherwise conclude it's using a caller-injected client and disable
# retries even when the client is really library-owned via v1.
self._mlb_adapter_v1_1._set_retries_enabled(self._mlb_adapter_v1._owns_client)

async def aclose(self) -> None:
"""Close library-owned async resources."""
await self._mlb_adapter_v1.aclose()
"""Close the HTTP client when this client owns it.

Safe to call more than once. Caller-injected clients are left alone.
"""
if self._owns_client and not self._closed:
await self._client.aclose()
self._closed = True

async def __aenter__(self) -> "AsyncMlb":
return self
Expand Down
Loading
Loading