Skip to content

Commit bce16fe

Browse files
http: add support for HTTP 429 rate limit retries
Add retry logic for HTTP 429 (Too Many Requests) responses to handle server-side rate limiting gracefully. When Git's HTTP client receives a 429 response, it can now automatically retry the request after an appropriate delay, respecting the server's rate limits. The implementation supports the RFC-compliant Retry-After header in both delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a past date is provided, Git retries immediately without waiting. Retry behavior is controlled by three new configuration options (http.maxRetries, http.retryAfter, and http.maxRetryTime) which are documented in git-config(1). The retry logic implements a fail-fast approach: if any delay (whether from server header or configuration) exceeds maxRetryTime, Git fails immediately with a clear error message rather than capping the delay. This provides better visibility into rate limiting issues. The implementation includes extensive test coverage for basic retry behavior, Retry-After header formats (integer and HTTP-date), configuration combinations, maxRetryTime limits, invalid header handling, environment variable overrides, and edge cases. Signed-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>
1 parent ef48cab commit bce16fe

File tree

10 files changed

+590
-12
lines changed

10 files changed

+590
-12
lines changed

Documentation/config/http.adoc

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,29 @@ http.keepAliveCount::
315315
unset, curl's default value is used. Can be overridden by the
316316
`GIT_HTTP_KEEPALIVE_COUNT` environment variable.
317317

318+
http.retryAfter::
319+
Default wait time in seconds before retrying when a server returns
320+
HTTP 429 (Too Many Requests) without a Retry-After header.
321+
Defaults to 0 (retry immediately). When a Retry-After header is
322+
present, its value takes precedence over this setting. Can be
323+
overridden by the `GIT_HTTP_RETRY_AFTER` environment variable.
324+
See also `http.maxRetries` and `http.maxRetryTime`.
325+
326+
http.maxRetries::
327+
Maximum number of times to retry after receiving HTTP 429 (Too Many
328+
Requests) responses. Set to 0 (the default) to disable retries.
329+
Can be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.
330+
See also `http.retryAfter` and `http.maxRetryTime`.
331+
332+
http.maxRetryTime::
333+
Maximum time in seconds to wait for a single retry attempt when
334+
handling HTTP 429 (Too Many Requests) responses. If the server
335+
requests a delay (via Retry-After header) or if `http.retryAfter`
336+
is configured with a value that exceeds this maximum, Git will fail
337+
immediately rather than waiting. Default is 300 seconds (5 minutes).
338+
Can be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment
339+
variable. See also `http.retryAfter` and `http.maxRetries`.
340+
318341
http.noEPSV::
319342
A boolean which disables using of EPSV ftp command by curl.
320343
This can be helpful with some "poor" ftp servers which don't

git-curl-compat.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@
3737
#define GIT_CURL_NEED_TRANSFER_ENCODING_HEADER
3838
#endif
3939

40+
/**
41+
* CURLINFO_RETRY_AFTER was added in 7.66.0, released in September 2019.
42+
* It allows curl to automatically parse Retry-After headers.
43+
*/
44+
#if LIBCURL_VERSION_NUM >= 0x074200
45+
#define GIT_CURL_HAVE_CURLINFO_RETRY_AFTER 1
46+
#endif
47+
4048
/**
4149
* CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR were added in 7.85.0,
4250
* released in August 2022.

http.c

Lines changed: 179 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
#include "git-compat-util.h"
55
#include "git-curl-compat.h"
6+
#include "advice.h"
67
#include "environment.h"
78
#include "hex.h"
89
#include "http.h"
@@ -22,6 +23,8 @@
2223
#include "object-file.h"
2324
#include "odb.h"
2425
#include "tempfile.h"
26+
#include "date.h"
27+
#include "trace2.h"
2528

2629
static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);
2730
static int trace_curl_data = 1;
@@ -149,6 +152,11 @@ static char *cached_accept_language;
149152
static char *http_ssl_backend;
150153

151154
static int http_schannel_check_revoke = 1;
155+
156+
static long http_retry_after = 0;
157+
static long http_max_retries = 0;
158+
static long http_max_retry_time = 300;
159+
152160
/*
153161
* With the backend being set to `schannel`, setting sslCAinfo would override
154162
* the Certificate Store in cURL v7.60.0 and later, which is not what we want
@@ -209,7 +217,7 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)
209217
return size && (*ptr == ' ' || *ptr == '\t');
210218
}
211219

212-
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)
220+
static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)
213221
{
214222
size_t size = eltsize * nmemb;
215223
struct strvec *values = &http_auth.wwwauth_headers;
@@ -257,6 +265,50 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN
257265
goto exit;
258266
}
259267

268+
#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER
269+
/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */
270+
if (skip_iprefix_mem(ptr, size, "retry-after:", &val, &val_len)) {
271+
struct active_request_slot *slot = (struct active_request_slot *)p;
272+
273+
strbuf_add(&buf, val, val_len);
274+
strbuf_trim(&buf);
275+
276+
if (slot && slot->results) {
277+
/* Parse the retry-after value (delay-seconds or HTTP-date) */
278+
char *endptr;
279+
long retry_after;
280+
281+
errno = 0;
282+
retry_after = strtol(buf.buf, &endptr, 10);
283+
284+
/* Check if it's a valid integer (delay-seconds format) */
285+
if (endptr != buf.buf && *endptr == '\0' &&
286+
errno != ERANGE && retry_after >= 0) {
287+
slot->results->retry_after = retry_after;
288+
} else {
289+
/* Try parsing as HTTP-date format */
290+
timestamp_t timestamp;
291+
int offset;
292+
if (!parse_date_basic(buf.buf, &timestamp, &offset)) {
293+
/* Successfully parsed as date, calculate delay from now */
294+
timestamp_t now = time(NULL);
295+
if (timestamp > now) {
296+
slot->results->retry_after = (long)(timestamp - now);
297+
} else {
298+
/* Past date means retry immediately */
299+
slot->results->retry_after = 0;
300+
}
301+
} else {
302+
/* Failed to parse as either delay-seconds or HTTP-date */
303+
warning(_("unable to parse Retry-After header value: '%s'"), buf.buf);
304+
}
305+
}
306+
}
307+
308+
goto exit;
309+
}
310+
#endif
311+
260312
/*
261313
* This line could be a continuation of the previously matched header
262314
* field. If this is the case then we should append this value to the
@@ -342,6 +394,17 @@ static void finish_active_slot(struct active_request_slot *slot)
342394

343395
curl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,
344396
&slot->results->http_connectcode);
397+
398+
#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER
399+
if (slot->results->http_code == 429) {
400+
curl_off_t retry_after;
401+
CURLcode res = curl_easy_getinfo(slot->curl,
402+
CURLINFO_RETRY_AFTER,
403+
&retry_after);
404+
if (res == CURLE_OK && retry_after > 0)
405+
slot->results->retry_after = (long)retry_after;
406+
}
407+
#endif
345408
}
346409

347410
/* Run callback if appropriate */
@@ -575,6 +638,21 @@ static int http_options(const char *var, const char *value,
575638
return 0;
576639
}
577640

641+
if (!strcmp("http.retryafter", var)) {
642+
http_retry_after = git_config_int(var, value, ctx->kvi);
643+
return 0;
644+
}
645+
646+
if (!strcmp("http.maxretries", var)) {
647+
http_max_retries = git_config_int(var, value, ctx->kvi);
648+
return 0;
649+
}
650+
651+
if (!strcmp("http.maxretrytime", var)) {
652+
http_max_retry_time = git_config_int(var, value, ctx->kvi);
653+
return 0;
654+
}
655+
578656
/* Fall back on the default ones */
579657
return git_default_config(var, value, ctx, data);
580658
}
@@ -1422,6 +1500,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)
14221500
set_long_from_env(&curl_tcp_keepintvl, "GIT_TCP_KEEPINTVL");
14231501
set_long_from_env(&curl_tcp_keepcnt, "GIT_TCP_KEEPCNT");
14241502

1503+
set_long_from_env(&http_retry_after, "GIT_HTTP_RETRY_AFTER");
1504+
set_long_from_env(&http_max_retries, "GIT_HTTP_MAX_RETRIES");
1505+
set_long_from_env(&http_max_retry_time, "GIT_HTTP_MAX_RETRY_TIME");
1506+
14251507
curl_default = get_curl_handle();
14261508
}
14271509

@@ -1871,6 +1953,10 @@ static int handle_curl_result(struct slot_results *results)
18711953
}
18721954
return HTTP_REAUTH;
18731955
}
1956+
} else if (results->http_code == 429) {
1957+
trace2_data_intmax("http", the_repository, "http/429-retry-after",
1958+
results->retry_after);
1959+
return HTTP_RATE_LIMITED;
18741960
} else {
18751961
if (results->http_connectcode == 407)
18761962
credential_reject(the_repository, &proxy_auth);
@@ -1886,6 +1972,9 @@ int run_one_slot(struct active_request_slot *slot,
18861972
struct slot_results *results)
18871973
{
18881974
slot->results = results;
1975+
/* Initialize retry_after to -1 (not set) */
1976+
results->retry_after = -1;
1977+
18891978
if (!start_active_slot(slot)) {
18901979
xsnprintf(curl_errorstr, sizeof(curl_errorstr),
18911980
"failed to start HTTP request");
@@ -2119,7 +2208,8 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)
21192208

21202209
static int http_request(const char *url,
21212210
void *result, int target,
2122-
const struct http_get_options *options)
2211+
const struct http_get_options *options,
2212+
long *retry_after_out)
21232213
{
21242214
struct active_request_slot *slot;
21252215
struct slot_results results;
@@ -2148,7 +2238,8 @@ static int http_request(const char *url,
21482238
fwrite_buffer);
21492239
}
21502240

2151-
curl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);
2241+
curl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);
2242+
curl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);
21522243

21532244
accept_language = http_get_accept_language_header();
21542245

@@ -2183,6 +2274,10 @@ static int http_request(const char *url,
21832274

21842275
ret = run_one_slot(slot, &results);
21852276

2277+
/* Store retry_after from slot results if output parameter provided */
2278+
if (retry_after_out)
2279+
*retry_after_out = results.retry_after;
2280+
21862281
if (options && options->content_type) {
21872282
struct strbuf raw = STRBUF_INIT;
21882283
curlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);
@@ -2253,21 +2348,79 @@ static int update_url_from_redirect(struct strbuf *base,
22532348
return 1;
22542349
}
22552350

2256-
static int http_request_reauth(const char *url,
2351+
/*
2352+
* Handle rate limiting retry logic for HTTP 429 responses.
2353+
* Returns a negative value if retries are exhausted or configuration is invalid,
2354+
* otherwise returns the delay value (>= 0) to indicate the retry should proceed.
2355+
*/
2356+
static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)
2357+
{
2358+
int retry_attempt = http_max_retries - *rate_limit_retries + 1;
2359+
2360+
trace2_data_intmax("http", the_repository, "http/429-retry-attempt",
2361+
retry_attempt);
2362+
2363+
if (*rate_limit_retries <= 0) {
2364+
/* Retries are disabled or exhausted */
2365+
if (http_max_retries > 0) {
2366+
error(_("too many rate limit retries, giving up"));
2367+
trace2_data_string("http", the_repository,
2368+
"http/429-error", "retries-exhausted");
2369+
}
2370+
return -1;
2371+
}
2372+
2373+
(*rate_limit_retries)--;
2374+
2375+
/* Use the slot-specific retry_after value or configured default */
2376+
if (slot_retry_after >= 0) {
2377+
/* Check if retry delay exceeds maximum allowed */
2378+
if (slot_retry_after > http_max_retry_time) {
2379+
error(_("response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)"),
2380+
slot_retry_after, http_max_retry_time);
2381+
trace2_data_string("http", the_repository,
2382+
"http/429-error", "exceeds-max-retry-time");
2383+
trace2_data_intmax("http", the_repository,
2384+
"http/429-requested-delay", slot_retry_after);
2385+
return -1;
2386+
}
2387+
return slot_retry_after;
2388+
} else {
2389+
/* No Retry-After header provided, use configured default */
2390+
if (http_retry_after > http_max_retry_time) {
2391+
error(_("configured http.retryAfter exceeds http.maxRetryTime (%ld > %ld seconds)"),
2392+
http_retry_after, http_max_retry_time);
2393+
trace2_data_string("http", the_repository,
2394+
"http/429-error", "config-exceeds-max-retry-time");
2395+
return -1;
2396+
}
2397+
trace2_data_string("http", the_repository,
2398+
"http/429-retry-source", "config-default");
2399+
return http_retry_after;
2400+
}
2401+
}
2402+
2403+
static int http_request_recoverable(const char *url,
22572404
void *result, int target,
22582405
struct http_get_options *options)
22592406
{
22602407
int i = 3;
22612408
int ret;
2409+
int rate_limit_retries = http_max_retries;
2410+
long slot_retry_after = -1; /* Per-slot retry_after value */
22622411

22632412
if (always_auth_proactively())
22642413
credential_fill(the_repository, &http_auth, 1);
22652414

2266-
ret = http_request(url, result, target, options);
2415+
ret = http_request(url, result, target, options, &slot_retry_after);
22672416

2268-
if (ret != HTTP_OK && ret != HTTP_REAUTH)
2417+
if (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)
22692418
return ret;
22702419

2420+
/* If retries are disabled and we got a 429, fail immediately */
2421+
if (ret == HTTP_RATE_LIMITED && !http_max_retries)
2422+
return HTTP_ERROR;
2423+
22712424
if (options && options->effective_url && options->base_url) {
22722425
if (update_url_from_redirect(options->base_url,
22732426
url, options->effective_url)) {
@@ -2276,7 +2429,8 @@ static int http_request_reauth(const char *url,
22762429
}
22772430
}
22782431

2279-
while (ret == HTTP_REAUTH && --i) {
2432+
while ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {
2433+
long retry_delay = -1;
22802434
/*
22812435
* The previous request may have put cruft into our output stream; we
22822436
* should clear it out before making our next request.
@@ -2301,10 +2455,23 @@ static int http_request_reauth(const char *url,
23012455
default:
23022456
BUG("Unknown http_request target");
23032457
}
2458+
if (ret == HTTP_RATE_LIMITED) {
2459+
retry_delay = handle_rate_limit_retry(&rate_limit_retries, slot_retry_after);
2460+
if (retry_delay < 0)
2461+
return HTTP_ERROR;
2462+
2463+
if (retry_delay > 0) {
2464+
warning(_("rate limited, waiting %ld seconds before retry"), retry_delay);
2465+
trace2_data_intmax("http", the_repository,
2466+
"http/retry-sleep-seconds", retry_delay);
2467+
sleep(retry_delay);
2468+
}
2469+
slot_retry_after = -1; /* Reset after use */
2470+
} else if (ret == HTTP_REAUTH) {
2471+
credential_fill(the_repository, &http_auth, 1);
2472+
}
23042473

2305-
credential_fill(the_repository, &http_auth, 1);
2306-
2307-
ret = http_request(url, result, target, options);
2474+
ret = http_request(url, result, target, options, &slot_retry_after);
23082475
}
23092476
return ret;
23102477
}
@@ -2313,7 +2480,7 @@ int http_get_strbuf(const char *url,
23132480
struct strbuf *result,
23142481
struct http_get_options *options)
23152482
{
2316-
return http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);
2483+
return http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);
23172484
}
23182485

23192486
/*
@@ -2337,7 +2504,7 @@ int http_get_file(const char *url, const char *filename,
23372504
goto cleanup;
23382505
}
23392506

2340-
ret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);
2507+
ret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);
23412508
fclose(result);
23422509

23432510
if (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))

http.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ struct slot_results {
2020
long http_code;
2121
long auth_avail;
2222
long http_connectcode;
23+
long retry_after;
2324
};
2425

2526
struct active_request_slot {
@@ -167,6 +168,7 @@ struct http_get_options {
167168
#define HTTP_REAUTH 4
168169
#define HTTP_NOAUTH 5
169170
#define HTTP_NOMATCHPUBLICKEY 6
171+
#define HTTP_RATE_LIMITED 7
170172

171173
/*
172174
* Requests a URL and stores the result in a strbuf.

0 commit comments

Comments
 (0)