Skip to content

Commit 3418f45

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 0dc214d commit 3418f45

File tree

10 files changed

+551
-21
lines changed

10 files changed

+551
-21
lines changed

Documentation/config/http.adoc

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,32 @@ 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; however,
323+
automatic use of the server-provided `Retry-After` header requires
324+
libcurl 7.66.0 or later. On older versions, configure this setting
325+
manually to control the retry delay. Can be overridden by the
326+
`GIT_HTTP_RETRY_AFTER` environment variable.
327+
See also `http.maxRetries` and `http.maxRetryTime`.
328+
329+
http.maxRetries::
330+
Maximum number of times to retry after receiving HTTP 429 (Too Many
331+
Requests) responses. Set to 0 (the default) to disable retries.
332+
Can be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.
333+
See also `http.retryAfter` and `http.maxRetryTime`.
334+
335+
http.maxRetryTime::
336+
Maximum time in seconds to wait for a single retry attempt when
337+
handling HTTP 429 (Too Many Requests) responses. If the server
338+
requests a delay (via Retry-After header) or if `http.retryAfter`
339+
is configured with a value that exceeds this maximum, Git will fail
340+
immediately rather than waiting. Default is 300 seconds (5 minutes).
341+
Can be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment
342+
variable. See also `http.retryAfter` and `http.maxRetries`.
343+
318344
http.noEPSV::
319345
A boolean which disables using of EPSV ftp command by curl.
320346
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: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
#include "object-file.h"
2323
#include "odb.h"
2424
#include "tempfile.h"
25+
#include "date.h"
26+
#include "trace2.h"
2527

2628
static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);
2729
static int trace_curl_data = 1;
@@ -149,6 +151,11 @@ static char *cached_accept_language;
149151
static char *http_ssl_backend;
150152

151153
static int http_schannel_check_revoke = 1;
154+
155+
static long http_retry_after = 0;
156+
static long http_max_retries = 0;
157+
static long http_max_retry_time = 300;
158+
152159
/*
153160
* With the backend being set to `schannel`, setting sslCAinfo would override
154161
* the Certificate Store in cURL v7.60.0 and later, which is not what we want
@@ -209,7 +216,7 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)
209216
return size && (*ptr == ' ' || *ptr == '\t');
210217
}
211218

212-
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)
219+
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)
213220
{
214221
size_t size = eltsize * nmemb;
215222
struct strvec *values = &http_auth.wwwauth_headers;
@@ -575,6 +582,21 @@ static int http_options(const char *var, const char *value,
575582
return 0;
576583
}
577584

585+
if (!strcmp("http.retryafter", var)) {
586+
http_retry_after = git_config_int(var, value, ctx->kvi);
587+
return 0;
588+
}
589+
590+
if (!strcmp("http.maxretries", var)) {
591+
http_max_retries = git_config_int(var, value, ctx->kvi);
592+
return 0;
593+
}
594+
595+
if (!strcmp("http.maxretrytime", var)) {
596+
http_max_retry_time = git_config_int(var, value, ctx->kvi);
597+
return 0;
598+
}
599+
578600
/* Fall back on the default ones */
579601
return git_default_config(var, value, ctx, data);
580602
}
@@ -1422,6 +1444,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)
14221444
set_long_from_env(&curl_tcp_keepintvl, "GIT_TCP_KEEPINTVL");
14231445
set_long_from_env(&curl_tcp_keepcnt, "GIT_TCP_KEEPCNT");
14241446

1447+
set_long_from_env(&http_retry_after, "GIT_HTTP_RETRY_AFTER");
1448+
set_long_from_env(&http_max_retries, "GIT_HTTP_MAX_RETRIES");
1449+
set_long_from_env(&http_max_retry_time, "GIT_HTTP_MAX_RETRY_TIME");
1450+
14251451
curl_default = get_curl_handle();
14261452
}
14271453

@@ -1871,6 +1897,10 @@ static int handle_curl_result(struct slot_results *results)
18711897
}
18721898
return HTTP_REAUTH;
18731899
}
1900+
} else if (results->http_code == 429) {
1901+
trace2_data_intmax("http", the_repository, "http/429-retry-after",
1902+
results->retry_after);
1903+
return HTTP_RATE_LIMITED;
18741904
} else {
18751905
if (results->http_connectcode == 407)
18761906
credential_reject(the_repository, &proxy_auth);
@@ -1886,6 +1916,7 @@ int run_one_slot(struct active_request_slot *slot,
18861916
struct slot_results *results)
18871917
{
18881918
slot->results = results;
1919+
18891920
if (!start_active_slot(slot)) {
18901921
xsnprintf(curl_errorstr, sizeof(curl_errorstr),
18911922
"failed to start HTTP request");
@@ -2119,10 +2150,10 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)
21192150

21202151
static int http_request(const char *url,
21212152
void *result, int target,
2122-
const struct http_get_options *options)
2153+
struct http_get_options *options)
21232154
{
21242155
struct active_request_slot *slot;
2125-
struct slot_results results;
2156+
struct slot_results results = { .retry_after = -1 };
21262157
struct curl_slist *headers = http_copy_default_headers();
21272158
struct strbuf buf = STRBUF_INIT;
21282159
const char *accept_language;
@@ -2156,22 +2187,19 @@ static int http_request(const char *url,
21562187
headers = curl_slist_append(headers, accept_language);
21572188

21582189
strbuf_addstr(&buf, "Pragma:");
2159-
if (options && options->no_cache)
2190+
if (options->no_cache)
21602191
strbuf_addstr(&buf, " no-cache");
2161-
if (options && options->initial_request &&
2192+
if (options->initial_request &&
21622193
http_follow_config == HTTP_FOLLOW_INITIAL)
21632194
curl_easy_setopt(slot->curl, CURLOPT_FOLLOWLOCATION, 1L);
21642195

21652196
headers = curl_slist_append(headers, buf.buf);
21662197

21672198
/* Add additional headers here */
2168-
if (options && options->extra_headers) {
2199+
if (options->extra_headers) {
21692200
const struct string_list_item *item;
2170-
if (options && options->extra_headers) {
2171-
for_each_string_list_item(item, options->extra_headers) {
2172-
headers = curl_slist_append(headers, item->string);
2173-
}
2174-
}
2201+
for_each_string_list_item(item, options->extra_headers)
2202+
headers = curl_slist_append(headers, item->string);
21752203
}
21762204

21772205
headers = http_append_auth_header(&http_auth, headers);
@@ -2183,15 +2211,26 @@ static int http_request(const char *url,
21832211

21842212
ret = run_one_slot(slot, &results);
21852213

2186-
if (options && options->content_type) {
2214+
#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER
2215+
if (ret == HTTP_RATE_LIMITED) {
2216+
curl_off_t retry_after;
2217+
if (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,
2218+
&retry_after) == CURLE_OK && retry_after > 0)
2219+
results.retry_after = (long)retry_after;
2220+
}
2221+
#endif
2222+
2223+
options->retry_after = results.retry_after;
2224+
2225+
if (options->content_type) {
21872226
struct strbuf raw = STRBUF_INIT;
21882227
curlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);
21892228
extract_content_type(&raw, options->content_type,
21902229
options->charset);
21912230
strbuf_release(&raw);
21922231
}
21932232

2194-
if (options && options->effective_url)
2233+
if (options->effective_url)
21952234
curlinfo_strbuf(slot->curl, CURLINFO_EFFECTIVE_URL,
21962235
options->effective_url);
21972236

@@ -2253,30 +2292,76 @@ static int update_url_from_redirect(struct strbuf *base,
22532292
return 1;
22542293
}
22552294

2256-
static int http_request_reauth(const char *url,
2295+
/*
2296+
* Compute the retry delay for an HTTP 429 response.
2297+
* Returns a negative value if configuration is invalid (delay exceeds
2298+
* http.maxRetryTime), otherwise returns the delay in seconds (>= 0).
2299+
*/
2300+
static long handle_rate_limit_retry(long slot_retry_after)
2301+
{
2302+
/* Use the slot-specific retry_after value or configured default */
2303+
if (slot_retry_after >= 0) {
2304+
/* Check if retry delay exceeds maximum allowed */
2305+
if (slot_retry_after > http_max_retry_time) {
2306+
error(_("response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)"),
2307+
slot_retry_after, http_max_retry_time);
2308+
trace2_data_string("http", the_repository,
2309+
"http/429-error", "exceeds-max-retry-time");
2310+
trace2_data_intmax("http", the_repository,
2311+
"http/429-requested-delay", slot_retry_after);
2312+
return -1;
2313+
}
2314+
return slot_retry_after;
2315+
} else {
2316+
/* No Retry-After header provided, use configured default */
2317+
if (http_retry_after > http_max_retry_time) {
2318+
error(_("configured http.retryAfter exceeds http.maxRetryTime (%ld > %ld seconds)"),
2319+
http_retry_after, http_max_retry_time);
2320+
trace2_data_string("http", the_repository,
2321+
"http/429-error", "config-exceeds-max-retry-time");
2322+
return -1;
2323+
}
2324+
trace2_data_string("http", the_repository,
2325+
"http/429-retry-source", "config-default");
2326+
return http_retry_after;
2327+
}
2328+
}
2329+
2330+
static int http_request_recoverable(const char *url,
22572331
void *result, int target,
22582332
struct http_get_options *options)
22592333
{
2334+
static struct http_get_options empty_opts;
22602335
int i = 3;
22612336
int ret;
2337+
int rate_limit_retries = http_max_retries;
2338+
2339+
if (!options)
2340+
options = &empty_opts;
22622341

22632342
if (always_auth_proactively())
22642343
credential_fill(the_repository, &http_auth, 1);
22652344

22662345
ret = http_request(url, result, target, options);
22672346

2268-
if (ret != HTTP_OK && ret != HTTP_REAUTH)
2347+
if (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)
22692348
return ret;
22702349

2271-
if (options && options->effective_url && options->base_url) {
2350+
/* If retries are disabled and we got a 429, fail immediately */
2351+
if (ret == HTTP_RATE_LIMITED && !http_max_retries)
2352+
return HTTP_ERROR;
2353+
2354+
if (options->effective_url && options->base_url) {
22722355
if (update_url_from_redirect(options->base_url,
22732356
url, options->effective_url)) {
22742357
credential_from_url(&http_auth, options->base_url->buf);
22752358
url = options->effective_url->buf;
22762359
}
22772360
}
22782361

2279-
while (ret == HTTP_REAUTH && --i) {
2362+
while ((ret == HTTP_REAUTH && --i) ||
2363+
(ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {
2364+
long retry_delay = -1;
22802365
/*
22812366
* The previous request may have put cruft into our output stream; we
22822367
* should clear it out before making our next request.
@@ -2301,19 +2386,36 @@ static int http_request_reauth(const char *url,
23012386
default:
23022387
BUG("Unknown http_request target");
23032388
}
2304-
2305-
credential_fill(the_repository, &http_auth, 1);
2389+
if (ret == HTTP_RATE_LIMITED) {
2390+
retry_delay = handle_rate_limit_retry(options->retry_after);
2391+
if (retry_delay < 0)
2392+
return HTTP_ERROR;
2393+
2394+
if (retry_delay > 0) {
2395+
warning(_("rate limited, waiting %ld seconds before retry"), retry_delay);
2396+
trace2_data_intmax("http", the_repository,
2397+
"http/retry-sleep-seconds", retry_delay);
2398+
sleep(retry_delay);
2399+
}
2400+
} else if (ret == HTTP_REAUTH) {
2401+
credential_fill(the_repository, &http_auth, 1);
2402+
}
23062403

23072404
ret = http_request(url, result, target, options);
23082405
}
2406+
if (ret == HTTP_RATE_LIMITED) {
2407+
trace2_data_string("http", the_repository,
2408+
"http/429-error", "retries-exhausted");
2409+
return HTTP_RATE_LIMITED;
2410+
}
23092411
return ret;
23102412
}
23112413

23122414
int http_get_strbuf(const char *url,
23132415
struct strbuf *result,
23142416
struct http_get_options *options)
23152417
{
2316-
return http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);
2418+
return http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);
23172419
}
23182420

23192421
/*
@@ -2337,7 +2439,7 @@ int http_get_file(const char *url, const char *filename,
23372439
goto cleanup;
23382440
}
23392441

2340-
ret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);
2442+
ret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);
23412443
fclose(result);
23422444

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

http.h

Lines changed: 9 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 {
@@ -157,6 +158,13 @@ struct http_get_options {
157158
* request has completed.
158159
*/
159160
struct string_list *extra_headers;
161+
162+
/*
163+
* After a request completes, contains the Retry-After delay in seconds
164+
* if the server returned HTTP 429 with a Retry-After header (requires
165+
* libcurl 7.66.0 or later), or -1 if no such header was present.
166+
*/
167+
long retry_after;
160168
};
161169

162170
/* Return values for http_get_*() */
@@ -167,6 +175,7 @@ struct http_get_options {
167175
#define HTTP_REAUTH 4
168176
#define HTTP_NOAUTH 5
169177
#define HTTP_NOMATCHPUBLICKEY 6
178+
#define HTTP_RATE_LIMITED 7
170179

171180
/*
172181
* Requests a URL and stores the result in a strbuf.

remote-curl.c

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,17 @@ static struct discovery *discover_refs(const char *service, int for_push)
529529
show_http_message(&type, &charset, &buffer);
530530
die(_("unable to access '%s' with http.pinnedPubkey configuration: %s"),
531531
transport_anonymize_url(url.buf), curl_errorstr);
532+
case HTTP_RATE_LIMITED:
533+
if (http_options.retry_after > 0) {
534+
show_http_message(&type, &charset, &buffer);
535+
die(_("rate limited by '%s', please try again in %ld seconds"),
536+
transport_anonymize_url(url.buf),
537+
http_options.retry_after);
538+
} else {
539+
show_http_message(&type, &charset, &buffer);
540+
die(_("rate limited by '%s', please try again later"),
541+
transport_anonymize_url(url.buf));
542+
}
532543
default:
533544
show_http_message(&type, &charset, &buffer);
534545
die(_("unable to access '%s': %s"),

t/lib-httpd.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ prepare_httpd() {
167167
install_script error.sh
168168
install_script apply-one-time-script.sh
169169
install_script nph-custom-auth.sh
170+
install_script http-429.sh
170171

171172
ln -s "$LIB_HTTPD_MODULE_PATH" "$HTTPD_ROOT_PATH/modules"
172173

0 commit comments

Comments
 (0)