Skip to content

Commit 416b21e

Browse files
committed
add max_sleep_seconds, fix tests
1 parent 76bc506 commit 416b21e

File tree

4 files changed

+50
-40
lines changed

4 files changed

+50
-40
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ These methods all accept keyword arguments which control how the request is made
7373

7474
- `max_retries::Int=5`: how many retries to attempt in requesting the resources. Retries are only made for idempotent requests ("GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE") and delays respect GitHub [rate limit headers](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#checking-the-status-of-your-rate-limit).
7575
- `verbose::Bool=true`: whether or not to log retries as Info level logs
76+
- `max_sleep_seconds::Real=60*20`: if GitHub.jl intends to sleep for longer than `max_sleep_seconds` before retrying, e.g. due to rate limit headers from GitHub, throws an `RetryDelayException` instead.
7677

7778
#### Users and Organizations
7879

src/GitHub.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export # auth.jl
4444
authenticate
4545

4646
export # requests.jl
47-
rate_limit
47+
rate_limit, RetryDelayException
4848

4949
##################################
5050
# Owners (organizations + users) #

src/utils/requests.jl

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,13 @@ function github_retry_decision(method::String, resp::Union{HTTP.Response, Nothin
184184
return (true, delay_seconds)
185185
end
186186

187+
struct RetryDelayException <: Exception
188+
msg::String
189+
end
190+
Base.showerror(io::IO, e::RetryDelayException) = print(io, e.msg)
191+
187192
"""
188-
with_retries(f; method::AbstractString="GET", max_retries::Int=5, verbose::Bool=true, sleep_fn=sleep) -> Any
193+
with_retries(f; method::AbstractString="GET", max_retries::Int=5, verbose::Bool=true, sleep_fn=sleep, max_sleep_seconds::Real = 20*60)
189194
190195
Generic retry wrapper that executes function `f()` with GitHub-specific retry logic.
191196
@@ -195,6 +200,7 @@ Generic retry wrapper that executes function `f()` with GitHub-specific retry lo
195200
- `max_retries`: Maximum number of retry attempts (default: 5)
196201
- `verbose`: Whether to log retry decisions (default: true)
197202
- `sleep_fn`: Function to call for sleeping between retries (default: sleep). For testing, can be replaced with a custom function.
203+
- `max_sleep_seconds::Real`: maximum number of seconds to sleep when delaying before retrying. If the intended retry delay exceeds `max_sleep_seconds` an exception is thrown instead. This parameter defaults to 20*60 (20 minutes).
198204
199205
# Returns
200206
Returns the result of `f()` if successful, or re-throws the final exception if all retries fail.
@@ -206,7 +212,7 @@ result = with_retries(method="GET", verbose=false) do
206212
end
207213
```
208214
"""
209-
function with_retries(f; method::AbstractString="GET", max_retries::Int=5, verbose::Bool=true, sleep_fn=sleep)
215+
function with_retries(f; method::AbstractString="GET", max_retries::Int=5, verbose::Bool=true, sleep_fn=sleep, max_sleep_seconds::Real = 60*20)
210216
backoff = Base.ExponentialBackOff(n = max_retries+1)
211217

212218
for (attempt, exponential_delay) in enumerate(backoff)
@@ -227,7 +233,7 @@ function with_retries(f; method::AbstractString="GET", max_retries::Int=5, verbo
227233
end
228234

229235
# Check if we should retry based on this attempt
230-
should_retry, sleep_seconds = github_retry_decision(method, r, ex, exponential_delay; verbose=verbose)
236+
should_retry, sleep_seconds = github_retry_decision(method, r, ex, exponential_delay; verbose)
231237

232238
if !should_retry
233239
if ex !== nothing
@@ -236,7 +242,9 @@ function with_retries(f; method::AbstractString="GET", max_retries::Int=5, verbo
236242
return r
237243
end
238244
end
239-
245+
if sleep_seconds > max_sleep_seconds
246+
throw(RetryDelayException("Retry delay $(sleep_seconds) exceeds configured maximum ($(max_sleep_seconds) seconds)"))
247+
end
240248
if sleep_seconds > 0
241249
sleep_fn(sleep_seconds)
242250
end
@@ -246,14 +254,14 @@ end
246254
function github_request(api::GitHubAPI, request_method::String, endpoint;
247255
auth = AnonymousAuth(), handle_error = true,
248256
headers = Dict(), params = Dict(), allowredirects = true,
249-
max_retries = 5, verbose = true)
257+
max_retries = 5, verbose = true, max_sleep_seconds = 20*60)
250258
authenticate_headers!(headers, auth)
251259
params = github2json(params)
252260
api_endpoint = api_uri(api, endpoint)
253261
_headers = convert(Dict{String, String}, headers)
254262
!haskey(_headers, "User-Agent") && (_headers["User-Agent"] = "GitHub-jl")
255263

256-
r = with_retries(; method = request_method, max_retries, verbose) do
264+
r = with_retries(; method = request_method, max_retries, verbose, max_sleep_seconds) do
257265
if request_method == "GET"
258266
return HTTP.request(request_method, URIs.URI(api_endpoint, query = params), _headers;
259267
redirect = allowredirects, status_exception = false,
@@ -307,20 +315,20 @@ end
307315
extract_page_url(link) = match(r"<.*?>", link).match[2:end-1]
308316

309317
function github_paged_get(api, endpoint; page_limit = Inf, start_page = "", handle_error = true,
310-
auth = AnonymousAuth(), headers = Dict(), params = Dict(), max_retries = 5, verbose = true, options...)
318+
auth = AnonymousAuth(), headers = Dict(), params = Dict(), max_retries = 5, verbose = true, max_sleep_seconds = 20*60, options...)
311319
authenticate_headers!(headers, auth)
312320
_headers = convert(Dict{String, String}, headers)
313321
!haskey(_headers, "User-Agent") && (_headers["User-Agent"] = "GitHub-jl")
314322

315323
# Helper function to make a get request with retries
316324
function make_request_with_retries(url, headers)
317-
return with_retries(; method = "GET", max_retries, verbose) do
325+
return with_retries(; method = "GET", max_retries, verbose, max_sleep_seconds) do
318326
HTTP.request("GET", url, headers; status_exception = false, retry = false)
319327
end
320328
end
321329

322330
if isempty(start_page)
323-
r = gh_get(api, endpoint; handle_error, headers = _headers, params, auth, max_retries, verbose, options...)
331+
r = gh_get(api, endpoint; handle_error, headers = _headers, params, auth, max_retries, verbose, max_sleep_seconds, options...)
324332
else
325333
@assert isempty(params) "`start_page` kwarg is incompatible with `params` kwarg"
326334
r = make_request_with_retries(start_page, _headers)

test/retries.jl

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ using Test
22
using HTTP
33
using GitHub
44

5+
primary_rate_limit_body = Vector{UInt8}("primary rate limit")
6+
secondary_rate_limit_body = Vector{UInt8}("secondary rate limit")
7+
58
@testset "github_retry_decision" begin
69

710
@testset "HTTP.jl recoverable exceptions" begin
@@ -42,9 +45,9 @@ using GitHub
4245
resp = HTTP.Response(403, [
4346
"x-ratelimit-remaining" => "0",
4447
"x-ratelimit-reset" => future_time
45-
])
48+
], primary_rate_limit_body)
4649

47-
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 2.0; verbose=false)
50+
should_retry, sleep_seconds = GitHub.github_retry_decision("GET", resp, nothing, 2.0; verbose=false)
4851
@test should_retry == true
4952
@test sleep_seconds > 100000 # Should be a large delay since reset time is far in future
5053

@@ -53,18 +56,17 @@ using GitHub
5356
resp2 = HTTP.Response(403, [
5457
"x-ratelimit-remaining" => "0",
5558
"x-ratelimit-reset" => past_time
56-
])
59+
], primary_rate_limit_body)
5760

58-
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp2, nothing, 5.0; verbose=false)
61+
should_retry, sleep_seconds = GitHub.github_retry_decision("GET", resp2, nothing, 5.0; verbose=false)
5962
@test should_retry == true
6063
@test sleep_seconds == 5.0 # Should use the exponential delay
6164
end
6265

6366
@testset "Secondary rate limit - retry-after header" begin
6467

6568
# Test secondary rate limit with retry-after
66-
body = """{"message": "You have been rate limited due to a secondary rate limit", "documentation_url": "..."}"""
67-
resp = HTTP.Response(429, ["retry-after" => "30"]; body = Vector{UInt8}(body))
69+
resp = HTTP.Response(429, ["retry-after" => "30"]; body = secondary_rate_limit_body)
6870

6971
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 2.0; verbose=false)
7072
@test should_retry == true
@@ -77,11 +79,8 @@ using GitHub
7779
@test sleep_seconds == 15.0
7880
end
7981

80-
@testset "Secondary rate limit - message in body" begin
81-
82-
# Test secondary rate limit detected from body message
83-
body = """{"message": "You have exceeded a secondary rate limit. Please wait one minute before trying again."}"""
84-
resp = HTTP.Response(429; body = Vector{UInt8}(body))
82+
@testset "Secondary rate limit - no headers" begin
83+
resp = HTTP.Response(429; body = secondary_rate_limit_body)
8584

8685
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 2.0; verbose=false)
8786
@test should_retry == true
@@ -97,20 +96,18 @@ using GitHub
9796

9897
# Test secondary rate limit with reset time - use fixed timestamp to avoid race conditions
9998
future_time = "1900000000" # Fixed timestamp in the future (year 2030)
100-
body = """{"message": "secondary rate limit exceeded"}"""
10199
resp = HTTP.Response(403, [
102100
"x-ratelimit-remaining" => "0",
103101
"x-ratelimit-reset" => future_time
104-
]; body = Vector{UInt8}(body))
102+
], secondary_rate_limit_body)
105103

106104
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 5.0; verbose=false)
107105
@test should_retry == true
108106
@test sleep_seconds > 100000 # Should be a large delay since reset time is far in future
109107
end
110108

111-
@testset "Primary rate limit - exponential backoff" begin
112-
113-
# Primary rate limit without specific headers
109+
@testset "429 - exponential backoff" begin
110+
# 429 without specific headers or body
114111
resp = HTTP.Response(429, [])
115112

116113
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 4.0; verbose=false)
@@ -123,7 +120,6 @@ using GitHub
123120
end
124121

125122
@testset "Other HTTP errors" begin
126-
127123
for status in [408, 409, 500, 502, 503, 504, 599]
128124
resp = HTTP.Response(status, [])
129125

@@ -134,8 +130,7 @@ using GitHub
134130
end
135131

136132
@testset "Non-retryable client errors" begin
137-
138-
for status in [400, 401, 404, 422]
133+
for status in [400, 401, 403, 404, 422]
139134
resp = HTTP.Response(status, [])
140135
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 1.0; verbose=false)
141136
@test should_retry == false
@@ -144,25 +139,23 @@ using GitHub
144139
end
145140

146141
@testset "Invalid header values" begin
147-
148-
# Test with invalid retry-after header (should fall back to secondary rate limit minimum)
149-
resp1 = HTTP.Response(429, ["retry-after" => "invalid"], Vector{UInt8}("secondary rate limit"))
142+
# Test with invalid retry-after header (should use secondary rate limit minimum)
143+
resp1 = HTTP.Response(429, ["retry-after" => "invalid"], secondary_rate_limit_body)
150144
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp1, nothing, 2.0; verbose=false)
151145
@test should_retry == true
152146
@test sleep_seconds == 60.0 # Falls back to secondary rate limit minimum (1 minute)
153147

154-
# Test with invalid reset time (should fall back to exponential backoff)
148+
# Test with invalid reset time (should fall back to secondary min)
155149
resp2 = HTTP.Response(403, [
156150
"x-ratelimit-remaining" => "0",
157151
"x-ratelimit-reset" => "invalid"
158-
])
152+
], secondary_rate_limit_body)
159153
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp2, nothing, 3.0; verbose=false)
160154
@test should_retry == true
161-
@test sleep_seconds == 3.0 # Falls back to exponential backoff
155+
@test sleep_seconds == 60.0 # minimum for secondary rate limit
162156
end
163157

164158
@testset "Rate limit header precedence" begin
165-
166159
# retry-after should take precedence over x-ratelimit-reset
167160
future_time = "1900000000" # Fixed timestamp (doesn't matter since retry-after takes precedence)
168161
resp = HTTP.Response(429, [
@@ -183,7 +176,7 @@ using GitHub
183176
resp = HTTP.Response(403, [
184177
"x-ratelimit-remaining" => "5",
185178
"x-ratelimit-reset" => future_time
186-
])
179+
], primary_rate_limit_body)
187180

188181
should_retry, sleep_seconds = GitHub.github_retry_decision("GET",resp, nothing, 3.0; verbose=false)
189182
@test should_retry == true
@@ -332,13 +325,13 @@ end
332325
current_time = time()
333326
reset_time = string(Int(round(current_time)) + 500000000) # 500000000 seconds from now
334327

335-
result = GitHub.with_retries(method="GET", max_retries=1, verbose=false, sleep_fn=test_sleep) do
328+
result = GitHub.with_retries(method="GET", max_retries=1, verbose=false, sleep_fn=test_sleep, max_sleep_seconds=2*500000000) do
336329
call_count[] += 1
337330
if call_count[] == 1
338331
return HTTP.Response(403, [
339332
"x-ratelimit-remaining" => "0",
340333
"x-ratelimit-reset" => reset_time
341-
])
334+
], primary_rate_limit_body)
342335
else
343336
return HTTP.Response(200)
344337
end
@@ -347,7 +340,15 @@ end
347340
@test result.status == 200
348341
@test call_count[] ==2
349342
@test length(sleep_calls) == 1
350-
@test sleep_calls[1] >= 5.0 # Should wait at least until reset time
343+
@test sleep_calls[1] >= 500000000 # Should wait at least until reset time
344+
345+
346+
@test_throws RetryDelayException GitHub.with_retries(method="GET", max_retries=1, verbose=false, sleep_fn=test_sleep) do
347+
return HTTP.Response(403, [
348+
"x-ratelimit-remaining" => "0",
349+
"x-ratelimit-reset" => reset_time
350+
], primary_rate_limit_body)
351+
end
351352
end
352353

353354
@testset "Secondary rate limit with retry-after" begin

0 commit comments

Comments
 (0)