pippen.data.nba_api¶
The one module allowed to talk to stats.nba.com. Every call is paced at one request per second and retried with a bounded backoff.
The pacing is a compliance rule rather than a performance choice. Exceeding it risks the endpoint being blocked for every user of this package.
pippen.data.nba_api ¶
Rate-limited, retrying client for the NBA stats endpoints at stats.nba.com.
stats.nba.com is undocumented and reverse-engineered; it rate limits aggressively and, when pushed too hard, degrades rather than failing cleanly. This module is the one place in the pipeline allowed to talk to it, and it exists to make two guarantees to every caller: no more than one request per second leaves this process, and a transient failure is retried a bounded number of times instead of either giving up on the first hiccup or hammering the endpoint forever.
Verified against nba_api 1.11.4 (the version pinned by this project's
sources extra), two things worth recording because they are easy to get
wrong from the package's public documentation alone:
Status codes are invisible by default
nba_api's HTTP layer (nba_api.stats.library.http.NBAStatsHTTP.
send_api_request) never calls response.raise_for_status(), and the
status code it does capture has no public getter anywhere in the package.
A 429 does not raise; it is stored, then the endpoint tries to parse the
rate-limit page as the expected JSON result set and fails confusingly
several calls later. :func:_prepare_stats_session fixes this by
attaching a requests response hook to the session nba_api already
shares across every endpoint call, so a non-2xx response raises
requests.HTTPError at the moment it arrives, exactly where this
module's retry logic expects it.
Passing headers= replaces nba_api's defaults, it does not merge with them
Every endpoint constructor does if headers is not None: self.headers =
headers, a plain assignment. nba_api's own default headers
(STATS_HEADERS) carry more than a User-Agent: Host, Accept, Referer
and others that stats.nba.com also checks. Sending a bare
{"User-Agent": ...} mapping would silently drop the rest and likely
reproduce the exact stall a custom User-Agent is meant to avoid.
:func:_default_headers starts from STATS_HEADERS and overrides only
the one key this project needs to control.
Compliance
NBA API calls stay at one request per second (see CLAUDE.md). This is
not a suggestion: pushing past it risks the endpoint being blocked for
every user of this package, not only this one process.
MissingDependencyError ¶
Bases: ImportError
nba_api is required for this call but is not installed.
Raised instead of letting a bare ModuleNotFoundError surface, so the
message names the extra to install rather than only the missing name.
Source code in src/pippen/data/nba_api.py
RateLimitedError ¶
Bases: Exception
stats.nba.com answered with HTTP 429: too many requests.
Worth a bounded retry with backoff. Repeated 429s after the retry budget is exhausted mean the configured rate limit is still too aggressive for the current traffic, not that this one call was unlucky.
Source code in src/pippen/data/nba_api.py
TransientAPIError ¶
Bases: Exception
A network failure worth retrying: timeout, connection reset, or 5xx.
Anything else, a 4xx other than 429, a malformed URL, is treated as permanent for the attempt and is not retried: retrying an unchanged request will not fix a request that was wrong to begin with.
Source code in src/pippen/data/nba_api.py
RateLimiter ¶
Enforces a minimum wall-clock interval between the calls it guards.
Thread safety
A single threading.Lock serialises the whole read-decide-sleep
sequence in :meth:wait, and the lock is held for the sleep itself,
not released around it. Without that, two threads racing to enter
:meth:wait could each read the same "last call was N seconds ago"
timestamp, each independently decide no wait is needed, and both
proceed immediately, exactly the double-request this class exists to
prevent (a classic check-then-act race). Holding the lock across the
full sequence, including the sleep, makes it atomic: only one thread
at a time can be inside :meth:wait, so calls through this limiter
are paced at the configured interval regardless of how many threads
share it. The cost is that a blocked thread waits on the lock for the
remainder of another thread's sleep rather than doing useful work,
which is the correct trade for a limiter whose entire job is to make
callers wait.
Source code in src/pippen/data/nba_api.py
__init__ ¶
Create a rate limiter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
min_interval_seconds
|
float
|
Minimum time that must elapse between the start of one call and the start of the next. |
required |
clock
|
Callable[[], float]
|
Source of the current time. Defaults to
:func: |
monotonic
|
sleep
|
Callable[[float], None]
|
Function used to wait out the remaining interval. Defaults
to :func: |
sleep
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/pippen/data/nba_api.py
wait ¶
Block, if needed, until the minimum interval has elapsed since the last call.
The first call never waits. Every call after that blocks for whatever
is left of min_interval_seconds since the previous call returned.
Source code in src/pippen/data/nba_api.py
default_limiter
cached
¶
Return the process-wide rate limiter shared by calls that don't pass their own.
Built once, on first use, and cached: a rate limiter only works if its
state (the last call time) persists across calls, so unlike this module's
other lazily-rebuilt policies it cannot be reconstructed fresh each time.
The environment is read once, at that first use; changing
NBA_API_RATE_LIMIT_SECONDS afterwards has no effect on this process.
A caller that needs a different interval mid-process should build and
pass its own :class:RateLimiter rather than rely on this one.
Returns:
| Name | Type | Description |
|---|---|---|
A |
RateLimiter
|
class: |
RateLimiter
|
defaulting to :data: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If :data: |
Source code in src/pippen/data/nba_api.py
call_endpoint ¶
call_endpoint(endpoint_cls, *, limiter=None, headers=None, timeout=_DEFAULT_TIMEOUT_SECONDS, result_set=0, **params)
Call one nba_api stats endpoint, throttled and retried, as a DataFrame.
This is the one path every wrapper in this module (and, later, every
endpoint this pipeline adds) should call through. It does not know
anything about a specific endpoint's parameters; it only guarantees the
call is paced by limiter and retried with bounded backoff on a 429 or
a transient connection failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
endpoint_cls
|
Callable[..., _StatsEndpoint]
|
An |
required |
limiter
|
RateLimiter | None
|
Rate limiter to pace this call through. Defaults to
:func: |
None
|
headers
|
Mapping[str, str] | None
|
HTTP headers to send. Defaults to :func: |
None
|
timeout
|
float
|
Per-request timeout in seconds, passed through to the endpoint constructor. |
_DEFAULT_TIMEOUT_SECONDS
|
result_set
|
int
|
Index into the list |
0
|
**params
|
Any
|
Endpoint-specific request parameters, forwarded to
|
{}
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
The requested result set as a pandas DataFrame. |
Raises:
| Type | Description |
|---|---|
MissingDependencyError
|
If |
RateLimitedError
|
If every retry attempt still comes back HTTP 429. |
TransientAPIError
|
If every retry attempt hits a connection failure, timeout, or 5xx response. |
Source code in src/pippen/data/nba_api.py
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 | |
fetch_all_players ¶
Fetch the league's full player master list: one row per player, all history.
This is the smallest useful call this module can make, one request, no
per-season loop, and a good proof that throttling and retry work end to
end. Every other endpoint this project adds later (play-by-play, box
scores, shot logs) is keyed by the PERSON_ID this table returns, so it
is also the first table the rest of the pipeline needs: a name-and-ID
lookup that every downstream join can rely on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
season
|
str
|
Season string in the format |
required |
limiter
|
RateLimiter | None
|
Rate limiter to throttle this call through. Defaults to
:func: |
None
|
timeout
|
float
|
Per-request timeout in seconds. |
_DEFAULT_TIMEOUT_SECONDS
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per player, with at least |
DataFrame
|
|
DataFrame
|
returned by the |
Raises:
| Type | Description |
|---|---|
MissingDependencyError
|
If |
RateLimitedError
|
If every retry attempt still comes back HTTP 429. |
TransientAPIError
|
If every retry attempt hits a connection failure, timeout, or 5xx response. |