{"thread":{"id":"66729","subject":"[PATCH 0/4] http: exponential backoff, configurable status codes, and post_rpc() retries","startedAt":"2026-10-31T15:57:00Z","lastAt":"2026-11-05T00:10:10Z","messageCount":20,"participants":["Ravi Mistry via GitGitGadget","Junio C Hamano","Ravi Mistry","brian m. carlson"],"isPatch":true,"patchVersion":1,"patchTotal":4},"messages":[{"id":"557026","messageId":"pull.2287.git.1793462224.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":null,"subject":"[PATCH 0/4] http: exponential backoff, configurable status codes, and post_rpc() retries","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-31T15:57:00Z","receivedAt":"2026-10-31T15:57:00Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"Commit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n2026-03-17) added HTTP 429 retry support using http.maxRetries,\nhttp.retryAfter, and http.maxRetryTime.\n\nIn practice, when running Git against hosting backends and reverse proxies\nunder high concurrency, four gaps remain:\n\n 1. Off-by-one retry count and Retry-After Trace2 timing: Because\n    http_request_recoverable() pre-decrements the retry counter\n    (--rate_limit_retries) in its loop condition, setting http.maxRetries=1\n    performs 0 retries (and N configured retries performs N-1 retries). In\n    addition, the Retry-After header is extracted after Trace2 logging has\n    already run, so the http/429-retry-after Trace2 event always logs -1.\n\n 2. No exponential backoff when Retry-After is absent: When a server does\n    not send a Retry-After header, Git waits the same fixed http.retryAfter\n    delay on every retry. If that delay is short (e.g., 1s), Git burns\n    through all of its retries in just a few seconds before a busy server\n    can recover. If that delay is long, even a brief glitch has to wait a\n    long time on the first retry. Doubling the delay on each attempt (1s,\n    2s, 4s, ...) keeps the first retry fast while giving the server more\n    time to recover on later attempts, matching curl's own --retry behavior.\n\n 3. Transient 5xx errors cannot be retried: Reverse proxies, load balancers,\n    and Git backends frequently surface transient overload or connection\n    resets as 502 Bad Gateway, 503 Service Unavailable, or 504 Gateway\n    Timeout. Because 5xx errors are not universally transient across all\n    environments, 429 should remain the default, with an opt-in config to\n    specify additional status codes.\n\n 4. HTTP POST requests are not retried: Git's existing 429 retry logic only\n    covers HTTP GET requests (such as the initial /info/refs handshake).\n    However, the actual data transfer during fetch and clone, as well as\n    on-demand blob downloads in partial clones (during checkout, diff, or\n    blame), are sent as HTTP POST requests (/git-upload-pack) via\n    post_rpc(), which only retries authentication and immediately aborts on\n    429 or 5xx.\n\nThis 4-patch series addresses all four gaps:\n\n * Patch 1/4 fixes the off-by-one retry count bug and extracts Retry-After\n   in finish_active_slot() before Trace2 logging runs.\n * Patch 2/4 doubles http.retryAfter on each subsequent retry attempt (1s,\n   2s, 4s, capped at http.maxRetryTime) when Retry-After is absent.\n * Patch 3/4 adds http.retryStatusCodes (and GIT_HTTP_RETRY_STATUS_CODES),\n   defaulting to \"429\", so environments can opt into retrying codes like\n   \"429,502,503,504\".\n * Patch 4/4 extracts a shared http_retry_wait() helper and extends retry\n   support to HTTP POST requests in probe_rpc() and post_rpc() (while fixing\n   a missing Content-Encoding: gzip header on retry).\n\nPrior discussions:\n\n * https://lore.kernel.org/git/20201012184806.166251-3-smcallis@google.com/\n * https://lore.kernel.org/git/pull.2008.v6.git.1773752435.gitgitgadget@gmail.com/\n\nRavi Mistry (4):\n  http: fix retry count and Retry-After timing\n  http: add exponential backoff without Retry-After\n  http: add http.retryStatusCodes config option\n  remote-curl: retry transient HTTP errors in post_rpc()\n\n Documentation/config/http.adoc |  42 ++++++---\n http.c                         | 122 ++++++++++++++++++--------\n http.h                         |  12 ++-\n remote-curl.c                  |  75 ++++++++--------\n t/lib-httpd/http-429.sh        |  63 ++++++++++++--\n t/t5584-http-429-retry.sh      | 155 +++++++++++++++++++++++++++++++++\n 6 files changed, 373 insertions(+), 96 deletions(-)\n\n\nbase-commit: 8d1b215c01512b9658d4525c54ff9b9245f9a7e8\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2287%2Frmistry%2Fhttp-exponential-retries-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2287/rmistry/http-exponential-retries-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2287\n-- \ngitgitgadget\n\n"},{"id":"557027","messageId":"80b7f207f42cff0a3dfe23181ee315fdc90e50ff.1793462224.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.git.1793462224.gitgitgadget@gmail.com","subject":"[PATCH 1/4] http: fix retry count and Retry-After timing","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-31T15:57:01Z","receivedAt":"2026-10-31T15:57:01Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nCommit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n2026-03-17) introduced two small bugs in HTTP 429 retry handling:\n\n1. In http_request_recoverable(), rate_limit_retries is initialized to\n   http_max_retries (the number of retries to perform after the initial\n   failed request) and decremented with pre-decrement\n   (--rate_limit_retries) in the while condition. Because of the\n   pre-decrement, setting http.maxRetries=1 performs 0 retries, and\n   more generally N configured retries only retries N-1 times. In\n   addition, if the first request returns 401 (HTTP_REAUTH) and the\n   re-authenticated request returns 429 when http.maxRetries is 0,\n   --rate_limit_retries wraps from 0 to -1 (truthy in C).\n\n   Fix this by checking http_max_retries > 0 && rate_limit_retries-- > 0\n   in the loop condition and returning HTTP_ERROR when\n   http_max_retries <= 0.\n\n2. In http_request(), CURLINFO_RETRY_AFTER is queried after\n   run_one_slot() has already called handle_curl_result(), which logs\n   results->retry_after to Trace2 (\"http/429-retry-after\") while it is\n   still -1. Move the CURLINFO_RETRY_AFTER extraction into\n   finish_active_slot() (initializing slot->results->retry_after = -1\n   first) so that both handle_curl_result() and callers of\n   run_one_slot() observe the parsed Retry-After value.\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n http.c                    | 27 +++++++++++++--------------\n t/lib-httpd/http-429.sh   | 31 ++++++++++++++++++++++++++-----\n t/t5584-http-429-retry.sh | 21 +++++++++++++++++++++\n 3 files changed, 60 insertions(+), 19 deletions(-)\n\ndiff --git a/http.c b/http.c\nindex d79678d01f..0308f973a6 100644\n--- a/http.c\n+++ b/http.c\n@@ -345,11 +345,20 @@ static void finish_active_slot(struct active_request_slot *slot)\n \tif (slot->results) {\n \t\tslot->results->curl_result = slot->curl_result;\n \t\tslot->results->http_code = slot->http_code;\n+\t\tslot->results->retry_after = -1;\n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTPAUTH_AVAIL,\n \t\t\t\t  &slot->results->auth_avail);\n \n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n \t\t\t&slot->results->http_connectcode);\n+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t\t{\n+\t\t\tcurl_off_t retry_after;\n+\t\t\tif (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,\n+\t\t\t\t\t      &retry_after) == CURLE_OK && retry_after > 0)\n+\t\t\t\tslot->results->retry_after = (long)retry_after;\n+\t\t}\n+#endif\n \t}\n \n \t/* Run callback if appropriate */\n@@ -2286,15 +2295,6 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n-#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n-\tif (ret == HTTP_RATE_LIMITED) {\n-\t\tcurl_off_t retry_after;\n-\t\tif (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,\n-\t\t\t\t      &retry_after) == CURLE_OK && retry_after > 0)\n-\t\t\tresults.retry_after = (long)retry_after;\n-\t}\n-#endif\n-\n \toptions->retry_after = results.retry_after;\n \n \tif (options->content_type) {\n@@ -2461,10 +2461,6 @@ static int http_request_recoverable(const char *url,\n \tif (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)\n \t\treturn ret;\n \n-\t/* If retries are disabled and we got a 429, fail immediately */\n-\tif (ret == HTTP_RATE_LIMITED && !http_max_retries)\n-\t\treturn HTTP_ERROR;\n-\n \tif (options->effective_url && options->base_url) {\n \t\tif (update_url_from_redirect(options->base_url,\n \t\t\t\t\t     url, options->effective_url)) {\n@@ -2474,7 +2470,8 @@ static int http_request_recoverable(const char *url,\n \t}\n \n \twhile ((ret == HTTP_REAUTH && --i) ||\n-\t       (ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {\n+\t       (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&\n+\t\trate_limit_retries-- > 0)) {\n \t\tlong retry_delay = -1;\n \t\t/*\n \t\t * The previous request may have put cruft into our output stream; we\n@@ -2518,6 +2515,8 @@ static int http_request_recoverable(const char *url,\n \t\tret = http_request(url, result, target, options);\n \t}\n \tif (ret == HTTP_RATE_LIMITED) {\n+\t\tif (http_max_retries <= 0)\n+\t\t\treturn HTTP_ERROR;\n \t\ttrace2_data_string(\"http\", the_repository,\n \t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n \t\treturn HTTP_RATE_LIMITED;\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nindex 1a5d7987db..5537fb7ac4 100644\n--- a/t/lib-httpd/http-429.sh\n+++ b/t/lib-httpd/http-429.sh\n@@ -32,12 +32,33 @@ repo_name=\"${repo_path%%/*}\"\n safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n state=\"http-429-state-${safe_name}\"\n \n-# Check if this is the first call (no state directory exists), or if\n-# the retry-after-value is \"permanent\", which indicates a 429 must be\n-# returned for every request (even if the state directory exists).\n-if test \"$retry_after\" != permanent && ! mkdir \"$state\" 2>/dev/null\n+max_fails=1\n+case \"$test_context\" in\n+\t*-fail-2*) max_fails=2 ;;\n+\t*-fail-3*) max_fails=3 ;;\n+esac\n+\n+should_fail=0\n+if test \"$retry_after\" = permanent\n+then\n+\tshould_fail=1\n+else\n+\tmkdir -p \"$state\" 2>/dev/null\n+\ti=1\n+\twhile test $i -le $max_fails\n+\tdo\n+\t\tif mkdir \"$state/$i\" 2>/dev/null\n+\t\tthen\n+\t\t\tshould_fail=1\n+\t\t\tbreak\n+\t\tfi\n+\t\ti=$((i + 1))\n+\tdone\n+fi\n+\n+if test $should_fail -eq 0\n then\n-\t# Already returned 429 once, forward to git-http-backend\n+\t# Already returned 429 max_fails times, forward to git-http-backend\n \t# Set PATH_INFO to just the repo path (without retry-after value)\n \t# Set GIT_PROJECT_ROOT so git-http-backend can find the repository\n \t# Use exec to replace this process so git-http-backend gets the updated environment\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex a22007b2cf..d92a26893d 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -51,6 +51,27 @@ test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'http.maxRetries=1 retries once and succeeds' '\n+\ttest_config http.maxRetries 1 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/max-retries-one/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'http.maxRetries=2 fails after 3 failures and succeeds on 2 failures' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/max-retries-two-fail-3/none/repo.git\" 2>err &&\n+\ttest_grep \"rate limited\" err &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/max-retries-two-fail-2/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'Trace2 logs actual CURLINFO_RETRY_AFTER value instead of -1' '\n+\ttest_config http.maxRetries 1 &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace-retry-after.event\" \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/trace2-retry-after/1/repo.git\" >output 2>err &&\n+\ttest_grep \"\\\"key\\\":\\\"http/429-retry-after\\\",\\\"value\\\":\\\"1\\\"\" trace-retry-after.event\n+'\n+\n test_expect_success 'HTTP 429 without Retry-After uses configured default' '\n \t# Enable retries and configure default delay\n \ttest_config http.maxRetries 3 &&\n-- \ngitgitgadget\n\n\n"},{"id":"557028","messageId":"36cef34668b6e2ceb867ca99029e8bc11d9cd63f.1793462224.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.git.1793462224.gitgitgadget@gmail.com","subject":"[PATCH 2/4] http: add exponential backoff without Retry-After","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-31T15:57:02Z","receivedAt":"2026-10-31T15:57:02Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nWhen a server returns HTTP 429 without a Retry-After header, Git waits\nthe same fixed http.retryAfter delay on every retry. If that delay is\nshort (e.g., 1s), Git burns through all of its retries in just a few\nseconds before a busy server can recover. If that delay is long, even a\nbrief glitch has to wait a long time on the first retry.\n\nPass the 1-based retry attempt count to handle_rate_limit_retry() and\ndouble http.retryAfter on each subsequent retry (1s, 2s, 4s, ..., via\nhttp.retryAfter * 2^(attempt - 1)), clamping the delay at\nhttp.maxRetryTime. This keeps the first retry fast while giving the\nserver more time to recover on later attempts, matching curl's own\n--retry behavior. When the server provides an explicit Retry-After\nheader, continue to honor the server-provided delay (or fail fast if it\nexceeds http.maxRetryTime).\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n Documentation/config/http.adoc | 11 ++++++++---\n http.c                         | 16 +++++++++++++---\n t/t5584-http-429-retry.sh      | 33 +++++++++++++++++++++++++++++++++\n 3 files changed, 54 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 792a71b413..b387b0ad6a 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -327,9 +327,12 @@ http.keepAliveCount::\n \t`GIT_HTTP_KEEPALIVE_COUNT` environment variable.\n \n http.retryAfter::\n-\tDefault wait time in seconds before retrying when a server returns\n+\tInitial wait time in seconds before retrying when a server returns\n \tHTTP 429 (Too Many Requests) without a Retry-After header.\n-\tDefaults to 0 (retry immediately). When a Retry-After header is\n+\tDefaults to 0 (retry immediately). When set to a positive value and\n+\tno Retry-After header is present, the delay doubles on each\n+\tsubsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n+\tcapped at `http.maxRetryTime`. When a Retry-After header is\n \tpresent, its value takes precedence over this setting; however,\n \tautomatic use of the server-provided `Retry-After` header requires\n \tlibcurl 7.66.0 or later. On older versions, configure this setting\n@@ -348,7 +351,9 @@ http.maxRetryTime::\n \thandling HTTP 429 (Too Many Requests) responses. If the server\n \trequests a delay (via Retry-After header) or if `http.retryAfter`\n \tis configured with a value that exceeds this maximum, Git will fail\n-\timmediately rather than waiting. Default is 300 seconds (5 minutes).\n+\timmediately rather than waiting. Exponentially backed-off delays\n+\tcomputed from `http.retryAfter` are capped at this maximum. Default\n+\tis 300 seconds (5 minutes).\n \tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n \tvariable. See also `http.retryAfter` and `http.maxRetries`.\n \ndiff --git a/http.c b/http.c\nindex 0308f973a6..0dec6377dd 100644\n--- a/http.c\n+++ b/http.c\n@@ -2411,7 +2411,7 @@ static int update_url_from_redirect(struct strbuf *base,\n  * Returns a negative value if configuration is invalid (delay exceeds\n  * http.maxRetryTime), otherwise returns the delay in seconds (>= 0).\n  */\n-static long handle_rate_limit_retry(long slot_retry_after)\n+static long handle_rate_limit_retry(long slot_retry_after, int attempt)\n {\n \t/* Use the slot-specific retry_after value or configured default */\n \tif (slot_retry_after >= 0) {\n@@ -2427,6 +2427,8 @@ static long handle_rate_limit_retry(long slot_retry_after)\n \t\t}\n \t\treturn slot_retry_after;\n \t} else {\n+\t\tlong delay = http_retry_after;\n+\n \t\t/* No Retry-After header provided, use configured default */\n \t\tif (http_retry_after > http_max_retry_time) {\n \t\t\terror(_(\"configured http.retryAfter exceeds http.maxRetryTime (%ld > %ld seconds)\"),\n@@ -2437,7 +2439,14 @@ static long handle_rate_limit_retry(long slot_retry_after)\n \t\t}\n \t\ttrace2_data_string(\"http\", the_repository,\n \t\t\t\"http/429-retry-source\", \"config-default\");\n-\t\treturn http_retry_after;\n+\t\tfor (int a = 1; a < attempt && delay > 0; a++) {\n+\t\t\tif (delay >= http_max_retry_time - delay) {\n+\t\t\t\tdelay = http_max_retry_time;\n+\t\t\t\tbreak;\n+\t\t\t}\n+\t\t\tdelay *= 2;\n+\t\t}\n+\t\treturn delay;\n \t}\n }\n \n@@ -2449,6 +2458,7 @@ static int http_request_recoverable(const char *url,\n \tint i = 3;\n \tint ret;\n \tint rate_limit_retries = http_max_retries;\n+\tint attempt = 0;\n \n \tif (!options)\n \t\toptions = &empty_opts;\n@@ -2498,7 +2508,7 @@ static int http_request_recoverable(const char *url,\n \t\t\tBUG(\"Unknown http_request target\");\n \t\t}\n \t\tif (ret == HTTP_RATE_LIMITED) {\n-\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after);\n+\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);\n \t\t\tif (retry_delay < 0)\n \t\t\t\treturn HTTP_ERROR;\n \ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex d92a26893d..f0c3cc3b61 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -82,6 +82,39 @@ test_expect_success 'HTTP 429 without Retry-After uses configured default' '\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'exponential backoff doubles http.retryAfter without Retry-After header' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/exp-backoff-fail-3/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting 1 seconds before retry\" err &&\n+\ttest_grep \"waiting 2 seconds before retry\" err &&\n+\ttest_grep \"waiting 4 seconds before retry\" err\n+'\n+\n+test_expect_success 'exponential backoff clamps delay at http.maxRetryTime' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\ttest_config http.maxRetryTime 2 &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace-clamp.event\" \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/exp-clamp-fail-3/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting 1 seconds before retry\" err &&\n+\ttest_grep \"waiting 2 seconds before retry\" err &&\n+\ttest_grep ! \"waiting 4 seconds before retry\" err &&\n+\tgrep \"\\\"key\\\":\\\"http/retry-sleep-seconds\\\",\\\"value\\\":\\\"2\\\"\" trace-clamp.event >clamped &&\n+\ttest_line_count = 2 clamped\n+'\n+\n+test_expect_success 'server Retry-After header overrides exponential backoff' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_config http.retryAfter 1 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/header-override-fail-2/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"waiting 2 seconds before retry\" err\n+'\n+\n test_expect_success 'HTTP 429 retry delays are respected' '\n \t# Enable retries\n \ttest_config http.maxRetries 3 &&\n-- \ngitgitgadget\n\n\n"},{"id":"557029","messageId":"f56ab2afaaecfbd64c4460be5d95637054623e4a.1793462224.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.git.1793462224.gitgitgadget@gmail.com","subject":"[PATCH 3/4] http: add http.retryStatusCodes config option","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-31T15:57:03Z","receivedAt":"2026-10-31T15:57:03Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nCommit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n2026-03-17) hardcoded HTTP 429 as the only status code eligible for\nretry. In practice, reverse proxies, load balancers, and Git hosting\nbackends also return transient 5xx responses such as 502 Bad Gateway,\n503 Service Unavailable (which RFC 9110 section 10.2.3 defines to\nsupport Retry-After), and 504 Gateway Timeout during load shedding or\nupstream connection resets.\n\nBecause 5xx responses are not universally transient across all\nenvironments, keep 429 as the default and introduce a new\nhttp.retryStatusCodes configuration option (and corresponding\nGIT_HTTP_RETRY_STATUS_CODES environment variable) accepting a comma- or\nwhitespace-separated list of HTTP status codes in the 400..599 range\n(for example, \"429,502,503,504\").\n\nKeep the existing \"rate limited\" warning and error messages for HTTP\n429, and report the numeric status code (e.g., \"HTTP 502, waiting <N>\nseconds before retry\") when retrying other status codes.\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n Documentation/config/http.adoc | 31 ++++++++++++++++------\n http.c                         | 39 ++++++++++++++++++++++++---\n http.h                         |  6 +++--\n t/lib-httpd/http-429.sh        | 10 +++++--\n t/t5584-http-429-retry.sh      | 48 ++++++++++++++++++++++++++++++++++\n 5 files changed, 118 insertions(+), 16 deletions(-)\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex b387b0ad6a..8e02a1d754 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -328,7 +328,8 @@ http.keepAliveCount::\n \n http.retryAfter::\n \tInitial wait time in seconds before retrying when a server returns\n-\tHTTP 429 (Too Many Requests) without a Retry-After header.\n+\ta retryable HTTP status code (HTTP 429 by default) without a\n+\tRetry-After header.\n \tDefaults to 0 (retry immediately). When set to a positive value and\n \tno Retry-After header is present, the delay doubles on each\n \tsubsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n@@ -338,24 +339,38 @@ http.retryAfter::\n \tlibcurl 7.66.0 or later. On older versions, configure this setting\n \tmanually to control the retry delay. Can be overridden by the\n \t`GIT_HTTP_RETRY_AFTER` environment variable.\n-\tSee also `http.maxRetries` and `http.maxRetryTime`.\n+\tSee also `http.maxRetries`, `http.maxRetryTime`, and\n+\t`http.retryStatusCodes`.\n \n http.maxRetries::\n-\tMaximum number of times to retry after receiving HTTP 429 (Too Many\n-\tRequests) responses. Set to 0 (the default) to disable retries.\n+\tMaximum number of times to retry after receiving a retryable HTTP\n+\tstatus code (HTTP 429 by default). Set to 0 (the default) to disable\n+\tretries.\n \tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n-\tSee also `http.retryAfter` and `http.maxRetryTime`.\n+\tSee also `http.retryAfter`, `http.maxRetryTime`, and\n+\t`http.retryStatusCodes`.\n \n http.maxRetryTime::\n \tMaximum time in seconds to wait for a single retry attempt when\n-\thandling HTTP 429 (Too Many Requests) responses. If the server\n-\trequests a delay (via Retry-After header) or if `http.retryAfter`\n+\thandling a retryable HTTP status code (HTTP 429 by default). If the\n+\tserver requests a delay (via Retry-After header) or if `http.retryAfter`\n \tis configured with a value that exceeds this maximum, Git will fail\n \timmediately rather than waiting. Exponentially backed-off delays\n \tcomputed from `http.retryAfter` are capped at this maximum. Default\n \tis 300 seconds (5 minutes).\n \tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n-\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\tvariable. See also `http.retryAfter`, `http.maxRetries`, and\n+\t`http.retryStatusCodes`.\n+\n+http.retryStatusCodes::\n+\tComma- or whitespace-separated list of HTTP status codes (in the\n+\t400..599 range) that should be retried up to `http.maxRetries`\n+\ttimes. Defaults to `429`. For example, setting this to\n+\t`429,502,503,504` also retries transient `5xx` gateway and\n+\tservice-unavailable errors. Can be overridden by the\n+\t`GIT_HTTP_RETRY_STATUS_CODES` environment variable.\n+\tSee also `http.maxRetries`, `http.retryAfter`, and\n+\t`http.maxRetryTime`.\n \n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\ndiff --git a/http.c b/http.c\nindex 0dec6377dd..950e2bbffa 100644\n--- a/http.c\n+++ b/http.c\n@@ -156,6 +156,7 @@ static int http_schannel_check_revoke = 1;\n static long http_retry_after = 0;\n static long http_max_retries = 0;\n static long http_max_retry_time = 300;\n+static char *http_retry_status_codes;\n \n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n@@ -607,6 +608,11 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retrystatuscodes\", var)) {\n+\t\tFREE_AND_NULL(http_retry_status_codes);\n+\t\treturn git_config_string(&http_retry_status_codes, var, value);\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1518,6 +1524,7 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&http_retry_after, \"GIT_HTTP_RETRY_AFTER\");\n \tset_long_from_env(&http_max_retries, \"GIT_HTTP_MAX_RETRIES\");\n \tset_long_from_env(&http_max_retry_time, \"GIT_HTTP_MAX_RETRY_TIME\");\n+\tset_from_env(&http_retry_status_codes, \"GIT_HTTP_RETRY_STATUS_CODES\");\n \n \tcurl_default = get_curl_handle();\n }\n@@ -1579,6 +1586,7 @@ void http_cleanup(void)\n \tproxy_ssl_cert_password_required = 0;\n \n \tFREE_AND_NULL(cached_accept_language);\n+\tFREE_AND_NULL(http_retry_status_codes);\n }\n \n struct active_request_slot *get_active_slot(void)\n@@ -1928,6 +1936,23 @@ void normalize_curl_result(CURLcode *result, long http_code,\n \t}\n }\n \n+static int is_retryable_http_code(long code)\n+{\n+\tconst char *p = http_retry_status_codes ? http_retry_status_codes : \"429\";\n+\tchar *end;\n+\n+\tfor (; code >= 400 && code < 600 && *p; p = end) {\n+\t\twhile (*p == ',' || isspace(*p))\n+\t\t\tp++;\n+\t\tif (strtol(p, &end, 10) == code && end > p &&\n+\t\t    (!*end || *end == ',' || isspace(*end)))\n+\t\t\treturn 1;\n+\t\twhile (*end && *end != ',' && !isspace(*end))\n+\t\t\tend++;\n+\t}\n+\treturn 0;\n+}\n+\n static int handle_curl_result(struct slot_results *results)\n {\n \tnormalize_curl_result(&results->curl_result, results->http_code,\n@@ -1981,7 +2006,7 @@ static int handle_curl_result(struct slot_results *results)\n \t\t\t}\n \t\t\treturn HTTP_REAUTH;\n \t\t}\n-\t} else if (results->http_code == 429) {\n+\t} else if (is_retryable_http_code(results->http_code)) {\n \t\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n \t\t\tresults->retry_after);\n \t\treturn HTTP_RATE_LIMITED;\n@@ -2296,6 +2321,7 @@ static int http_request(const char *url,\n \tret = run_one_slot(slot, &results);\n \n \toptions->retry_after = results.retry_after;\n+\toptions->http_code = results.http_code;\n \n \tif (options->content_type) {\n \t\tstruct strbuf raw = STRBUF_INIT;\n@@ -2407,7 +2433,7 @@ static int update_url_from_redirect(struct strbuf *base,\n }\n \n /*\n- * Compute the retry delay for an HTTP 429 response.\n+ * Compute the retry delay for a retryable HTTP response.\n  * Returns a negative value if configuration is invalid (delay exceeds\n  * http.maxRetryTime), otherwise returns the delay in seconds (>= 0).\n  */\n@@ -2513,7 +2539,12 @@ static int http_request_recoverable(const char *url,\n \t\t\t\treturn HTTP_ERROR;\n \n \t\t\tif (retry_delay > 0) {\n-\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_delay);\n+\t\t\t\tif (options->http_code == 429)\n+\t\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n+\t\t\t\t\t\tretry_delay);\n+\t\t\t\telse\n+\t\t\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n+\t\t\t\t\t\toptions->http_code, retry_delay);\n \t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n \t\t\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n \t\t\t\tsleep(retry_delay);\n@@ -2529,7 +2560,7 @@ static int http_request_recoverable(const char *url,\n \t\t\treturn HTTP_ERROR;\n \t\ttrace2_data_string(\"http\", the_repository,\n \t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n-\t\treturn HTTP_RATE_LIMITED;\n+\t\treturn options->http_code == 429 ? HTTP_RATE_LIMITED : HTTP_ERROR;\n \t}\n \treturn ret;\n }\ndiff --git a/http.h b/http.h\nindex 729c51904d..5985a0f71f 100644\n--- a/http.h\n+++ b/http.h\n@@ -167,10 +167,12 @@ struct http_get_options {\n \n \t/*\n \t * After a request completes, contains the Retry-After delay in seconds\n-\t * if the server returned HTTP 429 with a Retry-After header (requires\n-\t * libcurl 7.66.0 or later), or -1 if no such header was present.\n+\t * if the server returned a retryable status code with a Retry-After\n+\t * header (requires libcurl 7.66.0 or later), or -1 if no such header\n+\t * was present.\n \t */\n \tlong retry_after;\n+\tlong http_code;\n };\n \n /* Return values for http_get_*() */\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nindex 5537fb7ac4..5c7d58a75f 100644\n--- a/t/lib-httpd/http-429.sh\n+++ b/t/lib-httpd/http-429.sh\n@@ -75,8 +75,14 @@ then\n \texec \"$GIT_EXEC_PATH/git-http-backend\"\n fi\n \n-# Output HTTP 429 response\n-printf \"Status: 429 Too Many Requests\\r\\n\"\n+# Output HTTP 429 (or configured status) response\n+status_line=\"429 Too Many Requests\"\n+case \"$test_context\" in\n+\t*-status-502*) status_line=\"502 Bad Gateway\" ;;\n+\t*-status-503*) status_line=\"503 Service Unavailable\" ;;\n+\t*-status-504*) status_line=\"504 Gateway Timeout\" ;;\n+esac\n+printf \"Status: %s\\r\\n\" \"$status_line\"\n \n # Set Retry-After header based on retry_after value\n case \"$retry_after\" in\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex f0c3cc3b61..f3e7dbaaa4 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -317,4 +317,52 @@ test_expect_success 'verify normal repository access still works' '\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'HTTP 502/503/504 are not retried by default' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/default-no-retry-status-502/none/repo.git\" 2>err &&\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\ttest_grep \"The requested URL returned error: 502\" err\n+'\n+\n+test_expect_success 'http.retryStatusCodes retries configured 502, 503, and 504 errors' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\ttest_config http.retryStatusCodes \"429, 502, 503, 504\" &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-status-502/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"HTTP 502, waiting 1 seconds before retry\" err &&\n+\ttest_config http.retryAfter 0 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-status-503/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-status-504/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 503 honors Retry-After header when present' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_config http.retryStatusCodes \"429,503\" &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/header-status-503/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"HTTP 503, waiting 1 seconds before retry\" err\n+'\n+\n+test_expect_success 'exhausted 502 retries report HTTP 502 error, not rate limited' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_config http.retryStatusCodes \"429,502\" &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/exhausted-fail-3-status-502/none/repo.git\" 2>err &&\n+\ttest_grep \"The requested URL returned error: 502\" err &&\n+\ttest_grep ! \"rate limited\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_STATUS_CODES and urlmatch http.<url>.retryStatusCodes' '\n+\ttest_config http.maxRetries 2 &&\n+\tGIT_HTTP_RETRY_STATUS_CODES=\"429,502\" \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-status-502/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_config \"http.$HTTPD_URL.retryStatusCodes\" \"429,503\" &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/urlmatch-status-503/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n test_done\n-- \ngitgitgadget\n\n\n"},{"id":"557030","messageId":"52819accc894e760a0fd06730d20f46cc8da9914.1793462224.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.git.1793462224.gitgitgadget@gmail.com","subject":"[PATCH 4/4] remote-curl: retry transient HTTP errors in post_rpc()","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-10-31T15:57:04Z","receivedAt":"2026-10-31T15:57:04Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nGit's existing retry logic only covers HTTP GET requests. However, the\nactual data transfer during fetch and clone, as well as on-demand blob\ndownloads in partial clones, are sent as HTTP POST requests via\npost_rpc(), which only retries authentication and immediately aborts on\n429 or 5xx.\n\nExtract the retry wait logic from http_request_recoverable() into a\nshared http_retry_wait() helper and use it to retry transient HTTP\nerrors in probe_rpc() and post_rpc(). Also defer reporting RPC errors\nuntil retries are exhausted, and re-attach the \"Content-Encoding: gzip\"\nheader when retrying a compressed request.\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n http.c                    | 64 ++++++++++++++++++---------------\n http.h                    |  6 ++++\n remote-curl.c             | 75 +++++++++++++++++++--------------------\n t/lib-httpd/http-429.sh   | 50 ++++++++++++++++++--------\n t/t5584-http-429-retry.sh | 53 +++++++++++++++++++++++++++\n 5 files changed, 167 insertions(+), 81 deletions(-)\n\ndiff --git a/http.c b/http.c\nindex 950e2bbffa..36b559e596 100644\n--- a/http.c\n+++ b/http.c\n@@ -2476,6 +2476,34 @@ static long handle_rate_limit_retry(long slot_retry_after, int attempt)\n \t}\n }\n \n+int http_retry_wait(long http_code, long slot_retry_after, int attempt)\n+{\n+\tlong retry_delay;\n+\n+\tif (http_max_retries <= 0)\n+\t\treturn -1;\n+\tif (attempt > http_max_retries) {\n+\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n+\t\treturn -1;\n+\t}\n+\tretry_delay = handle_rate_limit_retry(slot_retry_after, attempt);\n+\tif (retry_delay < 0)\n+\t\treturn -1;\n+\tif (retry_delay > 0) {\n+\t\tif (http_code == 429)\n+\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n+\t\t\t\tretry_delay);\n+\t\telse\n+\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n+\t\t\t\thttp_code, retry_delay);\n+\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n+\t\tsleep(retry_delay);\n+\t}\n+\treturn 0;\n+}\n+\n static int http_request_recoverable(const char *url,\n \t\t\t       void *result, int target,\n \t\t\t       struct http_get_options *options)\n@@ -2483,7 +2511,6 @@ static int http_request_recoverable(const char *url,\n \tstatic struct http_get_options empty_opts;\n \tint i = 3;\n \tint ret;\n-\tint rate_limit_retries = http_max_retries;\n \tint attempt = 0;\n \n \tif (!options)\n@@ -2506,9 +2533,8 @@ static int http_request_recoverable(const char *url,\n \t}\n \n \twhile ((ret == HTTP_REAUTH && --i) ||\n-\t       (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&\n-\t\trate_limit_retries-- > 0)) {\n-\t\tlong retry_delay = -1;\n+\t       (ret == HTTP_RATE_LIMITED &&\n+\t\t!http_retry_wait(options->http_code, options->retry_after, ++attempt))) {\n \t\t/*\n \t\t * The previous request may have put cruft into our output stream; we\n \t\t * should clear it out before making our next request.\n@@ -2533,35 +2559,15 @@ static int http_request_recoverable(const char *url,\n \t\tdefault:\n \t\t\tBUG(\"Unknown http_request target\");\n \t\t}\n-\t\tif (ret == HTTP_RATE_LIMITED) {\n-\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);\n-\t\t\tif (retry_delay < 0)\n-\t\t\t\treturn HTTP_ERROR;\n-\n-\t\t\tif (retry_delay > 0) {\n-\t\t\t\tif (options->http_code == 429)\n-\t\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n-\t\t\t\t\t\tretry_delay);\n-\t\t\t\telse\n-\t\t\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n-\t\t\t\t\t\toptions->http_code, retry_delay);\n-\t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n-\t\t\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n-\t\t\t\tsleep(retry_delay);\n-\t\t\t}\n-\t\t} else if (ret == HTTP_REAUTH) {\n+\t\tif (ret == HTTP_REAUTH)\n \t\t\thttp_reauth_prepare(1);\n-\t\t}\n \n \t\tret = http_request(url, result, target, options);\n \t}\n-\tif (ret == HTTP_RATE_LIMITED) {\n-\t\tif (http_max_retries <= 0)\n-\t\t\treturn HTTP_ERROR;\n-\t\ttrace2_data_string(\"http\", the_repository,\n-\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n-\t\treturn options->http_code == 429 ? HTTP_RATE_LIMITED : HTTP_ERROR;\n-\t}\n+\tif (ret == HTTP_RATE_LIMITED &&\n+\t    (http_max_retries <= 0 || attempt <= http_max_retries ||\n+\t     options->http_code != 429))\n+\t\treturn HTTP_ERROR;\n \treturn ret;\n }\n \ndiff --git a/http.h b/http.h\nindex 5985a0f71f..fa9f46a11b 100644\n--- a/http.h\n+++ b/http.h\n@@ -201,6 +201,12 @@ int http_get_strbuf(const char *url, struct strbuf *result, struct http_get_opti\n int http_get_file(const char *url, const char *filename,\n \t\t  struct http_get_options *options);\n \n+/*\n+ * Wait before retrying a transient HTTP error (1-based attempt count).\n+ * Returns 0 if the caller should retry, or -1 to stop retrying.\n+ */\n+int http_retry_wait(long http_code, long slot_retry_after, int attempt);\n+\n int http_fetch_ref(const char *base, struct ref *ref);\n \n struct curl_slist *http_append_auth_header(const struct credential *c,\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 2c35dd5240..d73aaebadb 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -845,35 +845,22 @@ static size_t rpc_in(char *ptr, size_t eltsize,\n \treturn size;\n }\n \n-static int run_slot(struct active_request_slot *slot,\n-\t\t    struct slot_results *results)\n+static void report_rpc_error(struct slot_results *results)\n {\n-\tint err;\n-\tstruct slot_results results_buf;\n-\n-\tif (!results)\n-\t\tresults = &results_buf;\n-\n-\terr = run_one_slot(slot, results);\n-\n-\tif (err != HTTP_OK && err != HTTP_REAUTH) {\n-\t\tstruct strbuf msg = STRBUF_INIT;\n-\t\tif (results->http_code && results->http_code != 200)\n-\t\t\tstrbuf_addf(&msg, \"HTTP %ld\", results->http_code);\n-\t\tif (results->curl_result != CURLE_OK) {\n-\t\t\tif (msg.len)\n-\t\t\t\tstrbuf_addch(&msg, ' ');\n-\t\t\tstrbuf_addf(&msg, \"curl %d\", results->curl_result);\n-\t\t\tif (curl_errorstr[0]) {\n-\t\t\t\tstrbuf_addch(&msg, ' ');\n-\t\t\t\tstrbuf_addstr(&msg, curl_errorstr);\n-\t\t\t}\n+\tstruct strbuf msg = STRBUF_INIT;\n+\tif (results->http_code && results->http_code != 200)\n+\t\tstrbuf_addf(&msg, \"HTTP %ld\", results->http_code);\n+\tif (results->curl_result != CURLE_OK) {\n+\t\tif (msg.len)\n+\t\t\tstrbuf_addch(&msg, ' ');\n+\t\tstrbuf_addf(&msg, \"curl %d\", results->curl_result);\n+\t\tif (curl_errorstr[0]) {\n+\t\t\tstrbuf_addch(&msg, ' ');\n+\t\t\tstrbuf_addstr(&msg, curl_errorstr);\n \t\t}\n-\t\terror(_(\"RPC failed; %s\"), msg.buf);\n-\t\tstrbuf_release(&msg);\n \t}\n-\n-\treturn err;\n+\terror(_(\"RPC failed; %s\"), msg.buf);\n+\tstrbuf_release(&msg);\n }\n \n static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)\n@@ -899,7 +886,7 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)\n \tcurl_easy_setopt(slot->curl, CURLOPT_WRITEFUNCTION, fwrite_buffer);\n \tcurl_easy_setopt(slot->curl, CURLOPT_WRITEDATA, &buf);\n \n-\terr = run_slot(slot, results);\n+\terr = run_one_slot(slot, results);\n \n \tcurl_slist_free_all(headers);\n \tstrbuf_release(&buf);\n@@ -913,12 +900,14 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)\n static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_received)\n {\n \tstruct active_request_slot *slot;\n+\tstruct slot_results results = { 0 };\n \tstruct curl_slist *headers = NULL;\n \tint use_gzip = rpc->gzip_request;\n \tchar *gzip_body = NULL;\n \tsize_t gzip_size = 0;\n \tint err, large_request = 0;\n \tint needs_100_continue = 0;\n+\tint attempt = 0;\n \tstruct rpc_in_data rpc_in_data;\n \n \t/* Try to load the entire request, if we can fit it into the\n@@ -941,15 +930,18 @@ static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_rece\n \t}\n \n \tif (large_request) {\n-\t\tstruct slot_results results;\n-\n \t\tdo {\n \t\t\terr = probe_rpc(rpc, &results);\n \t\t\tif (err == HTTP_REAUTH)\n \t\t\t\thttp_reauth_prepare(0);\n-\t\t} while (err == HTTP_REAUTH);\n-\t\tif (err != HTTP_OK)\n+\t\t} while (err == HTTP_REAUTH ||\n+\t\t\t (err == HTTP_RATE_LIMITED &&\n+\t\t\t  !http_retry_wait(results.http_code,\n+\t\t\t\t\t   results.retry_after, ++attempt)));\n+\t\tif (err != HTTP_OK) {\n+\t\t\treport_rpc_error(&results);\n \t\t\treturn -1;\n+\t\t}\n \n \t\tif (results.auth_avail & CURLAUTH_GSSNEGOTIATE || http_auth.authtype)\n \t\t\tneeds_100_continue = 1;\n@@ -998,10 +990,11 @@ retry:\n \n \t} else if (gzip_body) {\n \t\t/*\n-\t\t * If we are looping to retry authentication, then the previous\n-\t\t * run will have set up the headers and gzip buffer already,\n-\t\t * and we just need to send it.\n+\t\t * If we are looping to retry, then the previous run will\n+\t\t * have set up the gzip buffer already, and we just need to\n+\t\t * send it.\n \t\t */\n+\t\theaders = curl_slist_append(headers, \"Content-Encoding: gzip\");\n \t\tcurl_easy_setopt(slot->curl, CURLOPT_POSTFIELDS, gzip_body);\n \t\tcurl_easy_setopt(slot->curl, CURLOPT_POSTFIELDSIZE_LARGE, cast_size_t_to_curl_off_t(gzip_size));\n \n@@ -1066,14 +1059,20 @@ retry:\n \n \n \trpc->any_written = 0;\n-\terr = run_slot(slot, NULL);\n-\tif (err == HTTP_REAUTH && !large_request) {\n-\t\thttp_reauth_prepare(0);\n+\terr = run_one_slot(slot, &results);\n+\tif (!large_request && !rpc->any_written &&\n+\t    (err == HTTP_REAUTH ||\n+\t     (err == HTTP_RATE_LIMITED &&\n+\t      !http_retry_wait(results.http_code, results.retry_after, ++attempt)))) {\n+\t\tif (err == HTTP_REAUTH)\n+\t\t\thttp_reauth_prepare(0);\n \t\tcurl_slist_free_all(headers);\n \t\tgoto retry;\n \t}\n-\tif (err != HTTP_OK)\n+\tif (err != HTTP_OK) {\n+\t\treport_rpc_error(&results);\n \t\terr = -1;\n+\t}\n \n \tif (!rpc->any_written)\n \t\terr = -1;\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nindex 5c7d58a75f..ec70a4e9d1 100644\n--- a/t/lib-httpd/http-429.sh\n+++ b/t/lib-httpd/http-429.sh\n@@ -39,21 +39,36 @@ case \"$test_context\" in\n esac\n \n should_fail=0\n-if test \"$retry_after\" = permanent\n+case \"$test_context,$REQUEST_METHOD\" in\n+\t*-post-only*,POST)\n+\t\tcan_fail=1\n+\t\t;;\n+\t*-post-only*,*)\n+\t\tcan_fail=0\n+\t\t;;\n+\t*)\n+\t\tcan_fail=1\n+\t\t;;\n+esac\n+\n+if test $can_fail -eq 1\n then\n-\tshould_fail=1\n-else\n-\tmkdir -p \"$state\" 2>/dev/null\n-\ti=1\n-\twhile test $i -le $max_fails\n-\tdo\n-\t\tif mkdir \"$state/$i\" 2>/dev/null\n-\t\tthen\n-\t\t\tshould_fail=1\n-\t\t\tbreak\n-\t\tfi\n-\t\ti=$((i + 1))\n-\tdone\n+\tif test \"$retry_after\" = permanent\n+\tthen\n+\t\tshould_fail=1\n+\telse\n+\t\tmkdir -p \"$state\" 2>/dev/null\n+\t\ti=1\n+\t\twhile test $i -le $max_fails\n+\t\tdo\n+\t\t\tif mkdir \"$state/$i\" 2>/dev/null\n+\t\t\tthen\n+\t\t\t\tshould_fail=1\n+\t\t\t\tbreak\n+\t\t\tfi\n+\t\t\ti=$((i + 1))\n+\t\tdone\n+\tfi\n fi\n \n if test $should_fail -eq 0\n@@ -75,6 +90,13 @@ then\n \texec \"$GIT_EXEC_PATH/git-http-backend\"\n fi\n \n+# Drain POST request body before returning synthetic error so Apache mod_cgi\n+# does not reset the TCP connection.\n+if test \"$REQUEST_METHOD\" = POST\n+then\n+\tcat >/dev/null\n+fi\n+\n # Output HTTP 429 (or configured status) response\n status_line=\"429 Too Many Requests\"\n case \"$test_context\" in\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex f3e7dbaaa4..e627aa2b63 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -365,4 +365,57 @@ test_expect_success 'GIT_HTTP_RETRY_STATUS_CODES and urlmatch http.<url>.retrySt\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'Protocol v2 POST /git-upload-pack retries 429 and 502' '\n+\ttest_when_finished \"rm -rf clone-v2-429 clone-v2-502 clone-v2-fail\" &&\n+\ttest_config_global protocol.version 2 &&\n+\ttest_config_global http.maxRetries 3 &&\n+\ttest_config_global http.retryAfter 1 &&\n+\ttest_config_global http.retryStatusCodes \"429,502\" &&\n+\tgit clone \"$HTTPD_URL/http_429/v2-post-only-429/1/repo.git\" clone-v2-429 2>err &&\n+\ttest_grep \"rate limited, waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"RPC failed\" err &&\n+\tgit clone \"$HTTPD_URL/http_429/v2-post-only-status-502/1/repo.git\" clone-v2-502 2>err &&\n+\ttest_grep \"HTTP 502, waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"RPC failed\" err &&\n+\ttest_config_global http.maxRetries 1 &&\n+\ttest_config_global http.retryAfter 0 &&\n+\ttest_must_fail git clone \\\n+\t\t\"$HTTPD_URL/http_429/v2-post-only-fail-2-status-502/none/repo.git\" \\\n+\t\tclone-v2-fail 2>err &&\n+\ttest_grep \"RPC failed; HTTP 502\" err\n+'\n+\n+test_expect_success 'partial clone lazy blob fetch (promisor-remote) retries transient 502' '\n+\ttest_when_finished \"rm -rf partial\" &&\n+\ttest_config -C \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" uploadpack.allowfilter 1 &&\n+\ttest_config -C \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" uploadpack.allowanysha1inwant 1 &&\n+\tgit clone --filter=blob:none --no-checkout \"$HTTPD_URL/smart/repo.git\" partial &&\n+\tgit -C partial config remote.origin.url \\\n+\t\t\"$HTTPD_URL/http_429/promisor-post-only-status-502/1/repo.git\" &&\n+\tgit -C partial config http.maxRetries 3 &&\n+\tgit -C partial config http.retryAfter 1 &&\n+\tgit -C partial config http.retryStatusCodes \"429,502\" &&\n+\tgit -C partial checkout HEAD 2>err &&\n+\ttest_grep \"HTTP 502, waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"RPC failed\" err\n+'\n+\n+test_expect_success 'gzipped POST /git-upload-pack preserves Content-Encoding: gzip on retry' '\n+\ttest_when_finished \"rm -f curl.log gzip.headers\" &&\n+\ttest_config protocol.version 2 &&\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\tset -- &&\n+\tfor i in $(test_seq 30)\n+\tdo\n+\t\tset -- \"$@\" -o \"very-long-server-option-to-exceed-1024-bytes-$i\" || return 1\n+\tdone &&\n+\tGIT_TRACE_CURL=\"$(pwd)/curl.log\" git ls-remote \"$@\" \\\n+\t\t\"$HTTPD_URL/http_429/gzip-post-only-429/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"rate limited, waiting 1 seconds before retry\" err &&\n+\tgrep \"Content-Encoding: gzip\" curl.log >gzip.headers &&\n+\ttest_line_count = 2 gzip.headers\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"557251","messageId":"xmqqpkwmenf3.fsf@gitster.g","threadId":"66729","inReplyTo":"80b7f207f42cff0a3dfe23181ee315fdc90e50ff.1793462224.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 1/4] http: fix retry count and Retry-After timing","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-11-03T01:03:44Z","receivedAt":"2026-11-03T01:03:44Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Ravi Mistry via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Ravi Mistry <rmistry@google.com>\n>\n> Commit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n> 2026-03-17) introduced two small bugs in HTTP 429 retry handling:\n>\n> 1. In http_request_recoverable(), rate_limit_retries is initialized to\n>    http_max_retries (the number of retries to perform after the initial\n>    failed request) and decremented with pre-decrement\n>    (--rate_limit_retries) in the while condition. Because of the\n>    pre-decrement, setting http.maxRetries=1 performs 0 retries, and\n>    more generally N configured retries only retries N-1 times. In\n\nThe new test exactly demonstrates it, which is fun.\n\n>    In\n>    addition, if the first request returns 401 (HTTP_REAUTH) and the\n>    re-authenticated request returns 429 when http.maxRetries is 0,\n>    --rate_limit_retries wraps from 0 to -1 (truthy in C).\n\nHmph, which would mean we would retry essentially forever until the\ninteger wraps around again?  That sounds, eh, nasty. \n\n>    Fix this by checking http_max_retries > 0 && rate_limit_retries-- > 0\n>    in the loop condition and returning HTTP_ERROR when\n>    http_max_retries <= 0.\n\nOK.\n\n> 2. In http_request(), CURLINFO_RETRY_AFTER is queried after\n>    run_one_slot() has already called handle_curl_result(), which logs\n>    results->retry_after to Trace2 (\"http/429-retry-after\") while it is\n>    still -1. Move the CURLINFO_RETRY_AFTER extraction into\n>    finish_active_slot() (initializing slot->results->retry_after = -1\n>    first) so that both handle_curl_result() and callers of\n>    run_one_slot() observe the parsed Retry-After value.\n>\n> Signed-off-by: Ravi Mistry <rmistry@google.com>\n> ---\n>  http.c                    | 27 +++++++++++++--------------\n>  t/lib-httpd/http-429.sh   | 31 ++++++++++++++++++++++++++-----\n>  t/t5584-http-429-retry.sh | 21 +++++++++++++++++++++\n>  3 files changed, 60 insertions(+), 19 deletions(-)\n>\n> diff --git a/http.c b/http.c\n> index d79678d01f..0308f973a6 100644\n> --- a/http.c\n> +++ b/http.c\n> @@ -345,11 +345,20 @@ static void finish_active_slot(struct active_request_slot *slot)\n>  \tif (slot->results) {\n>  \t\tslot->results->curl_result = slot->curl_result;\n>  \t\tslot->results->http_code = slot->http_code;\n> +\t\tslot->results->retry_after = -1;\n>  \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTPAUTH_AVAIL,\n>  \t\t\t\t  &slot->results->auth_avail);\n>  \n>  \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n>  \t\t\t&slot->results->http_connectcode);\n> +#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n> +\t\t{\n> +\t\t\tcurl_off_t retry_after;\n> +\t\t\tif (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,\n> +\t\t\t\t\t      &retry_after) == CURLE_OK && retry_after > 0)\n> +\t\t\t\tslot->results->retry_after = (long)retry_after;\n> +\t\t}\n> +#endif\n>  \t}\n>  \n>  \t/* Run callback if appropriate */\n> @@ -2286,15 +2295,6 @@ static int http_request(const char *url,\n>  \n>  \tret = run_one_slot(slot, &results);\n>  \n> -#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n> -\tif (ret == HTTP_RATE_LIMITED) {\n> -\t\tcurl_off_t retry_after;\n> -\t\tif (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,\n> -\t\t\t\t      &retry_after) == CURLE_OK && retry_after > 0)\n> -\t\t\tresults.retry_after = (long)retry_after;\n> -\t}\n> -#endif\n> -\n\nOK, this is the fix #2 explained in the log message.\n\n> @@ -2461,10 +2461,6 @@ static int http_request_recoverable(const char *url,\n>  \tif (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)\n>  \t\treturn ret;\n>  \n> -\t/* If retries are disabled and we got a 429, fail immediately */\n> -\tif (ret == HTTP_RATE_LIMITED && !http_max_retries)\n> -\t\treturn HTTP_ERROR;\n> -\n\nI am not sure I get this removal.  If it is because the change to\nthe next hunk (the condition to loop with the while statement)\nalready covers it, I guess it makes sense, because we never enter\nthe while() loop and we will return HTTP_ERROR more or less the same\nway as the above if http_max_retries is false.\n\n>  \twhile ((ret == HTTP_REAUTH && --i) ||\n> -\t       (ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {\n> +\t       (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&\n> +\t\trate_limit_retries-- > 0)) {\n>  \t\tlong retry_delay = -1;\n>  \t\t/*\n>  \t\t * The previous request may have put cruft into our output stream; we\n> @@ -2518,6 +2515,8 @@ static int http_request_recoverable(const char *url,\n>  \t\tret = http_request(url, result, target, options);\n>  \t}\n>  \tif (ret == HTTP_RATE_LIMITED) {\n> +\t\tif (http_max_retries <= 0)\n> +\t\t\treturn HTTP_ERROR;\n>  \t\ttrace2_data_string(\"http\", the_repository,\n>  \t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n>  \t\treturn HTTP_RATE_LIMITED;\n\n"},{"id":"557315","messageId":"xmqqpkwmaw51.fsf@gitster.g","threadId":"66729","inReplyTo":"36cef34668b6e2ceb867ca99029e8bc11d9cd63f.1793462224.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 2/4] http: add exponential backoff without Retry-After","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-11-03T13:21:14Z","receivedAt":"2026-11-03T13:21:14Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Ravi Mistry via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> From: Ravi Mistry <rmistry@google.com>\n>\n> When a server returns HTTP 429 without a Retry-After header, Git waits\n> the same fixed http.retryAfter delay on every retry. If that delay is\n> short (e.g., 1s), Git burns through all of its retries in just a few\n> seconds before a busy server can recover. If that delay is long, even a\n> brief glitch has to wait a long time on the first retry.\n>\n> Pass the 1-based retry attempt count to handle_rate_limit_retry() and\n> double http.retryAfter on each subsequent retry (1s, 2s, 4s, ..., via\n> http.retryAfter * 2^(attempt - 1)), clamping the delay at\n> http.maxRetryTime. This keeps the first retry fast while giving the\n> server more time to recover on later attempts, matching curl's own\n> --retry behavior. When the server provides an explicit Retry-After\n> header, continue to honor the server-provided delay (or fail fast if it\n> exceeds http.maxRetryTime).\n\nThe logic makes perfect sense (I am taking the above description at\nface value without checking if this really matches cURL's behavior\nmyself, though).\n\n>  http.retryAfter::\n> +\tInitial wait time in seconds before retrying when a server returns\n>  \tHTTP 429 (Too Many Requests) without a Retry-After header.\n> +\tDefaults to 0 (retry immediately). When set to a positive value and\n> +\tno Retry-After header is present, the delay doubles on each\n> +\tsubsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n> +\tcapped at `http.maxRetryTime`. When a Retry-After header is\n\nIt somehow feels that the default value of 0 (immediate retry) does\nnot mesh well with exponential backoff.  Will we keep retrying\nimmediately after failing until we run out of retries?\n\nAlso, it feels somewhat inconsistent with how maxretrytime is\ncurrently treated.  Specifically, when the other side tells us via\nRetry-After, or the configuration specifies via http.retryAfter, to\nwait longer than maxretrytime, instead of capping the wait, we\nimmediately give up without retrying.  But when retryAfter grows\nwith exponential backoff, the wait is capped at maxretrytime instead\nof resulting in an immediate failure.\n\nThe handling of maxretrytime in this definition, where it caps the\nwaiting time before retrying, is definitely more useful than the\ntraditional \"if we are told to wait longer than this, we fail\ninstead of capping\", I would think.\n\n> @@ -348,7 +351,9 @@ http.maxRetryTime::\n>  \thandling HTTP 429 (Too Many Requests) responses. If the server\n>  \trequests a delay (via Retry-After header) or if `http.retryAfter`\n>  \tis configured with a value that exceeds this maximum, Git will fail\n> -\timmediately rather than waiting. Default is 300 seconds (5 minutes).\n> +\timmediately rather than waiting. Exponentially backed-off delays\n> +\tcomputed from `http.retryAfter` are capped at this maximum. Default\n> +\tis 300 seconds (5 minutes).\n\n> -static long handle_rate_limit_retry(long slot_retry_after)\n> +static long handle_rate_limit_retry(long slot_retry_after, int attempt)\n>  {\n>  \t/* Use the slot-specific retry_after value or configured default */\n>  \tif (slot_retry_after >= 0) {\n> @@ -2427,6 +2427,8 @@ static long handle_rate_limit_retry(long slot_retry_after)\n>  \t\t}\n>  \t\treturn slot_retry_after;\n>  \t} else {\n> +\t\tlong delay = http_retry_after;\n> +\n>  \t\t/* No Retry-After header provided, use configured default */\n>  \t\tif (http_retry_after > http_max_retry_time) {\n>  \t\t\terror(_(\"configured http.retryAfter exceeds http.maxRetryTime (%ld > %ld seconds)\"),\n> @@ -2437,7 +2439,14 @@ static long handle_rate_limit_retry(long slot_retry_after)\n>  \t\t}\n>  \t\ttrace2_data_string(\"http\", the_repository,\n>  \t\t\t\"http/429-retry-source\", \"config-default\");\n> -\t\treturn http_retry_after;\n> +\t\tfor (int a = 1; a < attempt && delay > 0; a++) {\n> +\t\t\tif (delay >= http_max_retry_time - delay) {\n> +\t\t\t\tdelay = http_max_retry_time;\n> +\t\t\t\tbreak;\n> +\t\t\t}\n> +\t\t\tdelay *= 2;\n> +\t\t}\n> +\t\treturn delay;\n>  \t}\n>  }\n\nI guess what is in the loop is a tricky arithmetic to compute\n\n\tdelay = ((delay * 2) > http_max_retry_time)\n\t\t? http_max_retry_time\n\t\t: (delay * 2);\n\nwithout integer overflow?  Very effective.\n\n\n> @@ -2449,6 +2458,7 @@ static int http_request_recoverable(const char *url,\n>  \tint i = 3;\n>  \tint ret;\n>  \tint rate_limit_retries = http_max_retries;\n> +\tint attempt = 0;\n>  \n>  \tif (!options)\n>  \t\toptions = &empty_opts;\n> @@ -2498,7 +2508,7 @@ static int http_request_recoverable(const char *url,\n>  \t\t\tBUG(\"Unknown http_request target\");\n>  \t\t}\n>  \t\tif (ret == HTTP_RATE_LIMITED) {\n> -\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after);\n> +\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);\n>  \t\t\tif (retry_delay < 0)\n>  \t\t\t\treturn HTTP_ERROR;\n\nInteresting.\n\nWe already have to deal with the maximum number of retries to honor\nhttp_max_retries, so we must be counting how many times we have\nretried so far.  But we still need to introduce another variable to\ncount the number of attempts.\n\nOr would it suffice to pass (http_max_retries - rate_limit_retries)\nin place of the 'attempt' parameter?\n\nNot complaining.  Just making an observation.\n\n"},{"id":"557317","messageId":"xmqqld7aav7p.fsf@gitster.g","threadId":"66729","inReplyTo":"f56ab2afaaecfbd64c4460be5d95637054623e4a.1793462224.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 3/4] http: add http.retryStatusCodes config option","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-11-03T13:41:14Z","receivedAt":"2026-11-03T13:41:14Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Ravi Mistry via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n>  http.retryAfter::\n>  \tInitial wait time in seconds before retrying when a server returns\n> -\tHTTP 429 (Too Many Requests) without a Retry-After header.\n> +\ta retryable HTTP status code (HTTP 429 by default) without a\n> +\tRetry-After header.\n\nThis, and ...\n\n>  http.maxRetries::\n> -\tMaximum number of times to retry after receiving HTTP 429 (Too Many\n> -\tRequests) responses. Set to 0 (the default) to disable retries.\n> +\tMaximum number of times to retry after receiving a retryable HTTP\n> +\tstatus code (HTTP 429 by default). Set to 0 (the default) to disable\n> +\tretries.\n\nthis, and ...\n\n>  \tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n> -\tSee also `http.retryAfter` and `http.maxRetryTime`.\n> +\tSee also `http.retryAfter`, `http.maxRetryTime`, and\n> +\t`http.retryStatusCodes`.\n>  \n>  http.maxRetryTime::\n>  \tMaximum time in seconds to wait for a single retry attempt when\n> -\thandling HTTP 429 (Too Many Requests) responses. If the server\n> -\trequests a delay (via Retry-After header) or if `http.retryAfter`\n> +\thandling a retryable HTTP status code (HTTP 429 by default). If the\n> +\tserver requests a delay (via Retry-After header) or if `http.retryAfter`\n\nthis all share two issues.\n\n - \"Too Many Requests\", which is a helpful comment to highlight what\n   429 is, is lost, and\n\n - What we want to say is that there is a customizable set of HTTP\n   response codes that we treat as \"the other side invites us to\n   retry if we want to\", and that HTTP 429 is the ONLY member of\n   that set by default.  But \"HTTP 429 by default\" alone in the\n   above description does not quite say so.\n\nIt is unlikely that the reader realizes that we are talking about\nthe default membership in a set until ...\n\n> +http.retryStatusCodes::\n> +\tComma- or whitespace-separated list of HTTP status codes (in the\n> +\t400..599 range) that should be retried up to `http.maxRetries`\n> +\ttimes. Defaults to `429`. For example, setting this to\n> +\t`429,502,503,504` also retries transient `5xx` gateway and\n> +\tservice-unavailable errors. Can be overridden by the\n> +\t`GIT_HTTP_RETRY_STATUS_CODES` environment variable.\n> +\tSee also `http.maxRetries`, `http.retryAfter`, and\n> +\t`http.maxRetryTime`.\n\n... they read this, which is very clearly written.\n\n> diff --git a/http.c b/http.c\n> index 0dec6377dd..950e2bbffa 100644\n> --- a/http.c\n> +++ b/http.c\n> @@ -156,6 +156,7 @@ static int http_schannel_check_revoke = 1;\n>  static long http_retry_after = 0;\n>  static long http_max_retries = 0;\n>  static long http_max_retry_time = 300;\n> +static char *http_retry_status_codes;\n>  \n>  /*\n>   * With the backend being set to `schannel`, setting sslCAinfo would override\n> @@ -607,6 +608,11 @@ static int http_options(const char *var, const char *value,\n>  \t\treturn 0;\n>  \t}\n>  \n> +\tif (!strcmp(\"http.retrystatuscodes\", var)) {\n> +\t\tFREE_AND_NULL(http_retry_status_codes);\n> +\t\treturn git_config_string(&http_retry_status_codes, var, value);\n> +\t}\n\nOK, the input is a simple string.\n\n> +static int is_retryable_http_code(long code)\n> +{\n> +\tconst char *p = http_retry_status_codes ? http_retry_status_codes : \"429\";\n> +\tchar *end;\n> +\n> +\tfor (; code >= 400 && code < 600 && *p; p = end) {\n> +\t\twhile (*p == ',' || isspace(*p))\n> +\t\t\tp++;\n> +\t\tif (strtol(p, &end, 10) == code && end > p &&\n> +\t\t    (!*end || *end == ',' || isspace(*end)))\n> +\t\t\treturn 1;\n> +\t\twhile (*end && *end != ',' && !isspace(*end))\n> +\t\t\tend++;\n> +\t}\n> +\treturn 0;\n> +}\n\nAhh, we parse the string every time?\n\nI guess it is not performance-critical (after all, we are talking\nabout waiting and then retrying here), but I somehow imagined we\nwould allocate an array of ints and pre-parse the string upon\nreading the configuration variable.\n\nAlso 400 <= code < 600 is a constant throughout the function and it\nis a tad strange to see it in the loop control part of a for()\nstatement.\n\nIn any case, the strtol() based parsing looks cleanly done.\n\nThanks.\n\n"},{"id":"557321","messageId":"xmqqh5hyas5b.fsf@gitster.g","threadId":"66729","inReplyTo":"52819accc894e760a0fd06730d20f46cc8da9914.1793462224.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 4/4] remote-curl: retry transient HTTP errors in post_rpc()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-11-03T14:47:28Z","receivedAt":"2026-11-03T14:47:28Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Ravi Mistry via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n> diff --git a/http.c b/http.c\n> index 950e2bbffa..36b559e596 100644\n> --- a/http.c\n> +++ b/http.c\n> @@ -2476,6 +2476,34 @@ static long handle_rate_limit_retry(long slot_retry_after, int attempt)\n>  \t}\n>  }\n>  \n> +int http_retry_wait(long http_code, long slot_retry_after, int attempt)\n> +{\n> +\tlong retry_delay;\n> +\n> +\tif (http_max_retries <= 0)\n> +\t\treturn -1;\n> +\tif (attempt > http_max_retries) {\n> +\t\ttrace2_data_string(\"http\", the_repository,\n> +\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n> +\t\treturn -1;\n> +\t}\n> +\tretry_delay = handle_rate_limit_retry(slot_retry_after, attempt);\n> +\tif (retry_delay < 0)\n> +\t\treturn -1;\n> +\tif (retry_delay > 0) {\n> +\t\tif (http_code == 429)\n> +\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n> +\t\t\t\tretry_delay);\n> +\t\telse\n> +\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n> +\t\t\t\thttp_code, retry_delay);\n> +\t\ttrace2_data_intmax(\"http\", the_repository,\n> +\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n> +\t\tsleep(retry_delay);\n> +\t}\n> +\treturn 0;\n> +}\n\nThis code looks familiar.  Beautifully extracted from ...\n\n>  static int http_request_recoverable(const char *url,\n>  \t\t\t       void *result, int target,\n>  \t\t\t       struct http_get_options *options)\n> @@ -2483,7 +2511,6 @@ static int http_request_recoverable(const char *url,\n>  \tstatic struct http_get_options empty_opts;\n>  \tint i = 3;\n>  \tint ret;\n> -\tint rate_limit_retries = http_max_retries;\n>  \tint attempt = 0;\n>  \n>  \tif (!options)\n> @@ -2506,9 +2533,8 @@ static int http_request_recoverable(const char *url,\n>  \t}\n>  \n>  \twhile ((ret == HTTP_REAUTH && --i) ||\n> -\t       (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&\n> -\t\trate_limit_retries-- > 0)) {\n> -\t\tlong retry_delay = -1;\n> +\t       (ret == HTTP_RATE_LIMITED &&\n> +\t\t!http_retry_wait(options->http_code, options->retry_after, ++attempt))) {\n>  \t\t/*\n>  \t\t * The previous request may have put cruft into our output stream; we\n>  \t\t * should clear it out before making our next request.\n> @@ -2533,35 +2559,15 @@ static int http_request_recoverable(const char *url,\n>  \t\tdefault:\n>  \t\t\tBUG(\"Unknown http_request target\");\n>  \t\t}\n> -\t\tif (ret == HTTP_RATE_LIMITED) {\n> -\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);\n> -\t\t\tif (retry_delay < 0)\n> -\t\t\t\treturn HTTP_ERROR;\n> -\n> -\t\t\tif (retry_delay > 0) {\n> -\t\t\t\tif (options->http_code == 429)\n> -\t\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n> -\t\t\t\t\t\tretry_delay);\n> -\t\t\t\telse\n> -\t\t\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n> -\t\t\t\t\t\toptions->http_code, retry_delay);\n> -\t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n> -\t\t\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n> -\t\t\t\tsleep(retry_delay);\n> -\t\t\t}\n> -\t\t} else if (ret == HTTP_REAUTH) {\n> +\t\tif (ret == HTTP_REAUTH)\n>  \t\t\thttp_reauth_prepare(1);\n> -\t\t}\n\n... existing code around here.  It always feels good to see that an\nintroduction of a new function is accompanied by replacing many\nlines of existsing code with a call to the new function.\n\nThanks.\n\n"},{"id":"557351","messageId":"pull.2287.v2.git.1793732247.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.git.1793462224.gitgitgadget@gmail.com","subject":"[PATCH v2 0/4] http: exponential backoff, configurable status codes, and post_rpc() retries","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-11-03T18:57:23Z","receivedAt":"2026-11-03T18:57:23Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"Commit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n2026-03-17) added HTTP 429 retry support using http.maxRetries,\nhttp.retryAfter, and http.maxRetryTime.\n\nIn practice, when running Git against hosting backends and reverse proxies\nunder high concurrency, four gaps remain:\n\n 1. Off-by-one retry count and Retry-After Trace2 timing: Because\n    http_request_recoverable() pre-decrements the retry counter\n    (--rate_limit_retries) in its loop condition, setting http.maxRetries=1\n    performs 0 retries (and N configured retries performs N-1 retries). In\n    addition, the Retry-After header is extracted after Trace2 logging has\n    already run, so the http/429-retry-after Trace2 event always logs -1.\n\n 2. No exponential backoff when Retry-After is absent: When a server does\n    not send a Retry-After header, Git waits the same fixed http.retryAfter\n    delay on every retry. If that delay is short (e.g., 1s), Git burns\n    through all of its retries in just a few seconds before a busy server\n    can recover. If that delay is long, even a brief glitch has to wait a\n    long time on the first retry. Doubling the delay on each attempt (1s,\n    2s, 4s, ...) keeps the first retry fast while giving the server more\n    time to recover on later attempts, matching curl's own --retry behavior.\n\n 3. Transient 5xx errors cannot be retried: Reverse proxies, load balancers,\n    and Git backends frequently surface transient overload or connection\n    resets as 502 Bad Gateway, 503 Service Unavailable, or 504 Gateway\n    Timeout. Because 5xx errors are not universally transient across all\n    environments, 429 should remain the default, with an opt-in config to\n    specify additional status codes.\n\n 4. HTTP POST requests are not retried: Git's existing 429 retry logic only\n    covers HTTP GET requests (such as the initial /info/refs handshake).\n    However, the actual data transfer during fetch and clone, as well as\n    on-demand blob downloads in partial clones (during checkout, diff, or\n    blame), are sent as HTTP POST requests (/git-upload-pack) via\n    post_rpc(), which only retries authentication and immediately aborts on\n    429 or 5xx.\n\nThis 4-patch series addresses all four gaps:\n\n * Patch 1/4 fixes the off-by-one retry count bug and extracts Retry-After\n   in finish_active_slot() before Trace2 logging runs.\n * Patch 2/4 doubles http.retryAfter on each subsequent retry attempt (1s,\n   2s, 4s, capped at http.maxRetryTime) when Retry-After is absent.\n * Patch 3/4 adds http.retryStatusCodes (and GIT_HTTP_RETRY_STATUS_CODES),\n   defaulting to \"429\", so environments can opt into retrying codes like\n   \"429,502,503,504\".\n * Patch 4/4 extracts a shared http_retry_wait() helper and extends retry\n   support to HTTP POST requests in probe_rpc() and post_rpc() (while fixing\n   a missing Content-Encoding: gzip header on retry).\n\nPrior discussions:\n\n * https://lore.kernel.org/git/20201012184806.166251-3-smcallis@google.com/\n * https://lore.kernel.org/git/pull.2008.v6.git.1773752435.gitgitgadget@gmail.com/\n\nChanges since v1:\n\n * Patch 1/4: Clarified in the commit message why the http_max_retries <= 0\n   check was moved after the retry loop.\n * Patch 2/4: Passed http_max_retries - rate_limit_retries instead of adding\n   a separate attempt counter, and clarified the http.retryAfter and\n   http.maxRetryTime docs.\n * Patch 3/4: Restored (Too Many Requests) and clarified\n   http.retryStatusCodes in the config docs, and moved the 400..599 range\n   check to the top of is_retryable_http_code().\n * Patch 4/4: Added the attempt counter here when replacing\n   rate_limit_retries with http_retry_wait().\n\nRavi Mistry (4):\n  http: fix retry count and Retry-After timing\n  http: add exponential backoff without Retry-After\n  http: add http.retryStatusCodes config option\n  remote-curl: retry transient HTTP errors in post_rpc()\n\n Documentation/config/http.adoc |  52 ++++++++---\n http.c                         | 125 ++++++++++++++++++--------\n http.h                         |  12 ++-\n remote-curl.c                  |  75 ++++++++--------\n t/lib-httpd/http-429.sh        |  63 ++++++++++++--\n t/t5584-http-429-retry.sh      | 155 +++++++++++++++++++++++++++++++++\n 6 files changed, 385 insertions(+), 97 deletions(-)\n\n\nbase-commit: 8d1b215c01512b9658d4525c54ff9b9245f9a7e8\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2287%2Frmistry%2Fhttp-exponential-retries-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2287/rmistry/http-exponential-retries-v2\nPull-Request: https://github.com/gitgitgadget/git/pull/2287\n\nRange-diff vs v1:\n\n 1:  80b7f207f4 ! 1:  d53778dd46 http: fix retry count and Retry-After timing\n     @@ Commit message\n             --rate_limit_retries wraps from 0 to -1 (truthy in C).\n      \n             Fix this by checking http_max_retries > 0 && rate_limit_retries-- > 0\n     -       in the loop condition and returning HTTP_ERROR when\n     -       http_max_retries <= 0.\n     +       in the loop condition and checking http_max_retries <= 0 after the\n     +       loop (which handles both an initial 429 and a 429 after 401 re-auth\n     +       without logging \"retries-exhausted\", making the pre-loop check\n     +       redundant).\n      \n          2. In http_request(), CURLINFO_RETRY_AFTER is queried after\n             run_one_slot() has already called handle_curl_result(), which logs\n 2:  36cef34668 ! 2:  fe1e847ded http: add exponential backoff without Retry-After\n     @@ Commit message\n          http.retryAfter * 2^(attempt - 1)), clamping the delay at\n          http.maxRetryTime. This keeps the first retry fast while giving the\n          server more time to recover on later attempts, matching curl's own\n     -    --retry behavior. When the server provides an explicit Retry-After\n     -    header, continue to honor the server-provided delay (or fail fast if it\n     -    exceeds http.maxRetryTime).\n     +    --retry behavior (while keeping http.retryAfter's existing default of 0\n     +    for immediate retries when unconfigured). When the server provides an\n     +    explicit Retry-After header, continue to honor the server-provided delay\n     +    (or fail fast if it exceeds http.maxRetryTime).\n      \n          Signed-off-by: Ravi Mistry <rmistry@google.com>\n      \n     @@ Documentation/config/http.adoc: http.keepAliveCount::\n      +\tInitial wait time in seconds before retrying when a server returns\n       \tHTTP 429 (Too Many Requests) without a Retry-After header.\n      -\tDefaults to 0 (retry immediately). When a Retry-After header is\n     -+\tDefaults to 0 (retry immediately). When set to a positive value and\n     -+\tno Retry-After header is present, the delay doubles on each\n     -+\tsubsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n     ++\tDefaults to 0, which retries immediately without waiting on every\n     ++\tattempt (unless a Retry-After header is present). When set to a\n     ++\tpositive value and no Retry-After header is present, the first\n     ++\tretry waits `http.retryAfter` seconds and the delay doubles on\n     ++\teach subsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n      +\tcapped at `http.maxRetryTime`. When a Retry-After header is\n       \tpresent, its value takes precedence over this setting; however,\n       \tautomatic use of the server-provided `Retry-After` header requires\n       \tlibcurl 7.66.0 or later. On older versions, configure this setting\n     -@@ Documentation/config/http.adoc: http.maxRetryTime::\n     - \thandling HTTP 429 (Too Many Requests) responses. If the server\n     - \trequests a delay (via Retry-After header) or if `http.retryAfter`\n     - \tis configured with a value that exceeds this maximum, Git will fail\n     +@@ Documentation/config/http.adoc: http.maxRetries::\n     + \n     + http.maxRetryTime::\n     + \tMaximum time in seconds to wait for a single retry attempt when\n     +-\thandling HTTP 429 (Too Many Requests) responses. If the server\n     +-\trequests a delay (via Retry-After header) or if `http.retryAfter`\n     +-\tis configured with a value that exceeds this maximum, Git will fail\n      -\timmediately rather than waiting. Default is 300 seconds (5 minutes).\n     -+\timmediately rather than waiting. Exponentially backed-off delays\n     -+\tcomputed from `http.retryAfter` are capped at this maximum. Default\n     -+\tis 300 seconds (5 minutes).\n     ++\thandling HTTP 429 (Too Many Requests) responses.\n     ++\tIf the server requests a delay (via Retry-After header) or if the\n     ++\tconfigured `http.retryAfter` value itself exceeds this maximum,\n     ++\tGit will fail immediately rather than waiting. Exponentially\n     ++\tbacked-off delays computed from `http.retryAfter`, however, are\n     ++\tcapped at this maximum rather than causing an immediate failure.\n     ++\tDefault is 300 seconds (5 minutes).\n       \tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n       \tvariable. See also `http.retryAfter` and `http.maxRetries`.\n       \n     @@ http.c: static long handle_rate_limit_retry(long slot_retry_after)\n       \t}\n       }\n       \n     -@@ http.c: static int http_request_recoverable(const char *url,\n     - \tint i = 3;\n     - \tint ret;\n     - \tint rate_limit_retries = http_max_retries;\n     -+\tint attempt = 0;\n     - \n     - \tif (!options)\n     - \t\toptions = &empty_opts;\n      @@ http.c: static int http_request_recoverable(const char *url,\n       \t\t\tBUG(\"Unknown http_request target\");\n       \t\t}\n       \t\tif (ret == HTTP_RATE_LIMITED) {\n      -\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after);\n     -+\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);\n     ++\t\t\tretry_delay = handle_rate_limit_retry(\n     ++\t\t\t\toptions->retry_after,\n     ++\t\t\t\thttp_max_retries - rate_limit_retries);\n       \t\t\tif (retry_delay < 0)\n       \t\t\t\treturn HTTP_ERROR;\n       \n 3:  f56ab2afaa ! 3:  6510dddc39 http: add http.retryStatusCodes config option\n     @@ Documentation/config/http.adoc: http.keepAliveCount::\n       http.retryAfter::\n       \tInitial wait time in seconds before retrying when a server returns\n      -\tHTTP 429 (Too Many Requests) without a Retry-After header.\n     -+\ta retryable HTTP status code (HTTP 429 by default) without a\n     -+\tRetry-After header.\n     - \tDefaults to 0 (retry immediately). When set to a positive value and\n     - \tno Retry-After header is present, the delay doubles on each\n     - \tsubsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n     ++\ta retryable HTTP status code without a Retry-After header. The\n     ++\tset of retryable status codes is configured by\n     ++\t`http.retryStatusCodes`, which includes only HTTP 429 (Too Many\n     ++\tRequests) by default.\n     + \tDefaults to 0, which retries immediately without waiting on every\n     + \tattempt (unless a Retry-After header is present). When set to a\n     + \tpositive value and no Retry-After header is present, the first\n      @@ Documentation/config/http.adoc: http.retryAfter::\n       \tlibcurl 7.66.0 or later. On older versions, configure this setting\n       \tmanually to control the retry delay. Can be overridden by the\n     @@ Documentation/config/http.adoc: http.retryAfter::\n      -\tMaximum number of times to retry after receiving HTTP 429 (Too Many\n      -\tRequests) responses. Set to 0 (the default) to disable retries.\n      +\tMaximum number of times to retry after receiving a retryable HTTP\n     -+\tstatus code (HTTP 429 by default). Set to 0 (the default) to disable\n     -+\tretries.\n     ++\tstatus code. The set of retryable status codes is configured by\n     ++\t`http.retryStatusCodes`, which includes only HTTP 429 (Too Many\n     ++\tRequests) by default. Set to 0 (the default) to disable retries.\n       \tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n      -\tSee also `http.retryAfter` and `http.maxRetryTime`.\n      +\tSee also `http.retryAfter`, `http.maxRetryTime`, and\n     @@ Documentation/config/http.adoc: http.retryAfter::\n       \n       http.maxRetryTime::\n       \tMaximum time in seconds to wait for a single retry attempt when\n     --\thandling HTTP 429 (Too Many Requests) responses. If the server\n     --\trequests a delay (via Retry-After header) or if `http.retryAfter`\n     -+\thandling a retryable HTTP status code (HTTP 429 by default). If the\n     -+\tserver requests a delay (via Retry-After header) or if `http.retryAfter`\n     - \tis configured with a value that exceeds this maximum, Git will fail\n     - \timmediately rather than waiting. Exponentially backed-off delays\n     - \tcomputed from `http.retryAfter` are capped at this maximum. Default\n     - \tis 300 seconds (5 minutes).\n     +-\thandling HTTP 429 (Too Many Requests) responses.\n     ++\thandling a retryable HTTP status code. The set of retryable\n     ++\tstatus codes is configured by `http.retryStatusCodes`, which\n     ++\tincludes only HTTP 429 (Too Many Requests) by default.\n     + \tIf the server requests a delay (via Retry-After header) or if the\n     + \tconfigured `http.retryAfter` value itself exceeds this maximum,\n     + \tGit will fail immediately rather than waiting. Exponentially\n     +@@ Documentation/config/http.adoc: http.maxRetryTime::\n     + \tcapped at this maximum rather than causing an immediate failure.\n     + \tDefault is 300 seconds (5 minutes).\n       \tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n      -\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n      +\tvariable. See also `http.retryAfter`, `http.maxRetries`, and\n     @@ Documentation/config/http.adoc: http.retryAfter::\n      +http.retryStatusCodes::\n      +\tComma- or whitespace-separated list of HTTP status codes (in the\n      +\t400..599 range) that should be retried up to `http.maxRetries`\n     -+\ttimes. Defaults to `429`. For example, setting this to\n     -+\t`429,502,503,504` also retries transient `5xx` gateway and\n     ++\ttimes. Defaults to `429` (Too Many Requests). For example, setting\n     ++\tthis to `429,502,503,504` also retries transient `5xx` gateway and\n      +\tservice-unavailable errors. Can be overridden by the\n      +\t`GIT_HTTP_RETRY_STATUS_CODES` environment variable.\n      +\tSee also `http.maxRetries`, `http.retryAfter`, and\n     @@ http.c: void normalize_curl_result(CURLcode *result, long http_code,\n      +\tconst char *p = http_retry_status_codes ? http_retry_status_codes : \"429\";\n      +\tchar *end;\n      +\n     -+\tfor (; code >= 400 && code < 600 && *p; p = end) {\n     ++\tif (code < 400 || code >= 600)\n     ++\t\treturn 0;\n     ++\n     ++\tfor (; *p; p = end) {\n      +\t\twhile (*p == ',' || isspace(*p))\n      +\t\t\tp++;\n      +\t\tif (strtol(p, &end, 10) == code && end > p &&\n 4:  52819accc8 ! 4:  786964ff00 remote-curl: retry transient HTTP errors in post_rpc()\n     @@ http.c: static int http_request_recoverable(const char *url,\n       \tint i = 3;\n       \tint ret;\n      -\tint rate_limit_retries = http_max_retries;\n     - \tint attempt = 0;\n     ++\tint attempt = 0;\n       \n       \tif (!options)\n     + \t\toptions = &empty_opts;\n      @@ http.c: static int http_request_recoverable(const char *url,\n       \t}\n       \n     @@ http.c: static int http_request_recoverable(const char *url,\n       \t\t\tBUG(\"Unknown http_request target\");\n       \t\t}\n      -\t\tif (ret == HTTP_RATE_LIMITED) {\n     --\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after, ++attempt);\n     +-\t\t\tretry_delay = handle_rate_limit_retry(\n     +-\t\t\t\toptions->retry_after,\n     +-\t\t\t\thttp_max_retries - rate_limit_retries);\n      -\t\t\tif (retry_delay < 0)\n      -\t\t\t\treturn HTTP_ERROR;\n      -\n\n-- \ngitgitgadget\n\n"},{"id":"557352","messageId":"d53778dd4635bd7dfb5b115a23f3642ea3ae40e8.1793732247.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.v2.git.1793732247.gitgitgadget@gmail.com","subject":"[PATCH v2 1/4] http: fix retry count and Retry-After timing","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-11-03T18:57:24Z","receivedAt":"2026-11-03T18:57:24Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nCommit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n2026-03-17) introduced two small bugs in HTTP 429 retry handling:\n\n1. In http_request_recoverable(), rate_limit_retries is initialized to\n   http_max_retries (the number of retries to perform after the initial\n   failed request) and decremented with pre-decrement\n   (--rate_limit_retries) in the while condition. Because of the\n   pre-decrement, setting http.maxRetries=1 performs 0 retries, and\n   more generally N configured retries only retries N-1 times. In\n   addition, if the first request returns 401 (HTTP_REAUTH) and the\n   re-authenticated request returns 429 when http.maxRetries is 0,\n   --rate_limit_retries wraps from 0 to -1 (truthy in C).\n\n   Fix this by checking http_max_retries > 0 && rate_limit_retries-- > 0\n   in the loop condition and checking http_max_retries <= 0 after the\n   loop (which handles both an initial 429 and a 429 after 401 re-auth\n   without logging \"retries-exhausted\", making the pre-loop check\n   redundant).\n\n2. In http_request(), CURLINFO_RETRY_AFTER is queried after\n   run_one_slot() has already called handle_curl_result(), which logs\n   results->retry_after to Trace2 (\"http/429-retry-after\") while it is\n   still -1. Move the CURLINFO_RETRY_AFTER extraction into\n   finish_active_slot() (initializing slot->results->retry_after = -1\n   first) so that both handle_curl_result() and callers of\n   run_one_slot() observe the parsed Retry-After value.\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n http.c                    | 27 +++++++++++++--------------\n t/lib-httpd/http-429.sh   | 31 ++++++++++++++++++++++++++-----\n t/t5584-http-429-retry.sh | 21 +++++++++++++++++++++\n 3 files changed, 60 insertions(+), 19 deletions(-)\n\ndiff --git a/http.c b/http.c\nindex d79678d01f..0308f973a6 100644\n--- a/http.c\n+++ b/http.c\n@@ -345,11 +345,20 @@ static void finish_active_slot(struct active_request_slot *slot)\n \tif (slot->results) {\n \t\tslot->results->curl_result = slot->curl_result;\n \t\tslot->results->http_code = slot->http_code;\n+\t\tslot->results->retry_after = -1;\n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTPAUTH_AVAIL,\n \t\t\t\t  &slot->results->auth_avail);\n \n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n \t\t\t&slot->results->http_connectcode);\n+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t\t{\n+\t\t\tcurl_off_t retry_after;\n+\t\t\tif (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,\n+\t\t\t\t\t      &retry_after) == CURLE_OK && retry_after > 0)\n+\t\t\t\tslot->results->retry_after = (long)retry_after;\n+\t\t}\n+#endif\n \t}\n \n \t/* Run callback if appropriate */\n@@ -2286,15 +2295,6 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n-#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n-\tif (ret == HTTP_RATE_LIMITED) {\n-\t\tcurl_off_t retry_after;\n-\t\tif (curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER,\n-\t\t\t\t      &retry_after) == CURLE_OK && retry_after > 0)\n-\t\t\tresults.retry_after = (long)retry_after;\n-\t}\n-#endif\n-\n \toptions->retry_after = results.retry_after;\n \n \tif (options->content_type) {\n@@ -2461,10 +2461,6 @@ static int http_request_recoverable(const char *url,\n \tif (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)\n \t\treturn ret;\n \n-\t/* If retries are disabled and we got a 429, fail immediately */\n-\tif (ret == HTTP_RATE_LIMITED && !http_max_retries)\n-\t\treturn HTTP_ERROR;\n-\n \tif (options->effective_url && options->base_url) {\n \t\tif (update_url_from_redirect(options->base_url,\n \t\t\t\t\t     url, options->effective_url)) {\n@@ -2474,7 +2470,8 @@ static int http_request_recoverable(const char *url,\n \t}\n \n \twhile ((ret == HTTP_REAUTH && --i) ||\n-\t       (ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {\n+\t       (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&\n+\t\trate_limit_retries-- > 0)) {\n \t\tlong retry_delay = -1;\n \t\t/*\n \t\t * The previous request may have put cruft into our output stream; we\n@@ -2518,6 +2515,8 @@ static int http_request_recoverable(const char *url,\n \t\tret = http_request(url, result, target, options);\n \t}\n \tif (ret == HTTP_RATE_LIMITED) {\n+\t\tif (http_max_retries <= 0)\n+\t\t\treturn HTTP_ERROR;\n \t\ttrace2_data_string(\"http\", the_repository,\n \t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n \t\treturn HTTP_RATE_LIMITED;\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nindex 1a5d7987db..5537fb7ac4 100644\n--- a/t/lib-httpd/http-429.sh\n+++ b/t/lib-httpd/http-429.sh\n@@ -32,12 +32,33 @@ repo_name=\"${repo_path%%/*}\"\n safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n state=\"http-429-state-${safe_name}\"\n \n-# Check if this is the first call (no state directory exists), or if\n-# the retry-after-value is \"permanent\", which indicates a 429 must be\n-# returned for every request (even if the state directory exists).\n-if test \"$retry_after\" != permanent && ! mkdir \"$state\" 2>/dev/null\n+max_fails=1\n+case \"$test_context\" in\n+\t*-fail-2*) max_fails=2 ;;\n+\t*-fail-3*) max_fails=3 ;;\n+esac\n+\n+should_fail=0\n+if test \"$retry_after\" = permanent\n+then\n+\tshould_fail=1\n+else\n+\tmkdir -p \"$state\" 2>/dev/null\n+\ti=1\n+\twhile test $i -le $max_fails\n+\tdo\n+\t\tif mkdir \"$state/$i\" 2>/dev/null\n+\t\tthen\n+\t\t\tshould_fail=1\n+\t\t\tbreak\n+\t\tfi\n+\t\ti=$((i + 1))\n+\tdone\n+fi\n+\n+if test $should_fail -eq 0\n then\n-\t# Already returned 429 once, forward to git-http-backend\n+\t# Already returned 429 max_fails times, forward to git-http-backend\n \t# Set PATH_INFO to just the repo path (without retry-after value)\n \t# Set GIT_PROJECT_ROOT so git-http-backend can find the repository\n \t# Use exec to replace this process so git-http-backend gets the updated environment\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex a22007b2cf..d92a26893d 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -51,6 +51,27 @@ test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'http.maxRetries=1 retries once and succeeds' '\n+\ttest_config http.maxRetries 1 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/max-retries-one/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'http.maxRetries=2 fails after 3 failures and succeeds on 2 failures' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/max-retries-two-fail-3/none/repo.git\" 2>err &&\n+\ttest_grep \"rate limited\" err &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/max-retries-two-fail-2/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'Trace2 logs actual CURLINFO_RETRY_AFTER value instead of -1' '\n+\ttest_config http.maxRetries 1 &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace-retry-after.event\" \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/trace2-retry-after/1/repo.git\" >output 2>err &&\n+\ttest_grep \"\\\"key\\\":\\\"http/429-retry-after\\\",\\\"value\\\":\\\"1\\\"\" trace-retry-after.event\n+'\n+\n test_expect_success 'HTTP 429 without Retry-After uses configured default' '\n \t# Enable retries and configure default delay\n \ttest_config http.maxRetries 3 &&\n-- \ngitgitgadget\n\n\n"},{"id":"557353","messageId":"fe1e847dede7579da911b5a9ba16c806b5332723.1793732247.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.v2.git.1793732247.gitgitgadget@gmail.com","subject":"[PATCH v2 2/4] http: add exponential backoff without Retry-After","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-11-03T18:57:25Z","receivedAt":"2026-11-03T18:57:25Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nWhen a server returns HTTP 429 without a Retry-After header, Git waits\nthe same fixed http.retryAfter delay on every retry. If that delay is\nshort (e.g., 1s), Git burns through all of its retries in just a few\nseconds before a busy server can recover. If that delay is long, even a\nbrief glitch has to wait a long time on the first retry.\n\nPass the 1-based retry attempt count to handle_rate_limit_retry() and\ndouble http.retryAfter on each subsequent retry (1s, 2s, 4s, ..., via\nhttp.retryAfter * 2^(attempt - 1)), clamping the delay at\nhttp.maxRetryTime. This keeps the first retry fast while giving the\nserver more time to recover on later attempts, matching curl's own\n--retry behavior (while keeping http.retryAfter's existing default of 0\nfor immediate retries when unconfigured). When the server provides an\nexplicit Retry-After header, continue to honor the server-provided delay\n(or fail fast if it exceeds http.maxRetryTime).\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n Documentation/config/http.adoc | 20 ++++++++++++++------\n http.c                         | 17 ++++++++++++++---\n t/t5584-http-429-retry.sh      | 33 +++++++++++++++++++++++++++++++++\n 3 files changed, 61 insertions(+), 9 deletions(-)\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 792a71b413..8e7538ab76 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -327,9 +327,14 @@ http.keepAliveCount::\n \t`GIT_HTTP_KEEPALIVE_COUNT` environment variable.\n \n http.retryAfter::\n-\tDefault wait time in seconds before retrying when a server returns\n+\tInitial wait time in seconds before retrying when a server returns\n \tHTTP 429 (Too Many Requests) without a Retry-After header.\n-\tDefaults to 0 (retry immediately). When a Retry-After header is\n+\tDefaults to 0, which retries immediately without waiting on every\n+\tattempt (unless a Retry-After header is present). When set to a\n+\tpositive value and no Retry-After header is present, the first\n+\tretry waits `http.retryAfter` seconds and the delay doubles on\n+\teach subsequent retry attempt (`http.retryAfter * 2^(attempt - 1)`),\n+\tcapped at `http.maxRetryTime`. When a Retry-After header is\n \tpresent, its value takes precedence over this setting; however,\n \tautomatic use of the server-provided `Retry-After` header requires\n \tlibcurl 7.66.0 or later. On older versions, configure this setting\n@@ -345,10 +350,13 @@ http.maxRetries::\n \n http.maxRetryTime::\n \tMaximum time in seconds to wait for a single retry attempt when\n-\thandling HTTP 429 (Too Many Requests) responses. If the server\n-\trequests a delay (via Retry-After header) or if `http.retryAfter`\n-\tis configured with a value that exceeds this maximum, Git will fail\n-\timmediately rather than waiting. Default is 300 seconds (5 minutes).\n+\thandling HTTP 429 (Too Many Requests) responses.\n+\tIf the server requests a delay (via Retry-After header) or if the\n+\tconfigured `http.retryAfter` value itself exceeds this maximum,\n+\tGit will fail immediately rather than waiting. Exponentially\n+\tbacked-off delays computed from `http.retryAfter`, however, are\n+\tcapped at this maximum rather than causing an immediate failure.\n+\tDefault is 300 seconds (5 minutes).\n \tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n \tvariable. See also `http.retryAfter` and `http.maxRetries`.\n \ndiff --git a/http.c b/http.c\nindex 0308f973a6..8a28cd0fb3 100644\n--- a/http.c\n+++ b/http.c\n@@ -2411,7 +2411,7 @@ static int update_url_from_redirect(struct strbuf *base,\n  * Returns a negative value if configuration is invalid (delay exceeds\n  * http.maxRetryTime), otherwise returns the delay in seconds (>= 0).\n  */\n-static long handle_rate_limit_retry(long slot_retry_after)\n+static long handle_rate_limit_retry(long slot_retry_after, int attempt)\n {\n \t/* Use the slot-specific retry_after value or configured default */\n \tif (slot_retry_after >= 0) {\n@@ -2427,6 +2427,8 @@ static long handle_rate_limit_retry(long slot_retry_after)\n \t\t}\n \t\treturn slot_retry_after;\n \t} else {\n+\t\tlong delay = http_retry_after;\n+\n \t\t/* No Retry-After header provided, use configured default */\n \t\tif (http_retry_after > http_max_retry_time) {\n \t\t\terror(_(\"configured http.retryAfter exceeds http.maxRetryTime (%ld > %ld seconds)\"),\n@@ -2437,7 +2439,14 @@ static long handle_rate_limit_retry(long slot_retry_after)\n \t\t}\n \t\ttrace2_data_string(\"http\", the_repository,\n \t\t\t\"http/429-retry-source\", \"config-default\");\n-\t\treturn http_retry_after;\n+\t\tfor (int a = 1; a < attempt && delay > 0; a++) {\n+\t\t\tif (delay >= http_max_retry_time - delay) {\n+\t\t\t\tdelay = http_max_retry_time;\n+\t\t\t\tbreak;\n+\t\t\t}\n+\t\t\tdelay *= 2;\n+\t\t}\n+\t\treturn delay;\n \t}\n }\n \n@@ -2498,7 +2507,9 @@ static int http_request_recoverable(const char *url,\n \t\t\tBUG(\"Unknown http_request target\");\n \t\t}\n \t\tif (ret == HTTP_RATE_LIMITED) {\n-\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after);\n+\t\t\tretry_delay = handle_rate_limit_retry(\n+\t\t\t\toptions->retry_after,\n+\t\t\t\thttp_max_retries - rate_limit_retries);\n \t\t\tif (retry_delay < 0)\n \t\t\t\treturn HTTP_ERROR;\n \ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex d92a26893d..f0c3cc3b61 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -82,6 +82,39 @@ test_expect_success 'HTTP 429 without Retry-After uses configured default' '\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'exponential backoff doubles http.retryAfter without Retry-After header' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/exp-backoff-fail-3/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting 1 seconds before retry\" err &&\n+\ttest_grep \"waiting 2 seconds before retry\" err &&\n+\ttest_grep \"waiting 4 seconds before retry\" err\n+'\n+\n+test_expect_success 'exponential backoff clamps delay at http.maxRetryTime' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\ttest_config http.maxRetryTime 2 &&\n+\tGIT_TRACE2_EVENT=\"$(pwd)/trace-clamp.event\" \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/exp-clamp-fail-3/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting 1 seconds before retry\" err &&\n+\ttest_grep \"waiting 2 seconds before retry\" err &&\n+\ttest_grep ! \"waiting 4 seconds before retry\" err &&\n+\tgrep \"\\\"key\\\":\\\"http/retry-sleep-seconds\\\",\\\"value\\\":\\\"2\\\"\" trace-clamp.event >clamped &&\n+\ttest_line_count = 2 clamped\n+'\n+\n+test_expect_success 'server Retry-After header overrides exponential backoff' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_config http.retryAfter 1 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/header-override-fail-2/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"waiting 2 seconds before retry\" err\n+'\n+\n test_expect_success 'HTTP 429 retry delays are respected' '\n \t# Enable retries\n \ttest_config http.maxRetries 3 &&\n-- \ngitgitgadget\n\n\n"},{"id":"557354","messageId":"6510dddc39ca61fe1ae2f81cb8e7febd5f82d5ca.1793732247.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.v2.git.1793732247.gitgitgadget@gmail.com","subject":"[PATCH v2 3/4] http: add http.retryStatusCodes config option","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-11-03T18:57:26Z","receivedAt":"2026-11-03T18:57:26Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nCommit 640657ffd0 (http: add support for HTTP 429 rate limit retries,\n2026-03-17) hardcoded HTTP 429 as the only status code eligible for\nretry. In practice, reverse proxies, load balancers, and Git hosting\nbackends also return transient 5xx responses such as 502 Bad Gateway,\n503 Service Unavailable (which RFC 9110 section 10.2.3 defines to\nsupport Retry-After), and 504 Gateway Timeout during load shedding or\nupstream connection resets.\n\nBecause 5xx responses are not universally transient across all\nenvironments, keep 429 as the default and introduce a new\nhttp.retryStatusCodes configuration option (and corresponding\nGIT_HTTP_RETRY_STATUS_CODES environment variable) accepting a comma- or\nwhitespace-separated list of HTTP status codes in the 400..599 range\n(for example, \"429,502,503,504\").\n\nKeep the existing \"rate limited\" warning and error messages for HTTP\n429, and report the numeric status code (e.g., \"HTTP 502, waiting <N>\nseconds before retry\") when retrying other status codes.\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n Documentation/config/http.adoc | 34 +++++++++++++++++++-----\n http.c                         | 42 ++++++++++++++++++++++++++---\n http.h                         |  6 +++--\n t/lib-httpd/http-429.sh        | 10 +++++--\n t/t5584-http-429-retry.sh      | 48 ++++++++++++++++++++++++++++++++++\n 5 files changed, 125 insertions(+), 15 deletions(-)\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 8e7538ab76..e7fa251b65 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -328,7 +328,10 @@ http.keepAliveCount::\n \n http.retryAfter::\n \tInitial wait time in seconds before retrying when a server returns\n-\tHTTP 429 (Too Many Requests) without a Retry-After header.\n+\ta retryable HTTP status code without a Retry-After header. The\n+\tset of retryable status codes is configured by\n+\t`http.retryStatusCodes`, which includes only HTTP 429 (Too Many\n+\tRequests) by default.\n \tDefaults to 0, which retries immediately without waiting on every\n \tattempt (unless a Retry-After header is present). When set to a\n \tpositive value and no Retry-After header is present, the first\n@@ -340,17 +343,23 @@ http.retryAfter::\n \tlibcurl 7.66.0 or later. On older versions, configure this setting\n \tmanually to control the retry delay. Can be overridden by the\n \t`GIT_HTTP_RETRY_AFTER` environment variable.\n-\tSee also `http.maxRetries` and `http.maxRetryTime`.\n+\tSee also `http.maxRetries`, `http.maxRetryTime`, and\n+\t`http.retryStatusCodes`.\n \n http.maxRetries::\n-\tMaximum number of times to retry after receiving HTTP 429 (Too Many\n-\tRequests) responses. Set to 0 (the default) to disable retries.\n+\tMaximum number of times to retry after receiving a retryable HTTP\n+\tstatus code. The set of retryable status codes is configured by\n+\t`http.retryStatusCodes`, which includes only HTTP 429 (Too Many\n+\tRequests) by default. Set to 0 (the default) to disable retries.\n \tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n-\tSee also `http.retryAfter` and `http.maxRetryTime`.\n+\tSee also `http.retryAfter`, `http.maxRetryTime`, and\n+\t`http.retryStatusCodes`.\n \n http.maxRetryTime::\n \tMaximum time in seconds to wait for a single retry attempt when\n-\thandling HTTP 429 (Too Many Requests) responses.\n+\thandling a retryable HTTP status code. The set of retryable\n+\tstatus codes is configured by `http.retryStatusCodes`, which\n+\tincludes only HTTP 429 (Too Many Requests) by default.\n \tIf the server requests a delay (via Retry-After header) or if the\n \tconfigured `http.retryAfter` value itself exceeds this maximum,\n \tGit will fail immediately rather than waiting. Exponentially\n@@ -358,7 +367,18 @@ http.maxRetryTime::\n \tcapped at this maximum rather than causing an immediate failure.\n \tDefault is 300 seconds (5 minutes).\n \tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n-\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\tvariable. See also `http.retryAfter`, `http.maxRetries`, and\n+\t`http.retryStatusCodes`.\n+\n+http.retryStatusCodes::\n+\tComma- or whitespace-separated list of HTTP status codes (in the\n+\t400..599 range) that should be retried up to `http.maxRetries`\n+\ttimes. Defaults to `429` (Too Many Requests). For example, setting\n+\tthis to `429,502,503,504` also retries transient `5xx` gateway and\n+\tservice-unavailable errors. Can be overridden by the\n+\t`GIT_HTTP_RETRY_STATUS_CODES` environment variable.\n+\tSee also `http.maxRetries`, `http.retryAfter`, and\n+\t`http.maxRetryTime`.\n \n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\ndiff --git a/http.c b/http.c\nindex 8a28cd0fb3..fc9384353c 100644\n--- a/http.c\n+++ b/http.c\n@@ -156,6 +156,7 @@ static int http_schannel_check_revoke = 1;\n static long http_retry_after = 0;\n static long http_max_retries = 0;\n static long http_max_retry_time = 300;\n+static char *http_retry_status_codes;\n \n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n@@ -607,6 +608,11 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retrystatuscodes\", var)) {\n+\t\tFREE_AND_NULL(http_retry_status_codes);\n+\t\treturn git_config_string(&http_retry_status_codes, var, value);\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1518,6 +1524,7 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&http_retry_after, \"GIT_HTTP_RETRY_AFTER\");\n \tset_long_from_env(&http_max_retries, \"GIT_HTTP_MAX_RETRIES\");\n \tset_long_from_env(&http_max_retry_time, \"GIT_HTTP_MAX_RETRY_TIME\");\n+\tset_from_env(&http_retry_status_codes, \"GIT_HTTP_RETRY_STATUS_CODES\");\n \n \tcurl_default = get_curl_handle();\n }\n@@ -1579,6 +1586,7 @@ void http_cleanup(void)\n \tproxy_ssl_cert_password_required = 0;\n \n \tFREE_AND_NULL(cached_accept_language);\n+\tFREE_AND_NULL(http_retry_status_codes);\n }\n \n struct active_request_slot *get_active_slot(void)\n@@ -1928,6 +1936,26 @@ void normalize_curl_result(CURLcode *result, long http_code,\n \t}\n }\n \n+static int is_retryable_http_code(long code)\n+{\n+\tconst char *p = http_retry_status_codes ? http_retry_status_codes : \"429\";\n+\tchar *end;\n+\n+\tif (code < 400 || code >= 600)\n+\t\treturn 0;\n+\n+\tfor (; *p; p = end) {\n+\t\twhile (*p == ',' || isspace(*p))\n+\t\t\tp++;\n+\t\tif (strtol(p, &end, 10) == code && end > p &&\n+\t\t    (!*end || *end == ',' || isspace(*end)))\n+\t\t\treturn 1;\n+\t\twhile (*end && *end != ',' && !isspace(*end))\n+\t\t\tend++;\n+\t}\n+\treturn 0;\n+}\n+\n static int handle_curl_result(struct slot_results *results)\n {\n \tnormalize_curl_result(&results->curl_result, results->http_code,\n@@ -1981,7 +2009,7 @@ static int handle_curl_result(struct slot_results *results)\n \t\t\t}\n \t\t\treturn HTTP_REAUTH;\n \t\t}\n-\t} else if (results->http_code == 429) {\n+\t} else if (is_retryable_http_code(results->http_code)) {\n \t\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n \t\t\tresults->retry_after);\n \t\treturn HTTP_RATE_LIMITED;\n@@ -2296,6 +2324,7 @@ static int http_request(const char *url,\n \tret = run_one_slot(slot, &results);\n \n \toptions->retry_after = results.retry_after;\n+\toptions->http_code = results.http_code;\n \n \tif (options->content_type) {\n \t\tstruct strbuf raw = STRBUF_INIT;\n@@ -2407,7 +2436,7 @@ static int update_url_from_redirect(struct strbuf *base,\n }\n \n /*\n- * Compute the retry delay for an HTTP 429 response.\n+ * Compute the retry delay for a retryable HTTP response.\n  * Returns a negative value if configuration is invalid (delay exceeds\n  * http.maxRetryTime), otherwise returns the delay in seconds (>= 0).\n  */\n@@ -2514,7 +2543,12 @@ static int http_request_recoverable(const char *url,\n \t\t\t\treturn HTTP_ERROR;\n \n \t\t\tif (retry_delay > 0) {\n-\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_delay);\n+\t\t\t\tif (options->http_code == 429)\n+\t\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n+\t\t\t\t\t\tretry_delay);\n+\t\t\t\telse\n+\t\t\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n+\t\t\t\t\t\toptions->http_code, retry_delay);\n \t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n \t\t\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n \t\t\t\tsleep(retry_delay);\n@@ -2530,7 +2564,7 @@ static int http_request_recoverable(const char *url,\n \t\t\treturn HTTP_ERROR;\n \t\ttrace2_data_string(\"http\", the_repository,\n \t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n-\t\treturn HTTP_RATE_LIMITED;\n+\t\treturn options->http_code == 429 ? HTTP_RATE_LIMITED : HTTP_ERROR;\n \t}\n \treturn ret;\n }\ndiff --git a/http.h b/http.h\nindex 729c51904d..5985a0f71f 100644\n--- a/http.h\n+++ b/http.h\n@@ -167,10 +167,12 @@ struct http_get_options {\n \n \t/*\n \t * After a request completes, contains the Retry-After delay in seconds\n-\t * if the server returned HTTP 429 with a Retry-After header (requires\n-\t * libcurl 7.66.0 or later), or -1 if no such header was present.\n+\t * if the server returned a retryable status code with a Retry-After\n+\t * header (requires libcurl 7.66.0 or later), or -1 if no such header\n+\t * was present.\n \t */\n \tlong retry_after;\n+\tlong http_code;\n };\n \n /* Return values for http_get_*() */\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nindex 5537fb7ac4..5c7d58a75f 100644\n--- a/t/lib-httpd/http-429.sh\n+++ b/t/lib-httpd/http-429.sh\n@@ -75,8 +75,14 @@ then\n \texec \"$GIT_EXEC_PATH/git-http-backend\"\n fi\n \n-# Output HTTP 429 response\n-printf \"Status: 429 Too Many Requests\\r\\n\"\n+# Output HTTP 429 (or configured status) response\n+status_line=\"429 Too Many Requests\"\n+case \"$test_context\" in\n+\t*-status-502*) status_line=\"502 Bad Gateway\" ;;\n+\t*-status-503*) status_line=\"503 Service Unavailable\" ;;\n+\t*-status-504*) status_line=\"504 Gateway Timeout\" ;;\n+esac\n+printf \"Status: %s\\r\\n\" \"$status_line\"\n \n # Set Retry-After header based on retry_after value\n case \"$retry_after\" in\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex f0c3cc3b61..f3e7dbaaa4 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -317,4 +317,52 @@ test_expect_success 'verify normal repository access still works' '\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'HTTP 502/503/504 are not retried by default' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/default-no-retry-status-502/none/repo.git\" 2>err &&\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\ttest_grep \"The requested URL returned error: 502\" err\n+'\n+\n+test_expect_success 'http.retryStatusCodes retries configured 502, 503, and 504 errors' '\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\ttest_config http.retryStatusCodes \"429, 502, 503, 504\" &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-status-502/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"HTTP 502, waiting 1 seconds before retry\" err &&\n+\ttest_config http.retryAfter 0 &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-status-503/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-status-504/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 503 honors Retry-After header when present' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_config http.retryStatusCodes \"429,503\" &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/header-status-503/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"HTTP 503, waiting 1 seconds before retry\" err\n+'\n+\n+test_expect_success 'exhausted 502 retries report HTTP 502 error, not rate limited' '\n+\ttest_config http.maxRetries 2 &&\n+\ttest_config http.retryStatusCodes \"429,502\" &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/exhausted-fail-3-status-502/none/repo.git\" 2>err &&\n+\ttest_grep \"The requested URL returned error: 502\" err &&\n+\ttest_grep ! \"rate limited\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_STATUS_CODES and urlmatch http.<url>.retryStatusCodes' '\n+\ttest_config http.maxRetries 2 &&\n+\tGIT_HTTP_RETRY_STATUS_CODES=\"429,502\" \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-status-502/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_config \"http.$HTTPD_URL.retryStatusCodes\" \"429,503\" &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/urlmatch-status-503/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n test_done\n-- \ngitgitgadget\n\n\n"},{"id":"557355","messageId":"786964ff00368648e5bd0fd4c5889f7c105d0a51.1793732247.git.gitgitgadget@gmail.com","threadId":"66729","inReplyTo":"pull.2287.v2.git.1793732247.gitgitgadget@gmail.com","subject":"[PATCH v2 4/4] remote-curl: retry transient HTTP errors in post_rpc()","fromName":"Ravi Mistry via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-11-03T18:57:27Z","receivedAt":"2026-11-03T18:57:27Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"From: Ravi Mistry <rmistry@google.com>\n\nGit's existing retry logic only covers HTTP GET requests. However, the\nactual data transfer during fetch and clone, as well as on-demand blob\ndownloads in partial clones, are sent as HTTP POST requests via\npost_rpc(), which only retries authentication and immediately aborts on\n429 or 5xx.\n\nExtract the retry wait logic from http_request_recoverable() into a\nshared http_retry_wait() helper and use it to retry transient HTTP\nerrors in probe_rpc() and post_rpc(). Also defer reporting RPC errors\nuntil retries are exhausted, and re-attach the \"Content-Encoding: gzip\"\nheader when retrying a compressed request.\n\nSigned-off-by: Ravi Mistry <rmistry@google.com>\n---\n http.c                    | 67 ++++++++++++++++++----------------\n http.h                    |  6 ++++\n remote-curl.c             | 75 +++++++++++++++++++--------------------\n t/lib-httpd/http-429.sh   | 50 ++++++++++++++++++--------\n t/t5584-http-429-retry.sh | 53 +++++++++++++++++++++++++++\n 5 files changed, 168 insertions(+), 83 deletions(-)\n\ndiff --git a/http.c b/http.c\nindex fc9384353c..ea4f9245c4 100644\n--- a/http.c\n+++ b/http.c\n@@ -2479,6 +2479,34 @@ static long handle_rate_limit_retry(long slot_retry_after, int attempt)\n \t}\n }\n \n+int http_retry_wait(long http_code, long slot_retry_after, int attempt)\n+{\n+\tlong retry_delay;\n+\n+\tif (http_max_retries <= 0)\n+\t\treturn -1;\n+\tif (attempt > http_max_retries) {\n+\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n+\t\treturn -1;\n+\t}\n+\tretry_delay = handle_rate_limit_retry(slot_retry_after, attempt);\n+\tif (retry_delay < 0)\n+\t\treturn -1;\n+\tif (retry_delay > 0) {\n+\t\tif (http_code == 429)\n+\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n+\t\t\t\tretry_delay);\n+\t\telse\n+\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n+\t\t\t\thttp_code, retry_delay);\n+\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n+\t\tsleep(retry_delay);\n+\t}\n+\treturn 0;\n+}\n+\n static int http_request_recoverable(const char *url,\n \t\t\t       void *result, int target,\n \t\t\t       struct http_get_options *options)\n@@ -2486,7 +2514,7 @@ static int http_request_recoverable(const char *url,\n \tstatic struct http_get_options empty_opts;\n \tint i = 3;\n \tint ret;\n-\tint rate_limit_retries = http_max_retries;\n+\tint attempt = 0;\n \n \tif (!options)\n \t\toptions = &empty_opts;\n@@ -2508,9 +2536,8 @@ static int http_request_recoverable(const char *url,\n \t}\n \n \twhile ((ret == HTTP_REAUTH && --i) ||\n-\t       (ret == HTTP_RATE_LIMITED && http_max_retries > 0 &&\n-\t\trate_limit_retries-- > 0)) {\n-\t\tlong retry_delay = -1;\n+\t       (ret == HTTP_RATE_LIMITED &&\n+\t\t!http_retry_wait(options->http_code, options->retry_after, ++attempt))) {\n \t\t/*\n \t\t * The previous request may have put cruft into our output stream; we\n \t\t * should clear it out before making our next request.\n@@ -2535,37 +2562,15 @@ static int http_request_recoverable(const char *url,\n \t\tdefault:\n \t\t\tBUG(\"Unknown http_request target\");\n \t\t}\n-\t\tif (ret == HTTP_RATE_LIMITED) {\n-\t\t\tretry_delay = handle_rate_limit_retry(\n-\t\t\t\toptions->retry_after,\n-\t\t\t\thttp_max_retries - rate_limit_retries);\n-\t\t\tif (retry_delay < 0)\n-\t\t\t\treturn HTTP_ERROR;\n-\n-\t\t\tif (retry_delay > 0) {\n-\t\t\t\tif (options->http_code == 429)\n-\t\t\t\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"),\n-\t\t\t\t\t\tretry_delay);\n-\t\t\t\telse\n-\t\t\t\t\twarning(_(\"HTTP %ld, waiting %ld seconds before retry\"),\n-\t\t\t\t\t\toptions->http_code, retry_delay);\n-\t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n-\t\t\t\t\t\t   \"http/retry-sleep-seconds\", retry_delay);\n-\t\t\t\tsleep(retry_delay);\n-\t\t\t}\n-\t\t} else if (ret == HTTP_REAUTH) {\n+\t\tif (ret == HTTP_REAUTH)\n \t\t\thttp_reauth_prepare(1);\n-\t\t}\n \n \t\tret = http_request(url, result, target, options);\n \t}\n-\tif (ret == HTTP_RATE_LIMITED) {\n-\t\tif (http_max_retries <= 0)\n-\t\t\treturn HTTP_ERROR;\n-\t\ttrace2_data_string(\"http\", the_repository,\n-\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n-\t\treturn options->http_code == 429 ? HTTP_RATE_LIMITED : HTTP_ERROR;\n-\t}\n+\tif (ret == HTTP_RATE_LIMITED &&\n+\t    (http_max_retries <= 0 || attempt <= http_max_retries ||\n+\t     options->http_code != 429))\n+\t\treturn HTTP_ERROR;\n \treturn ret;\n }\n \ndiff --git a/http.h b/http.h\nindex 5985a0f71f..fa9f46a11b 100644\n--- a/http.h\n+++ b/http.h\n@@ -201,6 +201,12 @@ int http_get_strbuf(const char *url, struct strbuf *result, struct http_get_opti\n int http_get_file(const char *url, const char *filename,\n \t\t  struct http_get_options *options);\n \n+/*\n+ * Wait before retrying a transient HTTP error (1-based attempt count).\n+ * Returns 0 if the caller should retry, or -1 to stop retrying.\n+ */\n+int http_retry_wait(long http_code, long slot_retry_after, int attempt);\n+\n int http_fetch_ref(const char *base, struct ref *ref);\n \n struct curl_slist *http_append_auth_header(const struct credential *c,\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 2c35dd5240..d73aaebadb 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -845,35 +845,22 @@ static size_t rpc_in(char *ptr, size_t eltsize,\n \treturn size;\n }\n \n-static int run_slot(struct active_request_slot *slot,\n-\t\t    struct slot_results *results)\n+static void report_rpc_error(struct slot_results *results)\n {\n-\tint err;\n-\tstruct slot_results results_buf;\n-\n-\tif (!results)\n-\t\tresults = &results_buf;\n-\n-\terr = run_one_slot(slot, results);\n-\n-\tif (err != HTTP_OK && err != HTTP_REAUTH) {\n-\t\tstruct strbuf msg = STRBUF_INIT;\n-\t\tif (results->http_code && results->http_code != 200)\n-\t\t\tstrbuf_addf(&msg, \"HTTP %ld\", results->http_code);\n-\t\tif (results->curl_result != CURLE_OK) {\n-\t\t\tif (msg.len)\n-\t\t\t\tstrbuf_addch(&msg, ' ');\n-\t\t\tstrbuf_addf(&msg, \"curl %d\", results->curl_result);\n-\t\t\tif (curl_errorstr[0]) {\n-\t\t\t\tstrbuf_addch(&msg, ' ');\n-\t\t\t\tstrbuf_addstr(&msg, curl_errorstr);\n-\t\t\t}\n+\tstruct strbuf msg = STRBUF_INIT;\n+\tif (results->http_code && results->http_code != 200)\n+\t\tstrbuf_addf(&msg, \"HTTP %ld\", results->http_code);\n+\tif (results->curl_result != CURLE_OK) {\n+\t\tif (msg.len)\n+\t\t\tstrbuf_addch(&msg, ' ');\n+\t\tstrbuf_addf(&msg, \"curl %d\", results->curl_result);\n+\t\tif (curl_errorstr[0]) {\n+\t\t\tstrbuf_addch(&msg, ' ');\n+\t\t\tstrbuf_addstr(&msg, curl_errorstr);\n \t\t}\n-\t\terror(_(\"RPC failed; %s\"), msg.buf);\n-\t\tstrbuf_release(&msg);\n \t}\n-\n-\treturn err;\n+\terror(_(\"RPC failed; %s\"), msg.buf);\n+\tstrbuf_release(&msg);\n }\n \n static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)\n@@ -899,7 +886,7 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)\n \tcurl_easy_setopt(slot->curl, CURLOPT_WRITEFUNCTION, fwrite_buffer);\n \tcurl_easy_setopt(slot->curl, CURLOPT_WRITEDATA, &buf);\n \n-\terr = run_slot(slot, results);\n+\terr = run_one_slot(slot, results);\n \n \tcurl_slist_free_all(headers);\n \tstrbuf_release(&buf);\n@@ -913,12 +900,14 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results)\n static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_received)\n {\n \tstruct active_request_slot *slot;\n+\tstruct slot_results results = { 0 };\n \tstruct curl_slist *headers = NULL;\n \tint use_gzip = rpc->gzip_request;\n \tchar *gzip_body = NULL;\n \tsize_t gzip_size = 0;\n \tint err, large_request = 0;\n \tint needs_100_continue = 0;\n+\tint attempt = 0;\n \tstruct rpc_in_data rpc_in_data;\n \n \t/* Try to load the entire request, if we can fit it into the\n@@ -941,15 +930,18 @@ static int post_rpc(struct rpc_state *rpc, int stateless_connect, int flush_rece\n \t}\n \n \tif (large_request) {\n-\t\tstruct slot_results results;\n-\n \t\tdo {\n \t\t\terr = probe_rpc(rpc, &results);\n \t\t\tif (err == HTTP_REAUTH)\n \t\t\t\thttp_reauth_prepare(0);\n-\t\t} while (err == HTTP_REAUTH);\n-\t\tif (err != HTTP_OK)\n+\t\t} while (err == HTTP_REAUTH ||\n+\t\t\t (err == HTTP_RATE_LIMITED &&\n+\t\t\t  !http_retry_wait(results.http_code,\n+\t\t\t\t\t   results.retry_after, ++attempt)));\n+\t\tif (err != HTTP_OK) {\n+\t\t\treport_rpc_error(&results);\n \t\t\treturn -1;\n+\t\t}\n \n \t\tif (results.auth_avail & CURLAUTH_GSSNEGOTIATE || http_auth.authtype)\n \t\t\tneeds_100_continue = 1;\n@@ -998,10 +990,11 @@ retry:\n \n \t} else if (gzip_body) {\n \t\t/*\n-\t\t * If we are looping to retry authentication, then the previous\n-\t\t * run will have set up the headers and gzip buffer already,\n-\t\t * and we just need to send it.\n+\t\t * If we are looping to retry, then the previous run will\n+\t\t * have set up the gzip buffer already, and we just need to\n+\t\t * send it.\n \t\t */\n+\t\theaders = curl_slist_append(headers, \"Content-Encoding: gzip\");\n \t\tcurl_easy_setopt(slot->curl, CURLOPT_POSTFIELDS, gzip_body);\n \t\tcurl_easy_setopt(slot->curl, CURLOPT_POSTFIELDSIZE_LARGE, cast_size_t_to_curl_off_t(gzip_size));\n \n@@ -1066,14 +1059,20 @@ retry:\n \n \n \trpc->any_written = 0;\n-\terr = run_slot(slot, NULL);\n-\tif (err == HTTP_REAUTH && !large_request) {\n-\t\thttp_reauth_prepare(0);\n+\terr = run_one_slot(slot, &results);\n+\tif (!large_request && !rpc->any_written &&\n+\t    (err == HTTP_REAUTH ||\n+\t     (err == HTTP_RATE_LIMITED &&\n+\t      !http_retry_wait(results.http_code, results.retry_after, ++attempt)))) {\n+\t\tif (err == HTTP_REAUTH)\n+\t\t\thttp_reauth_prepare(0);\n \t\tcurl_slist_free_all(headers);\n \t\tgoto retry;\n \t}\n-\tif (err != HTTP_OK)\n+\tif (err != HTTP_OK) {\n+\t\treport_rpc_error(&results);\n \t\terr = -1;\n+\t}\n \n \tif (!rpc->any_written)\n \t\terr = -1;\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nindex 5c7d58a75f..ec70a4e9d1 100644\n--- a/t/lib-httpd/http-429.sh\n+++ b/t/lib-httpd/http-429.sh\n@@ -39,21 +39,36 @@ case \"$test_context\" in\n esac\n \n should_fail=0\n-if test \"$retry_after\" = permanent\n+case \"$test_context,$REQUEST_METHOD\" in\n+\t*-post-only*,POST)\n+\t\tcan_fail=1\n+\t\t;;\n+\t*-post-only*,*)\n+\t\tcan_fail=0\n+\t\t;;\n+\t*)\n+\t\tcan_fail=1\n+\t\t;;\n+esac\n+\n+if test $can_fail -eq 1\n then\n-\tshould_fail=1\n-else\n-\tmkdir -p \"$state\" 2>/dev/null\n-\ti=1\n-\twhile test $i -le $max_fails\n-\tdo\n-\t\tif mkdir \"$state/$i\" 2>/dev/null\n-\t\tthen\n-\t\t\tshould_fail=1\n-\t\t\tbreak\n-\t\tfi\n-\t\ti=$((i + 1))\n-\tdone\n+\tif test \"$retry_after\" = permanent\n+\tthen\n+\t\tshould_fail=1\n+\telse\n+\t\tmkdir -p \"$state\" 2>/dev/null\n+\t\ti=1\n+\t\twhile test $i -le $max_fails\n+\t\tdo\n+\t\t\tif mkdir \"$state/$i\" 2>/dev/null\n+\t\t\tthen\n+\t\t\t\tshould_fail=1\n+\t\t\t\tbreak\n+\t\t\tfi\n+\t\t\ti=$((i + 1))\n+\t\tdone\n+\tfi\n fi\n \n if test $should_fail -eq 0\n@@ -75,6 +90,13 @@ then\n \texec \"$GIT_EXEC_PATH/git-http-backend\"\n fi\n \n+# Drain POST request body before returning synthetic error so Apache mod_cgi\n+# does not reset the TCP connection.\n+if test \"$REQUEST_METHOD\" = POST\n+then\n+\tcat >/dev/null\n+fi\n+\n # Output HTTP 429 (or configured status) response\n status_line=\"429 Too Many Requests\"\n case \"$test_context\" in\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nindex f3e7dbaaa4..e627aa2b63 100755\n--- a/t/t5584-http-429-retry.sh\n+++ b/t/t5584-http-429-retry.sh\n@@ -365,4 +365,57 @@ test_expect_success 'GIT_HTTP_RETRY_STATUS_CODES and urlmatch http.<url>.retrySt\n \ttest_grep \"refs/heads/\" output\n '\n \n+test_expect_success 'Protocol v2 POST /git-upload-pack retries 429 and 502' '\n+\ttest_when_finished \"rm -rf clone-v2-429 clone-v2-502 clone-v2-fail\" &&\n+\ttest_config_global protocol.version 2 &&\n+\ttest_config_global http.maxRetries 3 &&\n+\ttest_config_global http.retryAfter 1 &&\n+\ttest_config_global http.retryStatusCodes \"429,502\" &&\n+\tgit clone \"$HTTPD_URL/http_429/v2-post-only-429/1/repo.git\" clone-v2-429 2>err &&\n+\ttest_grep \"rate limited, waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"RPC failed\" err &&\n+\tgit clone \"$HTTPD_URL/http_429/v2-post-only-status-502/1/repo.git\" clone-v2-502 2>err &&\n+\ttest_grep \"HTTP 502, waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"RPC failed\" err &&\n+\ttest_config_global http.maxRetries 1 &&\n+\ttest_config_global http.retryAfter 0 &&\n+\ttest_must_fail git clone \\\n+\t\t\"$HTTPD_URL/http_429/v2-post-only-fail-2-status-502/none/repo.git\" \\\n+\t\tclone-v2-fail 2>err &&\n+\ttest_grep \"RPC failed; HTTP 502\" err\n+'\n+\n+test_expect_success 'partial clone lazy blob fetch (promisor-remote) retries transient 502' '\n+\ttest_when_finished \"rm -rf partial\" &&\n+\ttest_config -C \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" uploadpack.allowfilter 1 &&\n+\ttest_config -C \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" uploadpack.allowanysha1inwant 1 &&\n+\tgit clone --filter=blob:none --no-checkout \"$HTTPD_URL/smart/repo.git\" partial &&\n+\tgit -C partial config remote.origin.url \\\n+\t\t\"$HTTPD_URL/http_429/promisor-post-only-status-502/1/repo.git\" &&\n+\tgit -C partial config http.maxRetries 3 &&\n+\tgit -C partial config http.retryAfter 1 &&\n+\tgit -C partial config http.retryStatusCodes \"429,502\" &&\n+\tgit -C partial checkout HEAD 2>err &&\n+\ttest_grep \"HTTP 502, waiting 1 seconds before retry\" err &&\n+\ttest_grep ! \"RPC failed\" err\n+'\n+\n+test_expect_success 'gzipped POST /git-upload-pack preserves Content-Encoding: gzip on retry' '\n+\ttest_when_finished \"rm -f curl.log gzip.headers\" &&\n+\ttest_config protocol.version 2 &&\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\tset -- &&\n+\tfor i in $(test_seq 30)\n+\tdo\n+\t\tset -- \"$@\" -o \"very-long-server-option-to-exceed-1024-bytes-$i\" || return 1\n+\tdone &&\n+\tGIT_TRACE_CURL=\"$(pwd)/curl.log\" git ls-remote \"$@\" \\\n+\t\t\"$HTTPD_URL/http_429/gzip-post-only-429/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"rate limited, waiting 1 seconds before retry\" err &&\n+\tgrep \"Content-Encoding: gzip\" curl.log >gzip.headers &&\n+\ttest_line_count = 2 gzip.headers\n+'\n+\n test_done\n-- \ngitgitgadget\n\n"},{"id":"557358","messageId":"CAF+mdida+tKjE884ZvsgLndzK8AvY0csYLzKPMmXkUpDF0r9ug@mail.gmail.com","threadId":"66729","inReplyTo":"xmqqpkwmenf3.fsf@gitster.g","subject":"Re: [PATCH 1/4] http: fix retry count and Retry-After timing","fromName":"Ravi Mistry","fromEmail":"rmistry@google.com","sentAt":"2026-11-03T19:05:56Z","receivedAt":"2026-11-03T19:05:56Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> > -\t/* If retries are disabled and we got a 429, fail immediately */\n> > -\tif (ret == HTTP_RATE_LIMITED && !http_max_retries)\n> > -\t\treturn HTTP_ERROR;\n> > -\n>\n> I am not sure I get this removal.  If it is because the change to\n> the next hunk (the condition to loop with the while statement)\n> already covers it, I guess it makes sense, because we never enter\n> the while() loop and we will return HTTP_ERROR more or less the same\n> way as the above if http_max_retries is false.\n\nYes, and moving that check to after the while() loop also handles the\ncase where the initial request returns 401 and the re-authenticated\nrequest inside the loop returns 429 when http.maxRetries is 0, without\nfalsely logging \"retries-exhausted\". I clarified this in the commit\nmessage in v2.\n\nThanks!\n\n"},{"id":"557359","messageId":"CAF+mdieDWEaVV3RT1xmxxpB6KPYOfN7mi_20vKnKe76C4vFP7w@mail.gmail.com","threadId":"66729","inReplyTo":"xmqqpkwmaw51.fsf@gitster.g","subject":"Re: [PATCH 2/4] http: add exponential backoff without Retry-After","fromName":"Ravi Mistry","fromEmail":"rmistry@google.com","sentAt":"2026-11-03T19:05:58Z","receivedAt":"2026-11-03T19:05:58Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> It somehow feels that the default value of 0 (immediate retry) does\n> not mesh well with exponential backoff.  Will we keep retrying\n> immediately after failing until we run out of retries?\n\nRight, when http.retryAfter is 0 (and no Retry-After header is sent),\nevery retry happens immediately without waiting. I kept 0 as the\ndefault to preserve the existing behavior from 640657ffd0 (and its\nt5584 tests). I made this explicit in the http.retryAfter and\nhttp.maxRetryTime documentation in v2.\n\n> Or would it suffice to pass (http_max_retries - rate_limit_retries)\n> in place of the 'attempt' parameter?\n>\n> Not complaining.  Just making an observation.\n\nPassing (http_max_retries - rate_limit_retries) here and only\nintroducing 'attempt' in Patch 4/4 is much cleaner, so I updated v2 to\ndo that.\n\nThanks!\n\n"},{"id":"557360","messageId":"CAF+mdiebXKufLYYubtMvxqA53Ovwig9o0ZZM6=30L58yAz9TJw@mail.gmail.com","threadId":"66729","inReplyTo":"xmqqld7aav7p.fsf@gitster.g","subject":"Re: [PATCH 3/4] http: add http.retryStatusCodes config option","fromName":"Ravi Mistry","fromEmail":"rmistry@google.com","sentAt":"2026-11-03T19:06:00Z","receivedAt":"2026-11-03T19:06:00Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n>  - \"Too Many Requests\", which is a helpful comment to highlight what\n>    429 is, is lost, and\n>\n>  - What we want to say is that there is a customizable set of HTTP\n>    response codes that we treat as \"the other side invites us to\n>    retry if we want to\", and that HTTP 429 is the ONLY member of\n>    that set by default.  But \"HTTP 429 by default\" alone in the\n>    above description does not quite say so.\n\nMakes sense. In v2 I restored \"Too Many Requests\" and stated explicitly\nin all three entries that the set of retryable status codes is\nconfigured by http.retryStatusCodes, which includes only HTTP 429 by\ndefault.\n\n> Also 400 <= code < 600 is a constant throughout the function and it\n> is a tad strange to see it in the loop control part of a for()\n> statement.\n\nI moved the 400 <= code < 600 check to an early return at the top of\nis_retryable_http_code() in v2.\n\nThanks!\n\n"},{"id":"557374","messageId":"xmqqik2d63cc.fsf@gitster.g","threadId":"66729","inReplyTo":"6510dddc39ca61fe1ae2f81cb8e7febd5f82d5ca.1793732247.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 3/4] http: add http.retryStatusCodes config option","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-11-03T20:56:51Z","receivedAt":"2026-11-03T20:56:51Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Ravi Mistry via GitGitGadget\" <gitgitgadget@gmail.com> writes:\n\n>  http.retryAfter::\n>  \tInitial wait time in seconds before retrying when a server returns\n> -\tHTTP 429 (Too Many Requests) without a Retry-After header.\n> +\ta retryable HTTP status code without a Retry-After header. The\n> +\tset of retryable status codes is configured by\n> +\t`http.retryStatusCodes`, which includes only HTTP 429 (Too Many\n> +\tRequests) by default.\n\nI as a lazy person would probably have written\n\n\t... when a server returns a retryable HTTP status code (see\n\t`http.retryStatusCodes`) without a Retry-After header.\n\nand omitted everything else, but this is better by being more self\ncontained ;-).\n\nLooking good.\n\nBy the way, after the dust settles, we should fix the formatting of\nthese variable name entries wholesale.  Looking at a few files in\nthe same directory, like log.adoc and grep.adoc:\n\n    $ git grep -h '^[^ ]*::$' Documentation/config/{log,grep}.adoc\n    `grep.lineNumber`::\n    `grep.column`::\n    `grep.patternType`::\n    `grep.extendedRegexp`::\n    `grep.threads`::\n    `grep.fullName`::\n    `grep.fallbackToNoIndex`::\n    `log.abbrevCommit`::\n    `log.date`::\n    `log.decorate`::\n    `log.initialDecorationSet`::\n    `log.excludeDecoration`::\n    `log.diffMerges`::\n    `log.follow`::\n    `log.graphColors`::\n    `log.graphIndent`::\n    `log.showRoot`::\n    `log.showSignature`::\n    `log.mailmap`::\n\nthese names are supposed to be marked up in certain ways, but none\nin this file is.\n\nAnyway, the whole series was a pleasant read.  Thanks.\n\n"},{"id":"557505","messageId":"auusPUaYRmUBDsdI@fruit.crustytoothpaste.net","threadId":"66729","inReplyTo":"fe1e847dede7579da911b5a9ba16c806b5332723.1793732247.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 2/4] http: add exponential backoff without Retry-After","fromName":"brian m. carlson","fromEmail":"sandals@crustytoothpaste.net","sentAt":"2026-11-04T22:05:50Z","receivedAt":"2026-11-04T22:05:50Z","isPatch":true,"sender":{"key":"sandals@crustytoothpaste.net","avatar":"https://avatars.githubusercontent.com/u/497054?v=4"},"body":"On 2026-11-03 at 18:57:25, Ravi Mistry via GitGitGadget wrote:\n> From: Ravi Mistry <rmistry@google.com>\n> \n> When a server returns HTTP 429 without a Retry-After header, Git waits\n> the same fixed http.retryAfter delay on every retry. If that delay is\n> short (e.g., 1s), Git burns through all of its retries in just a few\n> seconds before a busy server can recover. If that delay is long, even a\n> brief glitch has to wait a long time on the first retry.\n> \n> Pass the 1-based retry attempt count to handle_rate_limit_retry() and\n> double http.retryAfter on each subsequent retry (1s, 2s, 4s, ..., via\n> http.retryAfter * 2^(attempt - 1)), clamping the delay at\n> http.maxRetryTime. This keeps the first retry fast while giving the\n> server more time to recover on later attempts, matching curl's own\n> --retry behavior (while keeping http.retryAfter's existing default of 0\n> for immediate retries when unconfigured). When the server provides an\n> explicit Retry-After header, continue to honor the server-provided delay\n> (or fail fast if it exceeds http.maxRetryTime).\n\nWe should not immediately retry on a 429 response, even if that's the\nbehaviour we have now.  The server told us we were being too aggressive,\nso the exact thing we should not do is to continue making requests.  As\na server operator, I definitely don't want this behaviour; instead, we\nshould pick something sensible if no Retry-After is given, such as 30\nseconds.\n-- \nbrian m. carlson (they/them)\nToronto, Ontario, CA\nSpeaking for myself only\n"},{"id":"557520","messageId":"CAF+mdifcp+qkESZzd=8SmrL6eckxAQXR-4nxxSuL=xCV48OhVQ@mail.gmail.com","threadId":"66729","inReplyTo":"auusPUaYRmUBDsdI@fruit.crustytoothpaste.net","subject":"Re: [PATCH v2 2/4] http: add exponential backoff without Retry-After","fromName":"Ravi Mistry","fromEmail":"rmistry@google.com","sentAt":"2026-11-05T00:10:10Z","receivedAt":"2026-11-05T00:10:10Z","isPatch":true,"sender":{"key":"rmistry@google.com","avatar":null},"body":"\"brian m. carlson\" <sandals@crustytoothpaste.net> writes:\n\n> We should not immediately retry on a 429 response, even if that's the\n> behaviour we have now.  The server told us we were being too aggressive,\n> so the exact thing we should not do is to continue making requests.  As\n> a server operator, I definitely don't want this behaviour; instead, we\n> should pick something sensible if no Retry-After is given, such as 30\n> seconds.\n\nMakes sense. Junio noted on v1 as well that defaulting http.retryAfter\nto 0 does not mesh well with exponential backoff.\n\nWhat do you think about defaulting http.retryAfter to 1 second, matching\ncurl's --retry behavior (1s, 2s, 4s, ...)? Because the delay doubles on\neach attempt (and Patch 3/4 also reuses http.retryAfter for opt-in\ntransient 5xx retries), starting at 30s would turn 3 retries into 30s,\n60s, and 120s. Starting at 1s avoids hammering the server immediately\nwhile ramping up on repeated failures.\n\nIf 1s sounds reasonable, I can send a v3 with that change.\n\nThanks!\n\n"}]}