Skip to content

Commit ae0087c

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: Maximum number of retry attempts (default: 0, meaning retries are disabled by default). Users must explicitly opt-in to retry behavior. * http.retryAfter: Default delay in seconds when the server doesn't provide a Retry-After header (default: -1, meaning fail if no header is provided). This serves as a fallback mechanism. * http.maxRetryTime: Maximum delay in seconds for a single retry (default: 300). If the server requests a delay exceeding this limit, Git fails immediately rather than waiting. This prevents indefinite blocking on unreasonable server requests. All three options can be overridden via environment variables: GIT_HTTP_MAX_RETRIES, GIT_HTTP_RETRY_AFTER, and GIT_HTTP_MAX_RETRY_TIME. 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 6ab38b7 commit ae0087c

File tree

8 files changed

+618
-4
lines changed

8 files changed

+618
-4
lines changed

Documentation/config/http.adoc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,30 @@ 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. If set
321+
to -1 (the default), Git will fail immediately when encountering
322+
a 429 response without a Retry-After header. When a Retry-After
323+
header is present, its value takes precedence over this setting.
324+
Can be overridden by the `GIT_HTTP_RETRY_AFTER` environment variable.
325+
See also `http.maxRetries` and `http.maxRetryTime`.
326+
327+
http.maxRetries::
328+
Maximum number of times to retry after receiving HTTP 429 (Too Many
329+
Requests) responses. Set to 0 (the default) to disable retries.
330+
Can be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.
331+
See also `http.retryAfter` and `http.maxRetryTime`.
332+
333+
http.maxRetryTime::
334+
Maximum time in seconds to wait for a single retry attempt when
335+
handling HTTP 429 (Too Many Requests) responses. If the server
336+
requests a delay (via Retry-After header) or if `http.retryAfter`
337+
is configured with a value that exceeds this maximum, Git will fail
338+
immediately rather than waiting. Default is 300 seconds (5 minutes).
339+
Can be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment
340+
variable. See also `http.retryAfter` and `http.maxRetries`.
341+
318342
http.noEPSV::
319343
A boolean which disables using of EPSV ftp command by curl.
320344
This can be helpful with some "poor" ftp servers which don't

http-push.c

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,10 @@ static int fetch_indices(void)
716716
case HTTP_MISSING_TARGET:
717717
ret = 0;
718718
break;
719+
case HTTP_RATE_LIMITED:
720+
error("rate limited by '%s', please try again later", repo->url);
721+
ret = -1;
722+
break;
719723
default:
720724
ret = -1;
721725
}
@@ -1548,6 +1552,10 @@ static int remote_exists(const char *path)
15481552
case HTTP_MISSING_TARGET:
15491553
ret = 0;
15501554
break;
1555+
case HTTP_RATE_LIMITED:
1556+
error("rate limited by '%s', please try again later", url);
1557+
ret = -1;
1558+
break;
15511559
case HTTP_ERROR:
15521560
error("unable to access '%s': %s", url, curl_errorstr);
15531561
/* fallthrough */

http-walker.c

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,11 @@ static int fetch_indices(struct walker *walker, struct alt_base *repo)
414414
repo->got_indices = 1;
415415
ret = 0;
416416
break;
417+
case HTTP_RATE_LIMITED:
418+
error("rate limited by '%s', please try again later", repo->base);
419+
repo->got_indices = 0;
420+
ret = -1;
421+
break;
417422
default:
418423
repo->got_indices = 0;
419424
ret = -1;

http.c

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "object-file.h"
2323
#include "odb.h"
2424
#include "tempfile.h"
25+
#include "date.h"
2526

2627
static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);
2728
static int trace_curl_data = 1;
@@ -149,6 +150,14 @@ static char *cached_accept_language;
149150
static char *http_ssl_backend;
150151

151152
static int http_schannel_check_revoke = 1;
153+
154+
/* Retry configuration */
155+
static long http_retry_after = -1; /* Default retry-after in seconds when header is missing (-1 means not set, exit with 128) */
156+
static long http_max_retries = 0; /* Maximum number of retry attempts (0 means retries are disabled) */
157+
static long http_max_retry_time = 300; /* Maximum time to wait for a single retry (default 5 minutes) */
158+
159+
/* Store retry_after value from 429 responses for retry logic (-1 = not set, 0 = retry immediately, >0 = delay in seconds) */
160+
static long last_retry_after = -1;
152161
/*
153162
* With the backend being set to `schannel`, setting sslCAinfo would override
154163
* the Certificate Store in cURL v7.60.0 and later, which is not what we want
@@ -209,13 +218,14 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)
209218
return size && (*ptr == ' ' || *ptr == '\t');
210219
}
211220

212-
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)
221+
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p)
213222
{
214223
size_t size = eltsize * nmemb;
215224
struct strvec *values = &http_auth.wwwauth_headers;
216225
struct strbuf buf = STRBUF_INIT;
217226
const char *val;
218227
size_t val_len;
228+
struct active_request_slot *slot = (struct active_request_slot *)p;
219229

220230
/*
221231
* Header lines may not come NULL-terminated from libcurl so we must
@@ -257,6 +267,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN
257267
goto exit;
258268
}
259269

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

629+
if (!strcmp("http.retryafter", var)) {
630+
http_retry_after = git_config_int(var, value, ctx->kvi);
631+
return 0;
632+
}
633+
634+
if (!strcmp("http.maxretries", var)) {
635+
http_max_retries = git_config_int(var, value, ctx->kvi);
636+
return 0;
637+
}
638+
639+
if (!strcmp("http.maxretrytime", var)) {
640+
http_max_retry_time = git_config_int(var, value, ctx->kvi);
641+
return 0;
642+
}
643+
578644
/* Fall back on the default ones */
579645
return git_default_config(var, value, ctx, data);
580646
}
@@ -1422,6 +1488,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)
14221488
set_long_from_env(&curl_tcp_keepintvl, "GIT_TCP_KEEPINTVL");
14231489
set_long_from_env(&curl_tcp_keepcnt, "GIT_TCP_KEEPCNT");
14241490

1491+
set_long_from_env(&http_retry_after, "GIT_HTTP_RETRY_AFTER");
1492+
set_long_from_env(&http_max_retries, "GIT_HTTP_MAX_RETRIES");
1493+
set_long_from_env(&http_max_retry_time, "GIT_HTTP_MAX_RETRY_TIME");
1494+
14251495
curl_default = get_curl_handle();
14261496
}
14271497

@@ -1871,6 +1941,10 @@ static int handle_curl_result(struct slot_results *results)
18711941
}
18721942
return HTTP_REAUTH;
18731943
}
1944+
} else if (results->http_code == 429) {
1945+
/* Store the retry_after value for use in retry logic */
1946+
last_retry_after = results->retry_after;
1947+
return HTTP_RATE_LIMITED;
18741948
} else {
18751949
if (results->http_connectcode == 407)
18761950
credential_reject(the_repository, &proxy_auth);
@@ -1886,6 +1960,8 @@ int run_one_slot(struct active_request_slot *slot,
18861960
struct slot_results *results)
18871961
{
18881962
slot->results = results;
1963+
/* Initialize retry_after to -1 (not set) */
1964+
results->retry_after = -1;
18891965
if (!start_active_slot(slot)) {
18901966
xsnprintf(curl_errorstr, sizeof(curl_errorstr),
18911967
"failed to start HTTP request");
@@ -2149,6 +2225,7 @@ static int http_request(const char *url,
21492225
}
21502226

21512227
curl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);
2228+
curl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);
21522229

21532230
accept_language = http_get_accept_language_header();
21542231

@@ -2253,19 +2330,36 @@ static int update_url_from_redirect(struct strbuf *base,
22532330
return 1;
22542331
}
22552332

2333+
/*
2334+
* Sleep for the specified number of seconds before retrying.
2335+
*/
2336+
static void sleep_for_retry(long retry_after)
2337+
{
2338+
if (retry_after > 0) {
2339+
unsigned int remaining;
2340+
warning(_("rate limited, waiting %ld seconds before retry"), retry_after);
2341+
remaining = sleep(retry_after);
2342+
while (remaining > 0) {
2343+
/* Sleep was interrupted, continue sleeping */
2344+
remaining = sleep(remaining);
2345+
}
2346+
}
2347+
}
2348+
22562349
static int http_request_reauth(const char *url,
22572350
void *result, int target,
22582351
struct http_get_options *options)
22592352
{
22602353
int i = 3;
22612354
int ret;
2355+
int rate_limit_retries = http_max_retries;
22622356

22632357
if (always_auth_proactively())
22642358
credential_fill(the_repository, &http_auth, 1);
22652359

22662360
ret = http_request(url, result, target, options);
22672361

2268-
if (ret != HTTP_OK && ret != HTTP_REAUTH)
2362+
if (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)
22692363
return ret;
22702364

22712365
if (options && options->effective_url && options->base_url) {
@@ -2276,7 +2370,7 @@ static int http_request_reauth(const char *url,
22762370
}
22772371
}
22782372

2279-
while (ret == HTTP_REAUTH && --i) {
2373+
while ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {
22802374
/*
22812375
* The previous request may have put cruft into our output stream; we
22822376
* should clear it out before making our next request.
@@ -2302,7 +2396,54 @@ static int http_request_reauth(const char *url,
23022396
BUG("Unknown http_request target");
23032397
}
23042398

2305-
credential_fill(the_repository, &http_auth, 1);
2399+
if (ret == HTTP_RATE_LIMITED) {
2400+
/* Handle rate limiting with retry logic */
2401+
int retry_attempt = http_max_retries - rate_limit_retries + 1;
2402+
2403+
if (rate_limit_retries <= 0) {
2404+
/* Retries are disabled or exhausted */
2405+
if (http_max_retries > 0) {
2406+
error(_("too many rate limit retries, giving up"));
2407+
}
2408+
return HTTP_ERROR;
2409+
}
2410+
2411+
/* Decrement retries counter */
2412+
rate_limit_retries--;
2413+
2414+
/* Use the stored retry_after value or configured default */
2415+
if (last_retry_after >= 0) {
2416+
/* Check if retry delay exceeds maximum allowed */
2417+
if (last_retry_after > http_max_retry_time) {
2418+
error(_("rate limited (HTTP 429) requested %ld second delay, "
2419+
"exceeds http.maxRetryTime of %ld seconds"),
2420+
last_retry_after, http_max_retry_time);
2421+
last_retry_after = -1; /* Reset after use */
2422+
return HTTP_ERROR;
2423+
}
2424+
sleep_for_retry(last_retry_after);
2425+
last_retry_after = -1; /* Reset after use */
2426+
} else {
2427+
/* No Retry-After header provided */
2428+
if (http_retry_after < 0) {
2429+
/* Not configured - exit with error */
2430+
error(_("rate limited (HTTP 429) and no Retry-After header provided. "
2431+
"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER."));
2432+
return HTTP_ERROR;
2433+
}
2434+
/* Check if configured default exceeds maximum allowed */
2435+
if (http_retry_after > http_max_retry_time) {
2436+
error(_("configured http.retryAfter (%ld seconds) exceeds "
2437+
"http.maxRetryTime (%ld seconds)"),
2438+
http_retry_after, http_max_retry_time);
2439+
return HTTP_ERROR;
2440+
}
2441+
/* Use configured default retry-after value */
2442+
sleep_for_retry(http_retry_after);
2443+
}
2444+
} else if (ret == HTTP_REAUTH) {
2445+
credential_fill(the_repository, &http_auth, 1);
2446+
}
23062447

23072448
ret = http_request(url, result, target, options);
23082449
}

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.

remote-curl.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,10 @@ 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+
show_http_message(&type, &charset, &buffer);
534+
die(_("rate limited by '%s', please try again later"),
535+
transport_anonymize_url(url.buf));
532536
default:
533537
show_http_message(&type, &charset, &buffer);
534538
die(_("unable to access '%s': %s"),

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ integration_tests = [
698698
't5581-http-curl-verbose.sh',
699699
't5582-fetch-negative-refspec.sh',
700700
't5583-push-branches.sh',
701+
't5584-http-429-retry.sh',
701702
't5600-clone-fail-cleanup.sh',
702703
't5601-clone.sh',
703704
't5602-clone-remote-exec.sh',

0 commit comments

Comments
 (0)