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 4/4] remote-curl: retry transient HTTP errors in post_rpc()
Date: Sat, 31 Oct 2026 15:57:04 +0000 [thread overview]
Message-ID: <52819accc894e760a0fd06730d20f46cc8da9914.1793462224.git.gitgitgadget@gmail.com> (raw)
In-Reply-To: <pull.2287.git.1793462224.gitgitgadget@gmail.com>
From: Ravi Mistry <rmistry@google.com>
Git's existing retry logic only covers HTTP GET requests. However, the
actual data transfer during fetch and clone, as well as on-demand blob
downloads in partial clones, are sent as HTTP POST requests via
post_rpc(), which only retries authentication and immediately aborts on
429 or 5xx.
Extract the retry wait logic from http_request_recoverable() into a
shared http_retry_wait() helper and use it to retry transient HTTP
errors in probe_rpc() and post_rpc(). Also defer reporting RPC errors
until retries are exhausted, and re-attach the "Content-Encoding: gzip"
header when retrying a compressed request.
Signed-off-by: Ravi Mistry <rmistry@google.com>
---
http.c | 64 ++++++++++++++++++---------------
http.h | 6 ++++
remote-curl.c | 75 +++++++++++++++++++--------------------
t/lib-httpd/http-429.sh | 50 ++++++++++++++++++--------
t/t5584-http-429-retry.sh | 53 +++++++++++++++++++++++++++
5 files changed, 167 insertions(+), 81 deletions(-)
diff --git a/http.c b/http.c
index 950e2bbffa..36b559e596 100644
--- a/http.c
+++ b/http.c
@@ -2476,6 +2476,34 @@ static long handle_rate_limit_retry(long slot_retry_after, int attempt)
}
}
+int http_retry_wait(long http_code, long slot_retry_after, int attempt)
+{
+ long retry_delay;
+
+ if (http_max_retries <= 0)
+ return -1;
+ if (attempt > http_max_retries) {
+ trace2_data_string("http", the_repository,
+ "http/429-error", "retries-exhausted");
+ return -1;
+ }
+ retry_delay = handle_rate_limit_retry(slot_retry_after, attempt);
+ if (retry_delay < 0)
+ return -1;
+ if (retry_delay > 0) {
+ if (http_code == 429)
+ warning(_("rate limited, waiting %ld seconds before retry"),
+ retry_delay);
+ else
+ warning(_("HTTP %ld, waiting %ld seconds before retry"),
+ http_code, retry_delay);
+ trace2_data_intmax("http", the_repository,
+ "http/retry-sleep-seconds", retry_delay);
+ sleep(retry_delay);
+ }
+ return 0;
+}
+
static int http_request_recoverable(const char *url,
void *result, int target,
struct http_get_options *options)
@@ -2483,7 +2511,6 @@ static int http_request_recoverable(const char *url,
static struct http_get_options empty_opts;
int i = 3;
int ret;
- int rate_limit_retries = http_max_retries;
int attempt = 0;
if (!options)
@@ -2506,9 +2533,8 @@ static int http_request_recoverable(const char *url,
}
while ((ret == HTTP_REAUTH && --i) ||
- (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&
- rate_limit_retries-- > 0)) {
- long retry_delay = -1;
+ (ret == HTTP_RATE_LIMITED &&
+ !http_retry_wait(options->http_code, options->retry_after, ++attempt))) {
/*
* The previous request may have put cruft into our output stream; we
* should clear it out before making our next request.
@@ -2533,35 +2559,15 @@ static int http_request_recoverable(const char *url,
default:
BUG("Unknown http_request target");
}
- if (ret == HTTP_RATE_LIMITED) {
- retry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);
- if (retry_delay < 0)
- return HTTP_ERROR;
-
- if (retry_delay > 0) {
- if (options->http_code == 429)
- warning(_("rate limited, waiting %ld seconds before retry"),
- retry_delay);
- else
- warning(_("HTTP %ld, waiting %ld seconds before retry"),
- options->http_code, retry_delay);
- trace2_data_intmax("http", the_repository,
- "http/retry-sleep-seconds", retry_delay);
- sleep(retry_delay);
- }
- } else if (ret == HTTP_REAUTH) {
+ if (ret == HTTP_REAUTH)
http_reauth_prepare(1);
- }
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 options->http_code == 429 ? HTTP_RATE_LIMITED : HTTP_ERROR;
- }
+ if (ret == HTTP_RATE_LIMITED &&
+ (http_max_retries <= 0 || attempt <= http_max_retries ||
+ options->http_code != 429))
+ return HTTP_ERROR;
return ret;
}
diff --git a/http.h b/http.h
index 5985a0f71f..fa9f46a11b 100644
--- a/http.h
+++ b/http.h
@@ -201,6 +201,12 @@ int http_get_strbuf(const char *url, struct strbuf *result, struct http_get_opti
int http_get_file(const char *url, const char *filename,
struct http_get_options *options);
+/*
+ * Wait before retrying a transient HTTP error (1-based attempt count).
+ * Returns 0 if the caller should retry, or -1 to stop retrying.
+ */
+int http_retry_wait(long http_code, long slot_retry_after, int attempt);
+
int http_fetch_ref(const char *base, struct ref *ref);
struct curl_slist *http_append_auth_header(const struct credential *c,
diff --git a/remote-curl.c b/remote-curl.c
index 2c35dd5240..d73aaebadb 100644
--- a/remote-curl.c
+++ b/remote-curl.c
@@ -845,35 +845,22 @@ static size_t rpc_in(char *ptr, size_t eltsize,
return size;
}
-static int run_slot(struct active_request_slot *slot,
- struct slot_results *results)
+static void report_rpc_error(struct slot_results *results)
{
- int err;
- struct slot_results results_buf;
-
- if (!results)
- results = &results_buf;
-
- err = run_one_slot(slot, results);
-
- if (err != HTTP_OK && err != HTTP_REAUTH) {
- struct strbuf msg = STRBUF_INIT;
- if (results->http_code && results->http_code != 200)
- strbuf_addf(&msg, "HTTP %ld", results->http_code);
- if (results->curl_result != CURLE_OK) {
- if (msg.len)
- strbuf_addch(&msg, ' ');
- strbuf_addf(&msg, "curl %d", results->curl_result);
- if (curl_errorstr[0]) {
- strbuf_addch(&msg, ' ');
- strbuf_addstr(&msg, curl_errorstr);
- }
+ struct strbuf msg = STRBUF_INIT;
+ if (results->http_code && results->http_code != 200)
+ strbuf_addf(&msg, "HTTP %ld", results->http_code);
+ if (results->curl_result != CURLE_OK) {
+ if (msg.len)
+ strbuf_addch(&msg, ' ');
+ strbuf_addf(&msg, "curl %d", results->curl_result);
+ if (curl_errorstr[0]) {
+ strbuf_addch(&msg, ' ');
+ strbuf_addstr(&msg, curl_errorstr);
}
- error(_("RPC failed; %s"), msg.buf);
- strbuf_release(&msg);
}
-
- return err;
+ error(_("RPC failed; %s"), msg.buf);
+ strbuf_release(&msg);
}
static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)
@@ -899,7 +886,7 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)
curl_easy_setopt(slot->curl, CURLOPT_WRITEFUNCTION, fwrite_buffer);
curl_easy_setopt(slot->curl, CURLOPT_WRITEDATA, &buf);
- err = run_slot(slot, results);
+ err = run_one_slot(slot, results);
curl_slist_free_all(headers);
strbuf_release(&buf);
@@ -913,12 +900,14 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)
static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_received)
{
struct active_request_slot *slot;
+ struct slot_results results = { 0 };
struct curl_slist *headers = NULL;
int use_gzip = rpc->gzip_request;
char *gzip_body = NULL;
size_t gzip_size = 0;
int err, large_request = 0;
int needs_100_continue = 0;
+ int attempt = 0;
struct rpc_in_data rpc_in_data;
/* Try to load the entire request, if we can fit it into the
@@ -941,15 +930,18 @@ static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_rece
}
if (large_request) {
- struct slot_results results;
-
do {
err = probe_rpc(rpc, &results);
if (err == HTTP_REAUTH)
http_reauth_prepare(0);
- } while (err == HTTP_REAUTH);
- if (err != HTTP_OK)
+ } while (err == HTTP_REAUTH ||
+ (err == HTTP_RATE_LIMITED &&
+ !http_retry_wait(results.http_code,
+ results.retry_after, ++attempt)));
+ if (err != HTTP_OK) {
+ report_rpc_error(&results);
return -1;
+ }
if (results.auth_avail & CURLAUTH_GSSNEGOTIATE || http_auth.authtype)
needs_100_continue = 1;
@@ -998,10 +990,11 @@ retry:
} else if (gzip_body) {
/*
- * If we are looping to retry authentication, then the previous
- * run will have set up the headers and gzip buffer already,
- * and we just need to send it.
+ * If we are looping to retry, then the previous run will
+ * have set up the gzip buffer already, and we just need to
+ * send it.
*/
+ headers = curl_slist_append(headers, "Content-Encoding: gzip");
curl_easy_setopt(slot->curl, CURLOPT_POSTFIELDS, gzip_body);
curl_easy_setopt(slot->curl, CURLOPT_POSTFIELDSIZE_LARGE, cast_size_t_to_curl_off_t(gzip_size));
@@ -1066,14 +1059,20 @@ retry:
rpc->any_written = 0;
- err = run_slot(slot, NULL);
- if (err == HTTP_REAUTH && !large_request) {
- http_reauth_prepare(0);
+ err = run_one_slot(slot, &results);
+ if (!large_request && !rpc->any_written &&
+ (err == HTTP_REAUTH ||
+ (err == HTTP_RATE_LIMITED &&
+ !http_retry_wait(results.http_code, results.retry_after, ++attempt)))) {
+ if (err == HTTP_REAUTH)
+ http_reauth_prepare(0);
curl_slist_free_all(headers);
goto retry;
}
- if (err != HTTP_OK)
+ if (err != HTTP_OK) {
+ report_rpc_error(&results);
err = -1;
+ }
if (!rpc->any_written)
err = -1;
diff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh
index 5c7d58a75f..ec70a4e9d1 100644
--- a/t/lib-httpd/http-429.sh
+++ b/t/lib-httpd/http-429.sh
@@ -39,21 +39,36 @@ case "$test_context" in
esac
should_fail=0
-if test "$retry_after" = permanent
+case "$test_context,$REQUEST_METHOD" in
+ *-post-only*,POST)
+ can_fail=1
+ ;;
+ *-post-only*,*)
+ can_fail=0
+ ;;
+ *)
+ can_fail=1
+ ;;
+esac
+
+if test $can_fail -eq 1
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
+ 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
fi
if test $should_fail -eq 0
@@ -75,6 +90,13 @@ then
exec "$GIT_EXEC_PATH/git-http-backend"
fi
+# Drain POST request body before returning synthetic error so Apache mod_cgi
+# does not reset the TCP connection.
+if test "$REQUEST_METHOD" = POST
+then
+ cat >/dev/null
+fi
+
# Output HTTP 429 (or configured status) response
status_line="429 Too Many Requests"
case "$test_context" in
diff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh
index f3e7dbaaa4..e627aa2b63 100755
--- a/t/t5584-http-429-retry.sh
+++ b/t/t5584-http-429-retry.sh
@@ -365,4 +365,57 @@ test_expect_success 'GIT_HTTP_RETRY_STATUS_CODES and urlmatch http.<url>.retrySt
test_grep "refs/heads/" output
'
+test_expect_success 'Protocol v2 POST /git-upload-pack retries 429 and 502' '
+ test_when_finished "rm -rf clone-v2-429 clone-v2-502 clone-v2-fail" &&
+ test_config_global protocol.version 2 &&
+ test_config_global http.maxRetries 3 &&
+ test_config_global http.retryAfter 1 &&
+ test_config_global http.retryStatusCodes "429,502" &&
+ git clone "$HTTPD_URL/http_429/v2-post-only-429/1/repo.git" clone-v2-429 2>err &&
+ test_grep "rate limited, waiting 1 seconds before retry" err &&
+ test_grep ! "RPC failed" err &&
+ git clone "$HTTPD_URL/http_429/v2-post-only-status-502/1/repo.git" clone-v2-502 2>err &&
+ test_grep "HTTP 502, waiting 1 seconds before retry" err &&
+ test_grep ! "RPC failed" err &&
+ test_config_global http.maxRetries 1 &&
+ test_config_global http.retryAfter 0 &&
+ test_must_fail git clone \
+ "$HTTPD_URL/http_429/v2-post-only-fail-2-status-502/none/repo.git" \
+ clone-v2-fail 2>err &&
+ test_grep "RPC failed; HTTP 502" err
+'
+
+test_expect_success 'partial clone lazy blob fetch (promisor-remote) retries transient 502' '
+ test_when_finished "rm -rf partial" &&
+ test_config -C "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" uploadpack.allowfilter 1 &&
+ test_config -C "$HTTPD_DOCUMENT_ROOT_PATH/repo.git" uploadpack.allowanysha1inwant 1 &&
+ git clone --filter=blob:none --no-checkout "$HTTPD_URL/smart/repo.git" partial &&
+ git -C partial config remote.origin.url \
+ "$HTTPD_URL/http_429/promisor-post-only-status-502/1/repo.git" &&
+ git -C partial config http.maxRetries 3 &&
+ git -C partial config http.retryAfter 1 &&
+ git -C partial config http.retryStatusCodes "429,502" &&
+ git -C partial checkout HEAD 2>err &&
+ test_grep "HTTP 502, waiting 1 seconds before retry" err &&
+ test_grep ! "RPC failed" err
+'
+
+test_expect_success 'gzipped POST /git-upload-pack preserves Content-Encoding: gzip on retry' '
+ test_when_finished "rm -f curl.log gzip.headers" &&
+ test_config protocol.version 2 &&
+ test_config http.maxRetries 3 &&
+ test_config http.retryAfter 1 &&
+ set -- &&
+ for i in $(test_seq 30)
+ do
+ set -- "$@" -o "very-long-server-option-to-exceed-1024-bytes-$i" || return 1
+ done &&
+ GIT_TRACE_CURL="$(pwd)/curl.log" git ls-remote "$@" \
+ "$HTTPD_URL/http_429/gzip-post-only-429/1/repo.git" >output 2>err &&
+ test_grep "refs/heads/" output &&
+ test_grep "rate limited, waiting 1 seconds before retry" err &&
+ grep "Content-Encoding: gzip" curl.log >gzip.headers &&
+ test_line_count = 2 gzip.headers
+'
+
test_done
--
gitgitgadget
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 ` [PATCH 1/4] http: fix retry count and Retry-After timing Ravi Mistry via GitGitGadget
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 ` Ravi Mistry via GitGitGadget [this message]
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=52819accc894e760a0fd06730d20f46cc8da9914.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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.