Skip to content

Commit f671ad5

Browse files
authored
fix(http): raise the CFBD retry budget so a throttle stops looking like no data (#148)
cfbfastR_cfb_pbp published nothing from 2026-07-01 onward and has no play_by_play_2026 asset, while the cfbfastR-data job that builds it reported success every run. In one run cfbd_play_stats_player() hit HTTP 429 sixty-three times, so sack_player_id never materialised and the roster join in week.R:349 aborted the script 628 lines before the release upload. httr2 was already doing the right things -- retry_is_transient() retries 429/503 by default and retry_after() honours a Retry-After header, falling back to our backoff. Only the budget was wrong: 3 tries and roughly 1-9s of backoff covers an incidental 429, not the sustained throttling a parallel caller produces. When the budget ran out, req_error(is_error = ~FALSE) handed the still-429 response back and check_status() raised "The CFBD API returned HTTP 429", which every cfbd_*() tryCatch reports as "... or no data available" -- a throttle became indistinguishable from an empty result. max_tries 3 -> 6 with max_seconds = 120. The cap matters: without it a hard-down API hangs a caller for the full tries * backoff product. All 28 callers of get_req() inherit this. Also splits build_req() out of get_req() so the policy is assertable without network. Signature unchanged, so tests/testthat/setup-cfbd-throttle.R still works -- which is also why the test targets build_req() and why nothing asserts the delegation: the throttle hot-swaps get_req() in the namespace for the whole session. build_req() is title-free in roxygen so no .Rd is generated for an internal helper. No other generated file is touched: the committed docs predate the local roxygen and regenerating rewrites ~150 unrelated man/*.Rd plus a hand-formatted NAMESPACE. Verified: 6/6 assertions pass offline, the same assertions fail against the old 3-try/no-max-wait policy, and R CMD check passes on ubuntu release, ubuntu oldrel-1 and windows. Follow-up: CLAUDE.md line 133 still says wrappers "don't hot-retry"; that is now stale and needs updating.
1 parent 14e5900 commit f671ad5

2 files changed

Lines changed: 73 additions & 5 deletions

File tree

R/utils.R

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,14 @@ rbindlist_with_attrs <- function(dflist){
247247
}
248248

249249
# Request Functions ----
250+
# build_req() is split out from get_req() so the retry/timeout policy can be
251+
# asserted in a test without performing a request (see
252+
# tests/testthat/test-utils_request_policy.R). get_req() remains the only entry
253+
# point callers use. Deliberately kept title-free so roxygen generates no .Rd
254+
# for an internal helper.
250255
#' @keywords Internal
251256
#' @importFrom httr2 request req_headers req_timeout req_retry req_error req_perform req_proxy
252-
get_req <- function(full_url, proxy = NULL) {
257+
build_req <- function(full_url, proxy = NULL) {
253258
req <- httr2::request(full_url) |>
254259
httr2::req_headers(Authorization = paste("Bearer", cfbd_key())) |>
255260
httr2::req_timeout(60)
@@ -280,13 +285,37 @@ get_req <- function(full_url, proxy = NULL) {
280285
}
281286
}
282287

288+
# Retry budget. httr2 retries 429/503 by default (`retry_is_transient`) and
289+
# honours a `Retry-After` header when the server sends one, falling back to
290+
# `backoff` when it does not.
291+
#
292+
# The previous budget -- 3 tries, ~1-9s of backoff -- is enough for an
293+
# incidental 429 but not for sustained throttling, which is what a parallel
294+
# caller produces. When it ran out, `req_error(is_error = ~FALSE)` handed the
295+
# still-429 response back and `check_status()` turned it into a plain error,
296+
# which every cfbd_*() tryCatch reports as "no data available". A throttle
297+
# then looks exactly like an empty result. That is how cfbfastR_cfb_pbp
298+
# published nothing from 2026-07-01 onward while its job stayed green:
299+
# `cfbd_play_stats_player()` 429'd 63 times in one run, `sack_player_id`
300+
# never materialised, and the downstream join aborted the whole script.
301+
#
302+
# `max_seconds` bounds the total wait so a hard-down API still fails in
303+
# bounded time rather than hanging a caller for max_tries * backoff.
304+
#
305+
# Note this is the production path only -- tests already slow themselves via
306+
# the `get_req()` wrapper in tests/testthat/setup-cfbd-throttle.R.
283307
req |>
284308
httr2::req_retry(
285-
max_tries = 3,
286-
backoff = function(i) stats::runif(1, 0.5, 1.5) * (2 ^ i)
309+
max_tries = 6,
310+
max_seconds = 120,
311+
backoff = function(i) stats::runif(1, 0.5, 1.5) * (2 ^ i)
287312
) |>
288-
httr2::req_error(is_error = function(resp) FALSE) |>
289-
httr2::req_perform()
313+
httr2::req_error(is_error = function(resp) FALSE)
314+
}
315+
316+
#' @keywords Internal
317+
get_req <- function(full_url, proxy = NULL) {
318+
httr2::req_perform(build_req(full_url, proxy = proxy))
290319
}
291320

292321
#' Drop NULL entries from a list (internal)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
test_that("build_req() carries a retry budget that survives sustained CFBD throttling", {
2+
# Offline by design: build_req() only constructs the request, and cfbd_key()
3+
# returns NA rather than erroring when CFBD_API_KEY is unset, so this needs
4+
# neither network nor credentials.
5+
req <- cfbfastR:::build_req("https://api.collegefootballdata.com/games")
6+
7+
# httr2 already retries 429/503 (retry_is_transient) and honours a
8+
# Retry-After header; what this package controls is the budget. The previous
9+
# 3 tries / ~1-9s of backoff covered an incidental 429 but not the sustained
10+
# throttling a parallel caller produces -- when it ran out, check_status()
11+
# turned the surviving 429 into an error that every cfbd_*() tryCatch reports
12+
# as "no data available", making a throttle indistinguishable from an empty
13+
# result.
14+
expect_gte(req$policies$retry_max_tries, 6L)
15+
expect_gte(req$policies$retry_max_wait, 120)
16+
17+
# A backoff must still exist for servers that send no Retry-After header.
18+
expect_type(req$policies$retry_backoff, "closure")
19+
expect_true(is.finite(req$policies$retry_backoff(1)))
20+
})
21+
22+
test_that("build_req() leaves HTTP status handling to check_status()", {
23+
req <- cfbfastR:::build_req("https://api.collegefootballdata.com/games")
24+
25+
# req_error(is_error = ~FALSE) is deliberate: the response is handed back so
26+
# check_status() can raise the "The CFBD API returned HTTP ..." message the
27+
# cfbd_*() family reports. If this ever flips to TRUE, httr2 would abort
28+
# first and that message would never be produced.
29+
expect_type(req$policies$error_is_error, "closure")
30+
expect_false(req$policies$error_is_error(
31+
structure(list(status_code = 429L), class = "httr2_response")
32+
))
33+
})
34+
35+
# No test asserts that get_req() delegates to build_req(): setup-cfbd-throttle.R
36+
# hot-swaps get_req() in the cfbfastR namespace for the whole test session, so
37+
# anything inspecting it here sees the throttle wrapper rather than the package
38+
# function. build_req() is not swapped, which is why the policy assertions above
39+
# target it directly.

0 commit comments

Comments
 (0)