Skip to content

Commit 4c33b82

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. Trace2 logging has been added to track retry attempts, delays, and error conditions. This enables monitoring and debugging of rate limit scenarios in production environments. 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 <[email protected]>
1 parent 6ab38b7 commit 4c33b82

File tree

8 files changed

+640
-4
lines changed

8 files changed

+640
-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: 167 additions & 4 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,14 @@ static char *cached_accept_language;
149151
static char *http_ssl_backend;
150152

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

212-
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)
222+
static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p)
213223
{
214224
size_t size = eltsize * nmemb;
215225
struct strvec *values = &http_auth.wwwauth_headers;
216226
struct strbuf buf = STRBUF_INIT;
217227
const char *val;
218228
size_t val_len;
229+
struct active_request_slot *slot = (struct active_request_slot *)p;
219230

220231
/*
221232
* Header lines may not come NULL-terminated from libcurl so we must
@@ -257,6 +268,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN
257268
goto exit;
258269
}
259270

271+
/* Parse Retry-After header for rate limiting */
272+
if (skip_iprefix_mem(ptr, size, "retry-after:", &val, &val_len)) {
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+
http_auth.header_is_last_match = 1;
309+
goto exit;
310+
}
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
@@ -575,6 +627,21 @@ static int http_options(const char *var, const char *value,
575627
return 0;
576628
}
577629

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

1492+
set_long_from_env(&http_retry_after, "GIT_HTTP_RETRY_AFTER");
1493+
set_long_from_env(&http_max_retries, "GIT_HTTP_MAX_RETRIES");
1494+
set_long_from_env(&http_max_retry_time, "GIT_HTTP_MAX_RETRY_TIME");
1495+
14251496
curl_default = get_curl_handle();
14261497
}
14271498

@@ -1871,6 +1942,12 @@ static int handle_curl_result(struct slot_results *results)
18711942
}
18721943
return HTTP_REAUTH;
18731944
}
1945+
} else if (results->http_code == 429) {
1946+
/* Store the retry_after value for use in retry logic */
1947+
last_retry_after = results->retry_after;
1948+
trace2_data_intmax("http", the_repository, "http/429-retry-after",
1949+
last_retry_after);
1950+
return HTTP_RATE_LIMITED;
18741951
} else {
18751952
if (results->http_connectcode == 407)
18761953
credential_reject(the_repository, &proxy_auth);
@@ -1886,6 +1963,8 @@ int run_one_slot(struct active_request_slot *slot,
18861963
struct slot_results *results)
18871964
{
18881965
slot->results = results;
1966+
/* Initialize retry_after to -1 (not set) */
1967+
results->retry_after = -1;
18891968
if (!start_active_slot(slot)) {
18901969
xsnprintf(curl_errorstr, sizeof(curl_errorstr),
18911970
"failed to start HTTP request");
@@ -2149,6 +2228,7 @@ static int http_request(const char *url,
21492228
}
21502229

21512230
curl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);
2231+
curl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);
21522232

21532233
accept_language = http_get_accept_language_header();
21542234

@@ -2253,19 +2333,40 @@ static int update_url_from_redirect(struct strbuf *base,
22532333
return 1;
22542334
}
22552335

2336+
/*
2337+
* Sleep for the specified number of seconds before retrying.
2338+
*/
2339+
static void sleep_for_retry(long retry_after)
2340+
{
2341+
if (retry_after > 0) {
2342+
unsigned int remaining;
2343+
warning(_("rate limited, waiting %ld seconds before retry"), retry_after);
2344+
trace2_region_enter("http", "retry-sleep", the_repository);
2345+
trace2_data_intmax("http", the_repository, "http/retry-sleep-seconds",
2346+
retry_after);
2347+
remaining = sleep(retry_after);
2348+
while (remaining > 0) {
2349+
/* Sleep was interrupted, continue sleeping */
2350+
remaining = sleep(remaining);
2351+
}
2352+
trace2_region_leave("http", "retry-sleep", the_repository);
2353+
}
2354+
}
2355+
22562356
static int http_request_reauth(const char *url,
22572357
void *result, int target,
22582358
struct http_get_options *options)
22592359
{
22602360
int i = 3;
22612361
int ret;
2362+
int rate_limit_retries = http_max_retries;
22622363

22632364
if (always_auth_proactively())
22642365
credential_fill(the_repository, &http_auth, 1);
22652366

22662367
ret = http_request(url, result, target, options);
22672368

2268-
if (ret != HTTP_OK && ret != HTTP_REAUTH)
2369+
if (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)
22692370
return ret;
22702371

22712372
if (options && options->effective_url && options->base_url) {
@@ -2276,7 +2377,7 @@ static int http_request_reauth(const char *url,
22762377
}
22772378
}
22782379

2279-
while (ret == HTTP_REAUTH && --i) {
2380+
while ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {
22802381
/*
22812382
* The previous request may have put cruft into our output stream; we
22822383
* should clear it out before making our next request.
@@ -2302,7 +2403,69 @@ static int http_request_reauth(const char *url,
23022403
BUG("Unknown http_request target");
23032404
}
23042405

2305-
credential_fill(the_repository, &http_auth, 1);
2406+
if (ret == HTTP_RATE_LIMITED) {
2407+
/* Handle rate limiting with retry logic */
2408+
int retry_attempt = http_max_retries - rate_limit_retries + 1;
2409+
2410+
trace2_data_intmax("http", the_repository, "http/429-retry-attempt",
2411+
retry_attempt);
2412+
2413+
if (rate_limit_retries <= 0) {
2414+
/* Retries are disabled or exhausted */
2415+
if (http_max_retries > 0) {
2416+
error(_("too many rate limit retries, giving up"));
2417+
trace2_data_string("http", the_repository,
2418+
"http/429-error", "retries-exhausted");
2419+
}
2420+
return HTTP_ERROR;
2421+
}
2422+
2423+
/* Decrement retries counter */
2424+
rate_limit_retries--;
2425+
2426+
/* Use the stored retry_after value or configured default */
2427+
if (last_retry_after >= 0) {
2428+
/* Check if retry delay exceeds maximum allowed */
2429+
if (last_retry_after > http_max_retry_time) {
2430+
error(_("rate limited (HTTP 429) requested %ld second delay, "
2431+
"exceeds http.maxRetryTime of %ld seconds"),
2432+
last_retry_after, http_max_retry_time);
2433+
trace2_data_string("http", the_repository,
2434+
"http/429-error", "exceeds-max-retry-time");
2435+
trace2_data_intmax("http", the_repository,
2436+
"http/429-requested-delay", last_retry_after);
2437+
last_retry_after = -1; /* Reset after use */
2438+
return HTTP_ERROR;
2439+
}
2440+
sleep_for_retry(last_retry_after);
2441+
last_retry_after = -1; /* Reset after use */
2442+
} else {
2443+
/* No Retry-After header provided */
2444+
if (http_retry_after < 0) {
2445+
/* Not configured - exit with error */
2446+
error(_("rate limited (HTTP 429) and no Retry-After header provided. "
2447+
"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER."));
2448+
trace2_data_string("http", the_repository,
2449+
"http/429-error", "no-retry-after-config");
2450+
return HTTP_ERROR;
2451+
}
2452+
/* Check if configured default exceeds maximum allowed */
2453+
if (http_retry_after > http_max_retry_time) {
2454+
error(_("configured http.retryAfter (%ld seconds) exceeds "
2455+
"http.maxRetryTime (%ld seconds)"),
2456+
http_retry_after, http_max_retry_time);
2457+
trace2_data_string("http", the_repository,
2458+
"http/429-error", "config-exceeds-max-retry-time");
2459+
return HTTP_ERROR;
2460+
}
2461+
/* Use configured default retry-after value */
2462+
trace2_data_string("http", the_repository,
2463+
"http/429-retry-source", "config-default");
2464+
sleep_for_retry(http_retry_after);
2465+
}
2466+
} else if (ret == HTTP_REAUTH) {
2467+
credential_fill(the_repository, &http_auth, 1);
2468+
}
23062469

23072470
ret = http_request(url, result, target, options);
23082471
}

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)