From: "Ravi Mistry via GitGitGadget" <gitgitgadget@gmail.com>
To: git@vger.kernel.org
Cc: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>,
Patrick Steinhardt <ps@pks.im>,
"brian m. carlson" <sandals@crustytoothpaste.net>,
Ravi Mistry <rmistry@google.com>,
Ravi Mistry <rmistry@google.com>
Subject: [PATCH 1/4] http: fix retry count and Retry-After timing
Date: Sat, 31 Oct 2026 15:57:01 +0000 [thread overview]
Message-ID: <80b7f207f42cff0a3dfe23181ee315fdc90e50ff.1793462224.git.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2287.git.1793462224.gitgitgadget@gmail.com>
From: Ravi Mistry <rmistry@google.com>
Commit 640657ffd0 (http: add support for HTTP 429 rate limit retries,
2026-03-17) introduced two small bugs in HTTP 429 retry handling:
1. In http_request_recoverable(), rate_limit_retries is initialized to
http_max_retries (the number of retries to perform after the initial
failed request) and decremented with pre-decrement
(--rate_limit_retries) in the while condition. Because of the
pre-decrement, setting http.maxRetries=1 performs 0 retries, and
more generally N configured retries only retries N-1 times. In
addition, if the first request returns 401 (HTTP_REAUTH) and the
re-authenticated request returns 429 when http.maxRetries is 0,
--rate_limit_retries wraps from 0 to -1 (truthy in C).
Fix this by checking http_max_retries > 0 && rate_limit_retries-- > 0
in the loop condition and returning HTTP_ERROR when
http_max_retries <= 0.
2. In http_request(), CURLINFO_RETRY_AFTER is queried after
run_one_slot() has already called handle_curl_result(), which logs
results->retry_after to Trace2 ("http/429-retry-after") while it is
still -1. Move the CURLINFO_RETRY_AFTER extraction into
finish_active_slot() (initializing slot->results->retry_after = -1
first) so that both handle_curl_result() and callers of
run_one_slot() observe the parsed Retry-After value.
Signed-off-by: Ravi Mistry <rmistry@google.com>
---
http.c | 27 +++++++++++++--------------
t/lib-httpd/http-429.sh | 31 ++++++++++++++++++++++++++-----
t/t5584-http-429-retry.sh | 21 +++++++++++++++++++++
3 files changed, 60 insertions(+), 19 deletions(-)
diff --git a/http.c b/http.c
index d79678d01f..0308f973a6 100644
--- a/http.c
+++ b/http.c
@@ -345,11 +345,20 @@ static void finish_active_slot(struct active_request_slot *slot)
if (slot->results) {
slot->results->curl_result = slot->curl_result;
slot->results->http_code = slot->http_code;
+ slot->results->retry_after = -1;
curl_easy_getinfo(slot->curl, CURLINFO_HTTPAUTH_AVAIL,
&slot->results->auth_avail);
curl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,
&slot->results->http_connectcode);
+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER
+ {
+ curl_off_t retry_after;
+ if (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,
+ &retry_after) == CURLE_OK && retry_after > 0)
+ slot->results->retry_after = (long)retry_after;
+ }
+#endif
}
/* Run callback if appropriate */
@@ -2286,15 +2295,6 @@ static int http_request(const char *url,
ret = run_one_slot(slot, &results);
-#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER
- if (ret == HTTP_RATE_LIMITED) {
- curl_off_t retry_after;
- if (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,
- &retry_after) == CURLE_OK && retry_after > 0)
- results.retry_after = (long)retry_after;
- }
-#endif
-
options->retry_after = results.retry_after;
if (options->content_type) {
@@ -2461,10 +2461,6 @@ static int http_request_recoverable(const char *url,
if (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)
return ret;
- /* If retries are disabled and we got a 429, fail immediately */
- if (ret == HTTP_RATE_LIMITED && !http_max_retries)
- return HTTP_ERROR;
-
if (options->effective_url && options->base_url) {
if (update_url_from_redirect(options->base_url,
url, options->effective_url)) {
@@ -2474,7 +2470,8 @@ static int http_request_recoverable(const char *url,
}
while ((ret == HTTP_REAUTH && --i) ||
- (ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {
+ (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&
+ rate_limit_retries-- > 0)) {
long retry_delay = -1;
/*
* The previous request may have put cruft into our output stream; we
@@ -2518,6 +2515,8 @@ static int http_request_recoverable(const char *url,
ret = http_request(url, result, target, options);
}
if (ret == HTTP_RATE_LIMITED) {
+ if (http_max_retries <= 0)
+ return HTTP_ERROR;
trace2_data_string("http", the_repository,
"http/429-error", "retries-exhausted");
return HTTP_RATE_LIMITED;
diff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh
index 1a5d7987db..5537fb7ac4 100644
--- a/t/lib-httpd/http-429.sh
+++ b/t/lib-httpd/http-429.sh
@@ -32,12 +32,33 @@ repo_name="${repo_path%%/*}"
safe_name=$(echo "${test_context}-${retry_after}-${repo_name}" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')
state="http-429-state-${safe_name}"
-# Check if this is the first call (no state directory exists), or if
-# the retry-after-value is "permanent", which indicates a 429 must be
-# returned for every request (even if the state directory exists).
-if test "$retry_after" != permanent && ! mkdir "$state" 2>/dev/null
+max_fails=1
+case "$test_context" in
+ *-fail-2*) max_fails=2 ;;
+ *-fail-3*) max_fails=3 ;;
+esac
+
+should_fail=0
+if test "$retry_after" = permanent
+then
+ should_fail=1
+else
+ mkdir -p "$state" 2>/dev/null
+ i=1
+ while test $i -le $max_fails
+ do
+ if mkdir "$state/$i" 2>/dev/null
+ then
+ should_fail=1
+ break
+ fi
+ i=$((i + 1))
+ done
+fi
+
+if test $should_fail -eq 0
then
- # Already returned 429 once, forward to git-http-backend
+ # Already returned 429 max_fails times, forward to git-http-backend
# Set PATH_INFO to just the repo path (without retry-after value)
# Set GIT_PROJECT_ROOT so git-http-backend can find the repository
# Use exec to replace this process so git-http-backend gets the updated environment
diff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh
index a22007b2cf..d92a26893d 100755
--- a/t/t5584-http-429-retry.sh
+++ b/t/t5584-http-429-retry.sh
@@ -51,6 +51,27 @@ test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '
test_grep "refs/heads/" output
'
+test_expect_success 'http.maxRetries=1 retries once and succeeds' '
+ test_config http.maxRetries 1 &&
+ git ls-remote "$HTTPD_URL/http_429/max-retries-one/none/repo.git" >output 2>err &&
+ test_grep "refs/heads/" output
+'
+
+test_expect_success 'http.maxRetries=2 fails after 3 failures and succeeds on 2 failures' '
+ test_config http.maxRetries 2 &&
+ test_must_fail git ls-remote "$HTTPD_URL/http_429/max-retries-two-fail-3/none/repo.git" 2>err &&
+ test_grep "rate limited" err &&
+ git ls-remote "$HTTPD_URL/http_429/max-retries-two-fail-2/none/repo.git" >output 2>err &&
+ test_grep "refs/heads/" output
+'
+
+test_expect_success 'Trace2 logs actual CURLINFO_RETRY_AFTER value instead of -1' '
+ test_config http.maxRetries 1 &&
+ GIT_TRACE2_EVENT="$(pwd)/trace-retry-after.event" \
+ git ls-remote "$HTTPD_URL/http_429/trace2-retry-after/1/repo.git" >output 2>err &&
+ test_grep "\"key\":\"http/429-retry-after\",\"value\":\"1\"" trace-retry-after.event
+'
+
test_expect_success 'HTTP 429 without Retry-After uses configured default' '
# Enable retries and configure default delay
test_config http.maxRetries 3 &&
--
gitgitgadget
next prev parent reply other threads:[~2026-10-31 15:57 UTC|newest]
Thread overview: 5+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-31 15:57 [PATCH 0/4] http: exponential backoff, configurable status codes, and post_rpc() retries Ravi Mistry via GitGitGadget
2026-10-31 15:57 ` Ravi Mistry via GitGitGadget [this message]
2026-10-31 15:57 ` [PATCH 2/4] http: add exponential backoff without Retry-After Ravi Mistry via GitGitGadget
2026-10-31 15:57 ` [PATCH 3/4] http: add http.retryStatusCodes config option Ravi Mistry via GitGitGadget
2026-10-31 15:57 ` [PATCH 4/4] remote-curl: retry transient HTTP errors in post_rpc() Ravi Mistry via GitGitGadget
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=80b7f207f42cff0a3dfe23181ee315fdc90e50ff.1793462224.git.gitgitgadget@gmail.com \
--to=gitgitgadget@gmail.com \
--cc=git@vger.kernel.org \
--cc=ps@pks.im \
--cc=rmistry@google.com \
--cc=sandals@crustytoothpaste.net \
--cc=vaidas.pilkauskas@shopify.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox