Transport, auth & errors
Every wrapper — ESPN and flat-API alike — fetches through one runtime core:
- the auth provider for the wrapper's family decorates the request (bearer token, API-key header or query param, login cookies);
- the family's transport sends it;
- a
401triggers one credential refresh and a retry; network errors and the family's retry statuses (by default403,408,429,500,502,503,504) are retried with exponential backoff + jitter (honouringRetry-After, default 3 retries, at most 4 of them on statuses); auth-gated families such asnfl_apinever retry a403; - the outcome is classified into a small error vocabulary.
A family is the stem a wrapper belongs to: the ESPN URL families
site_v2, site_v2_alt, web_v3, core_v2, or a flat-API stem such as
mlb, mlb_statcast, nhl_api_web, nfl_api, odds_api, sports247,
sports247_site_pages, cbs, fox, yahoo, hockeytech, torvik, and the
subscription families pff_api, kenpom, nfl_pro (the keys of FLAT_HOSTS).
The hand-written legacy sdv.<league>.get* methods use the ESPN families too,
plus sports247_html, ncaa_com and stats_ncaa for their 247Sports and NCAA
pages.
Errors
import sdv, { NoDataError, AssetFetchError, SdvError } from 'sportsdataverse';
try {
const game = await sdv.nba.espnNbaSummary({ event_id: '401585601' });
} catch (err) {
if (err instanceof NoDataError) {
// The fetch worked and there is nothing there: HTTP 404, or ESPN's
// 200 response with a { code: 404 } body.
} else if (err instanceof AssetFetchError) {
// The fetch FAILED (403, 429, 5xx, network, retries exhausted).
// The answer is unknown. Do not record it as "no data".
console.error(err.status, err.url, err.cause);
}
}
NoDataError and AssetFetchError are siblings — neither is an instance of the
other — and both extend SdvError. NoESPNDataError is an alias of
NoDataError. Error messages and err.url never include the query string, so
keys passed as query parameters are not leaked into logs.
InvalidParameterError (also an SdvError) means the server rejected the
arguments themselves (a PFF 400 / 422, NFL Pro's empty 200): the call can
never succeed as made, so it is neither "no data" nor a failed fetch.
No credentials in errors. An error's cause is always a sanitized copy
of the underlying error: its name, message and stack — with URL query strings,
user:password@, and credential-looking text redacted (Authorization /
Cookie values, Bearer <token>, JWT-shaped strings, password= / token= /
api_key= values), including in an error your own transport throws — and
code / errno / syscall; never
the HTTP client's request config, so an Authorization header, a cookie or a
POSTed login form cannot surface in util.inspect(err) or a logged error.
The built-in transports reject with the same sanitized errors.
Retries, timeout, User-Agent
import { configure } from 'sportsdataverse';
configure({ retries: 5, timeoutMs: 60_000, userAgent: 'my-app/1.0' });
retries is the whole attempt budget. Network errors may use all of it; retry
statuses may use at most 4 retries (min(retries, 4)), so a persistent 403
or 5xx can't spin the full budget. A status that persists past the cap raises
AssetFetchError.
Which statuses are retried is per family. The default set,
DEFAULT_RETRY_STATUSES, matches sdv-py and includes 403 because ESPN's
Core v2 API answers 403 under load. A family whose 403 is a real
"forbidden" (an auth-gated API) narrows the set with registerFamilyDefaults:
import { registerFamilyDefaults, DEFAULT_RETRY_STATUSES } from 'sportsdataverse';
registerFamilyDefaults('my_family', {
retryStatuses: DEFAULT_RETRY_STATUSES.filter((s) => s !== 403),
});
nfl_api ships registered this way.
A family can register its own retries and timeoutMs too (pff_api uses
sdv-py's 4 retries, nfl_pro its 45 s timeout). A retries / timeoutMs you
pass to configure always wins over a family's own; the built-in 3 retries /
30 s apply only where neither is set. Login and token-mint requests (KenPom,
247Sports, nfl_api) use the same resolved timeout.
A family can also map a final failed response — non-2xx, not 404, no retry
left — to its own error with classifyError. Return an SdvError to throw it
instead of the default AssetFetchError, or undefined to keep the default;
404 is always NoDataError and never reaches the hook. url carries no
query string. The PFF family uses it to turn 400 / 422 into
InvalidParameterError with PFF's own error message:
import { registerFamilyDefaults, InvalidParameterError, AssetFetchError } from 'sportsdataverse';
registerFamilyDefaults('my_family', {
classifyError: (res, url) =>
res.status === 400 || res.status === 422
? new InvalidParameterError(`my_family: rejected ${url}`, { url, status: res.status })
: undefined,
});
Using a proxy
A transport is just an async function from a request to a response. It must resolve for every HTTP status (classification happens in the core) and reject only when no response arrived. To route everything through an HTTP proxy with axios:
import axios from 'axios';
import { configure } from 'sportsdataverse';
const viaProxy = async (req) => {
const res = await axios.request({
method: req.method,
url: req.url,
params: req.query,
headers: req.headers,
data: req.body,
timeout: req.timeoutMs,
responseType: req.responseType ?? 'json',
validateStatus: () => true, // never throw on a status
proxy: { protocol: 'http', host: '127.0.0.1', port: 8080 },
});
const headers = Object.fromEntries(
Object.entries(res.headers).map(([k, v]) => [k.toLowerCase(), String(v)])
);
return { status: res.status, headers, data: res.data, url: req.url };
};
configure({ transport: viaProxy }); // every family without its own registered transport
configure({ transport: { core_v2: viaProxy } }); // just ESPN Core v2
configure({ transport: { default: viaProxy, mlb: other } }); // per family + fallback
Transport precedence for a family:
- your
configureentry for that family; - the transport the family's runtime registers for itself
(
registerFamilyDefaults), when the host requires one; - your
default(or bare) transport; - the built-in axios transport.
So a generic proxy set as default never silently replaces a transport a host
requires, such as the browser-impersonating transport below. To proxy such a
family, configure that family explicitly. The impersonating transport takes a
proxyUrl. resetConfig() drops everything you configured.
Query values. The built-in transports send an array as repeated keys
(k=a&k=b) and a Date as ISO-8601 UTC (2025-02-01T00:00:00.000Z); an
invalid Date throws an SdvError before anything is sent. Where an API wants
a bare date (YYYY-MM-DD, ESPN's dates=YYYYMMDD, stats.nba.com's
MM/DD/YYYY), pass that string rather than a Date.
Browser-impersonating transport
Some hosts (stats.nba.com, stats.wnba.com) fingerprint the TLS handshake and
silently stall non-browser clients. createImpersonatingTransport sends
requests with a real browser's TLS / HTTP-2 fingerprint using the optional
impit package, which ships prebuilt native
binaries for Linux, macOS and Windows (x64 + arm64). Install it alongside
sportsdataverse:
npm install impit
import { configure, createImpersonatingTransport } from 'sportsdataverse';
configure({
transport: {
nba_stats: createImpersonatingTransport({ browser: 'chrome' }),
// with a proxy (HTTP, HTTPS or SOCKS):
wnba_stats: createImpersonatingTransport({ proxyUrl: 'socks5://127.0.0.1:1080' }),
},
});
Without impit installed, a request through this transport rejects with
TransportUnavailableError (not retried) naming the install command.
Keyless families that need headers
on3, asa, mls_api, nwsl_api and bart_wbb need no key or login, so there is nothing to configure. Their getters set a browser User-Agent for you (and the site Referer for MLS and NWSL); a headers argument on the call overrides either. A 404 is a NoDataError, any other failure an AssetFetchError, never an empty table.
Keys and logins per family
Auth providers are configured per family. There is deliberately no
default auth: credentials are only ever sent to the family they belong to.
Values you pass on the call itself (for example a headers argument with your
own Authorization) win over the configured provider.
import {
configure, bearerAuth, headerAuth, queryAuth, tokenAuth, sessionAuth,
} from 'sportsdataverse';
// Only configure a family when its key is actually set.
const auth = {};
if (process.env.ODDS_API_KEY) {
// A key sent as a query parameter (The Odds API's apiKey).
auth.odds_api = queryAuth({ apiKey: process.env.ODDS_API_KEY });
}
if (process.env.MY_TOKEN) {
// A bearer token. A getter is read per request.
auth.my_bearer_family = bearerAuth(() => process.env.MY_TOKEN);
}
if (process.env.MY_KEY) {
// A key sent as a header.
auth.my_family = headerAuth({ 'X-Api-Key': process.env.MY_KEY });
}
configure({ auth });
Empty credentials are never sent. If a bearerAuth token (or getter) yields an
empty value, or tokenAuth's mint returns an empty token, the call throws
an SdvError naming the family before any request goes out. headerAuth and
queryAuth drop undefined or empty values.
Failures are not retried. mint, login and token getters throw on
failure. request() calls them once per need and never re-submits credentials
in its retry loop. If you want to ride out transient errors there, retry inside
mint / login yourself. An SdvError you throw reaches the caller unchanged.
Anything else becomes AssetFetchError with the message
"<family>: auth failed (apply)" (or (refresh)).
Minted tokens — tokenAuth calls mint once, caches the token in-process,
re-mints skewSeconds (default 60) before expiresAt (unix epoch seconds,
like a JWT exp), and re-mints immediately after a 401 (unless the request
carried your own credential in that header, which a new token would not
replace). Concurrent requests
share one in-flight mint. mint receives the family's transport, which bypasses
auth, so the token call itself is not decorated:
configure({
auth: {
my_family: tokenAuth({
mint: async ({ transport }) => {
const res = await transport({
method: 'POST',
url: 'https://auth.example.com/token',
body: { client_id: process.env.CLIENT_ID },
});
return { token: res.data.access_token, expiresAt: res.data.exp };
},
header: 'Authorization', // default
scheme: 'Bearer', // default; '' sends the bare token
}),
},
});
Logins — sessionAuth logs in once, attaches the returned headers and
cookies to every request, logs in again after expiresAt, and re-logs-in after a
401. Response headers from a transport are lower-cased, and multiple
set-cookie values are joined with a newline:
configure({
auth: {
my_site: sessionAuth({
login: async ({ transport }) => {
const res = await transport({
method: 'POST',
url: 'https://example.com/login',
body: new URLSearchParams({ email: process.env.EMAIL, password: process.env.PASSWORD }).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const cookies = Object.fromEntries(
(res.headers['set-cookie'] ?? '').split('\n').filter(Boolean).map((c) => {
const pair = c.split(';')[0];
const eq = pair.indexOf('=');
return [pair.slice(0, eq), pair.slice(eq + 1)];
})
);
return { cookies };
},
}),
},
});
NFL.com (nfl_api)
The nfl_api family ships with a registered tokenAuth that mints an anonymous
NFL.com web token for you. Override it with environment variables —
NFL_ACCESS_TOKEN (used verbatim), or NFL_CLIENT_KEY / NFL_CLIENT_SECRET
(mint with your own client credentials) — or replace it entirely with
configure({ auth: { nfl_api: ... } }). The built-in mint retries a network
error or a 408 / 429 / 5xx itself (with the nfl_api retry budget), but
never a 401 / 403.
Subscription families: PFF, KenPom, NFL Pro
Three families need your own paid credentials. Each registers its own auth,
never retries a 403 (there it is an entitlement answer, not load; PFF, like
sdv-py, does not retry a 408 either), and is
never reachable from the docs playground. Credentials on the call win over the
environment; with none anywhere the call throws an SdvError naming the
variables to set, before any request goes out. Keys, tokens and passwords never
appear in an error message.
PFF Developer API (pff_api, sdv.nfl.pffApi*, 68 wrappers, api.pff.com)
needs a PFF Pro API key (ak_live_…, created at
pff.com/account/api-keys). The key is taken from, in order: an
Authorization header in headers, api_key on the call, SDV_PFF_API_KEY,
then PFF_API_KEY.
import sdv, { InvalidParameterError } from 'sportsdataverse';
// PFF_API_KEY is set in the environment
const rows = await sdv.nfl.pffApiFacetPassingSummary({
league: 'nfl', season: 2022, week: 1, franchise_id: 7, parsed: true,
});
const table = await sdv.nfl.pffApiTeamStats({
league: 'nfl', season: 2024, category: 'offense-passing', parsed: true,
});
/v1query keys are PFF's exact snake_case names (franchise_id,game_id); PFF silently ignores camelCase there./v2routes take camelCase keys (weekGroup,weekIds) and the league in the path.- A
400/422throwsInvalidParameterErrorwith PFF's own message (the call can never succeed as made).404isNoDataError.401/403/429/5xxthat outlive the retries areAssetFetchError, and so is a200whose body is not a JSON object. - A view-only entitlement answers
200with some columns removed and arestrictedlist naming them. By default you get the partial body plus aUserWarning. Passstrict: true(or setSDV_PFF_STRICT=1) to throwAssetFetchErrorinstead — do this in pipelines, where a missing column must never read as a missing stat. - The read budget is 100 requests a minute per account, shared by every client holding the key.
{ parsed: true }keeps sdv-py's shapes.sectionpicks one table:/v2'rows'(default) or'teamTotals'; player reports'weeks'(default) or'career'; a/v1matrix or multi-key body (a dict by default) by key. An unknown name throws, listing the valid ones.
KenPom (kenpom, sdv.mbb.kenpom*, 30 wrappers, kenpom.com) logs in
with your subscription e-mail and password: email / password on the call,
else KENPOM_EMAIL / KENPOM_PW (also KENPOM_PASSWORD, SDV_KENPOM_EMAIL /
SDV_KENPOM_PW, and hoopR's KP_USER / KP_PW). The login runs once and the
session is reused for 30 minutes. A login that KenPom rejects throws instead of
quietly scraping the free tables. A Cookie in headers is used as-is.
import sdv, { hasKenpomLogin, kenpomLogin } from 'sportsdataverse';
if (hasKenpomLogin()) {
await kenpomLogin(); // optional: check the credentials before a long pull
const html = await sdv.mbb.kenpomRatings({ year: 2025 }); // raw page HTML
const tables = await sdv.mbb.kenpomTeam({ team: 'Duke', year: 2025, parsed: true });
// { report_table: [...], schedule_table: [...], player_table: [...], depth_chart: [...] }
}
kenpom.com sits behind a Cloudflare check that answers 403 to Node's own TLS
fingerprint, so the family's default transport is the browser-impersonating
one: install the optional impit (npm install impit). To add a proxy, set
the family's transport yourself:
configure({ transport: { kenpom: createImpersonatingTransport({ proxyUrl }) } }).
{ parsed: true } returns every table on the page keyed by its HTML id, with
the same column names as sportsdataverse-py (and hoopR's KenPom tables);
add section: '<table id>' for one table. A page that comes back as the
logged-out login form is never returned as data: the session that was used
is refreshed once and the page re-fetched, then it throws AssetFetchError.
Sessions for explicit credentials are keyed by an HMAC of e-mail and
password under a random per-process key, cached only after a successful login, and capped at 8. Concurrent
calls for one account share a single login.
NFL Pro (nfl_pro, sdv.nfl.nflPro*, 16 wrappers, pro.nfl.com) serves
the Next Gen Stats tables. Its secured routes need a user-bound token
carrying an active NFL+ Premium plan. It resolves in sportsdataverse-py's order:
- an
Authorizationheader inheaders; tokenon the call;NFLPRO_TOKEN;- a headless-browser login to id.nfl.com with
email/passwordon the call, elseNFLPRO_EMAIL/NFLPRO_PW(the same names as sdv-py).
A supplied token's plan and expiry are checked before any request
(NflProAuthError); an anonymous or client-credentials token is not user-bound
and is refused on every route. Note that NFLPRO_TOKEN wins over email /
password on the call, exactly as in sdv-py.
The login needs the optional peer dependency playwright, imported only when
a login is actually needed:
npm i playwright && npx playwright install chromium. Without it, a login
throws TransportUnavailableError. The login walks id.nfl.com's steps (e-mail,
an optional passkey offer, password) in whatever order the site shows them, and
keeps only a token whose JWT carries an active NFL_PLUS_* plan; "signed in"
alone proves nothing, because an anonymous token looks the same. Logged-in
tokens are cached per account until they expire (with 120 s to spare). The
cache key is an HMAC of the e-mail and password under a random per-process
key. Concurrent calls for one account share one login, and a login whose page
flow has not finished 3 minutes after the browser starts is abandoned (the
browser is closed and the call throws). Browser start-up itself is bounded by
Playwright's own launch timeout (also 3 minutes), so the worst case is about 6. A 401 on a logged-in token drops it and logs in again once; a
supplied token is never re-minted. nflProClearTokenCache() forgets every
token. The e-mail, password and token never appear in an error, its cause,
or a warning. An NFL account with no password on file (id.nfl.com asks you to
set up a password or passkey) cannot log in this way: set a password on the
account first, or use NFLPRO_TOKEN.
DEBUG=pw:api makes Playwright itself log every fill() value, the password
included, to stderr. Never enable it around an NFL Pro login.
import sdv, { nflProToken } from 'sportsdataverse';
// NFLPRO_TOKEN, or NFLPRO_EMAIL + NFLPRO_PW, is set in the environment
const body = await sdv.nfl.nflProPlayersOffensePassingSeason({ season: 2024, season_type: 'REG' });
const rows = await sdv.nfl.nflProTeamOffenseOverviewSeason({ season: 2024, parsed: true });
// or resolve once and reuse it (logs in if no token is set)
const token = await nflProToken({ email: 'you@example.com', password: process.env.MY_NFL_PW });
Responses are cut off at the page size without saying so. The wrapper pages
on offset until it holds the envelope's own total rows (paginate: false
turns that off, max_pages caps it at 40 by default). A capped result carries
_truncated: true and emits a warning. An unsupported query parameter comes
back as an empty 200; that throws InvalidParameterError. week is a path
scope, not a query parameter.
Release downloads (releases)
The dataset loaders (sdv.<league>.load*, e.g. sdv.cfb.loadCfbPbp({ seasons: 2024 }))
download the published release assets (GitHub releases / raw) through the
keyless releases family. It is gateway traffic, so the default retry statuses
apply — 403 included, as sdv-py retries it for every gateway host. Each download
gets its own 5-minute timeout (the largest assets are ~55 MB); pass timeoutMs
to a loader to change it. Route the downloads through a proxy or a custom
transport like any other family:
import { configure } from 'sportsdataverse';
configure({ transport: { releases: myTransport } });
A season whose asset is absent (HTTP 404) is skipped with a warning; any other
failure raises AssetFetchError, so a failed download is never mistaken for an
empty season.
247Sports (sports247, sports247_site_pages)
Both 247Sports families live on sdv.sports247 and need no setup beyond the
optional impit dependency:
npm install impit
- Transport.
ipa.247sports.com(the Recruit Database) and the247sports.com/*.jsonpage models both block plain HTTP clients at the TLS layer. Both families therefore register the browser-impersonating transport as their default. Withoutimpit, every call rejects withTransportUnavailableError. - Auth (
sports247only). The family registers atokenAuth. On first use it requestshttps://247sports.com/and reads the free guestJWTcookie (no login, valid about 12 hours). It caches that token and re-mints it a minute before the JWTexp, and once after a401or — as sdv-py, since an expired guest token can answer403— a403(not when you sent your ownAuthorization). If the mint fails, the request goes out without a token, as in sdv-py, one warning is emitted per process, and the mint is not re-tried for a minute. Public routes such asteamsstill answer. A route that needs the token still fails loudly: its401triggers one refresh, whose mint fails and throws, or its403stands (AssetFetchError).sports247ClearTokenCache()drops the cached token and the failure state. - No
403status retries. Beyond that one re-mint, a403here means the fingerprint block or a logged-in-only route, so neither family retries it. - User-Agent. Requests send the User-Agent of the impersonated browser profile (Chrome 142), so the UA agrees with the TLS fingerprint.
- Thirteen RDB routes (for example
biggestMoversandplayerSportRankings) need a logged-in 247Sports session and are not wrapped.
import sdv from 'sportsdataverse';
const recruits = await sdv.sports247.sports247Recruits({ year: 2026, parsed: true });
const school = await sdv.sports247.sports247SitePagesInstitution({ key: 24099, parsed: true });
To use your own token or transport, replace either default per family. For
example, configure({ auth: { sports247: bearerAuth(() => process.env.MY_247_JWT) } }),
or configure({ transport: { sports247: myTransport, sports247_site_pages: myTransport } }).
The older recruiting family (api.247sports.com) is deprecated. That host
answers HTTP 500. Each of its methods emits one DeprecationWarning naming its
sports247 replacement, or saying that there is none.
stats.nba.com / stats.wnba.com (nba_stats, wnba_stats)
Both families install createImpersonatingTransport({ browser: 'chrome' }) as
their default transport, send the stats headers (x-nba-stats-origin,
x-nba-stats-token, Referer / Origin on nba.com or wnba.com) and never retry
403. Install the optional dependency (npm install impit) and run from a
residential connection: these hosts hang (rather than error) on datacenter
and cloud IPs such as GitHub Actions or AWS. A timeout, blank body or bare {}
rejects with AssetFetchError; it is never reported as "no data". Raise
configure({ timeoutMs }) for slow historical endpoints, and route through a
residential proxy with
configure({ transport: { nba_stats: createImpersonatingTransport({ proxyUrl }) } }).
Live tests: SDV_NBA_STATS_LIVE=1 npm test (never set in CI).