{"thread":{"id":"64535","subject":"[PATCH 0/3] http: add support for HTTP 429 rate limit retries","startedAt":"2025-11-26T12:30:31Z","lastAt":"2026-03-23T06:58:40Z","messageCount":49,"participants":["Vaidas Pilkauskas via GitGitGadget","Taylor Blau","Vaidas Pilkauskas","Jeff King","Junio C Hamano"],"isPatch":true,"patchVersion":1,"patchTotal":3},"messages":[{"id":"531292","messageId":"pull.2008.git.1764160227.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":null,"subject":"[PATCH 0/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-11-26T12:30:24Z","receivedAt":"2025-11-26T12:30:31Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"This patch series adds support for handling HTTP 429 (Too Many Requests)\nresponses in Git's HTTP client with automatic retry logic.\n\nGit hosting services can implement rate limiting to protect their\ninfrastructure. When these limits are reached, servers respond with HTTP 429\nstatus codes, potentially including a Retry-After header to indicate when\nthe client should retry. Currently, Git treats these responses as fatal\nerrors, forcing users to manually retry their operations.\n\nThis series implements automatic retry support with three new configuration\noptions:\n\n * http.maxRetries: Controls the maximum number of retry attempts (default:\n   0, opt-in behavior)\n\n * http.retryAfter: Provides a fallback delay when the server doesn't\n   include a Retry-After header (default: -1, fail if no header)\n\n * http.maxRetryTime: Sets an upper limit on any single retry delay\n   (default: 300 seconds) to prevent indefinite blocking\n\nThe implementation includes:\n\nPatch 1: Core HTTP 429 retry logic with support for RFC-compliant\nRetry-After headers (both delay-seconds and HTTP-date formats),\ncomprehensive configuration options, and fail-fast behavior for excessive\ndelays. Includes extensive test coverage.\n\nPatch 2: Fixes a pre-existing memory leak in show_http_message() that became\nmore visible with the new retry logic.\n\nPatch 3: Adds trace2 instrumentation to enable monitoring and debugging of\nretry operations in production environments.\n\nThe retry behavior is disabled by default (maxRetries = 0), requiring\nexplicit opt-in, ensuring backward compatibility while providing a robust\nsolution for environments that need rate limit handling.\n\nThere was a previous attempt to add retry support [1], which was not merged.\nIt had support for 50x status codes. Should they be supported here too?\n\n[1] https://lore.kernel.org/git/20201012184806.166251-3-smcallis@google.com/\n\nVaidas Pilkauskas (3): http: add support for HTTP 429 rate limit retries\nremote-curl: fix memory leak in show_http_message() http: add trace2 logging\nfor retry operations\n\nDocumentation/config/http.adoc | 24 ++ http-push.c | 8 + http-walker.c | 5 +\nhttp.c | 171 ++++++++++++- http.h | 2 + remote-curl.c | 18 +- t/meson.build\n| 1 + t/t5584-http-429-retry.sh | 429 +++++++++++++++++++++++++++++++++ 8\nfiles changed, 650 insertions(+), 8 deletions(-) create mode 100755\nt/t5584-http-429-retry.sh\n\n-- 2.50.1\n\nVaidas Pilkauskas (3):\n  http: add support for HTTP 429 rate limit retries\n  remote-curl: fix memory leak in show_http_message()\n  http: add trace2 logging for retry operations\n\n Documentation/config/http.adoc |  24 ++\n http-push.c                    |   8 +\n http-walker.c                  |   5 +\n http.c                         | 171 ++++++++++++-\n http.h                         |   2 +\n remote-curl.c                  |  18 +-\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 429 +++++++++++++++++++++++++++++++++\n 8 files changed, 650 insertions(+), 8 deletions(-)\n create mode 100755 t/t5584-http-429-retry.sh\n\n\nbase-commit: 6ab38b7e9cc7adafc304f3204616a4debd49c6e9\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2008%2Fvaidas-shopify%2Fretry-after-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2008/vaidas-shopify/retry-after-v1\nPull-Request: https://github.com/gitgitgadget/git/pull/2008\n-- \ngitgitgadget\n"},{"id":"531293","messageId":"ae0087cd1c7fbb6b748d6767b476c1bd1a19996f.1764160227.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.git.1764160227.gitgitgadget@gmail.com","subject":"[PATCH 1/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-11-26T12:30:25Z","receivedAt":"2025-11-26T12:30:33Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd retry logic for HTTP 429 (Too Many Requests) responses to handle\nserver-side rate limiting gracefully. When Git's HTTP client receives\na 429 response, it can now automatically retry the request after an\nappropriate delay, respecting the server's rate limits.\n\nThe implementation supports the RFC-compliant Retry-After header in\nboth delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\npast date is provided, Git retries immediately without waiting.\n\nRetry behavior is controlled by three new configuration options:\n\n  * http.maxRetries: Maximum number of retry attempts (default: 0,\n    meaning retries are disabled by default). Users must explicitly\n    opt-in to retry behavior.\n\n  * http.retryAfter: Default delay in seconds when the server doesn't\n    provide a Retry-After header (default: -1, meaning fail if no\n    header is provided). This serves as a fallback mechanism.\n\n  * http.maxRetryTime: Maximum delay in seconds for a single retry\n    (default: 300). If the server requests a delay exceeding this\n    limit, Git fails immediately rather than waiting. This prevents\n    indefinite blocking on unreasonable server requests.\n\nAll three options can be overridden via environment variables:\nGIT_HTTP_MAX_RETRIES, GIT_HTTP_RETRY_AFTER, and\nGIT_HTTP_MAX_RETRY_TIME.\n\nThe retry logic implements a fail-fast approach: if any delay\n(whether from server header or configuration) exceeds maxRetryTime,\nGit fails immediately with a clear error message rather than capping\nthe delay. This provides better visibility into rate limiting issues.\n\nThe implementation includes extensive test coverage for basic retry\nbehavior, Retry-After header formats (integer and HTTP-date),\nconfiguration combinations, maxRetryTime limits, invalid header\nhandling, environment variable overrides, and edge cases.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n Documentation/config/http.adoc |  24 ++\n http-push.c                    |   8 +\n http-walker.c                  |   5 +\n http.c                         | 149 +++++++++++-\n http.h                         |   2 +\n remote-curl.c                  |   4 +\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 429 +++++++++++++++++++++++++++++++++\n 8 files changed, 618 insertions(+), 4 deletions(-)\n create mode 100755 t/t5584-http-429-retry.sh\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 9da5c298cc..9e3c888df4 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -315,6 +315,30 @@ http.keepAliveCount::\n \tunset, curl's default value is used. Can be overridden by the\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+\tHTTP 429 (Too Many Requests) without a Retry-After header. If set\n+\tto -1 (the default), Git will fail immediately when encountering\n+\ta 429 response without a Retry-After header. When a Retry-After\n+\theader is present, its value takes precedence over this setting.\n+\tCan be overridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n+\tSee also `http.maxRetries` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n+\tSee also `http.retryAfter` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n+\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\n \tThis can be helpful with some \"poor\" ftp servers which don't\ndiff --git a/http-push.c b/http-push.c\nindex d86ce77119..a602a302ec 100644\n--- a/http-push.c\n+++ b/http-push.c\n@@ -716,6 +716,10 @@ static int fetch_indices(void)\n \tcase HTTP_MISSING_TARGET:\n \t\tret = 0;\n \t\tbreak;\n+\tcase HTTP_RATE_LIMITED:\n+\t\terror(\"rate limited by '%s', please try again later\", repo->url);\n+\t\tret = -1;\n+\t\tbreak;\n \tdefault:\n \t\tret = -1;\n \t}\n@@ -1548,6 +1552,10 @@ static int remote_exists(const char *path)\n \tcase HTTP_MISSING_TARGET:\n \t\tret = 0;\n \t\tbreak;\n+\tcase HTTP_RATE_LIMITED:\n+\t\terror(\"rate limited by '%s', please try again later\", url);\n+\t\tret = -1;\n+\t\tbreak;\n \tcase HTTP_ERROR:\n \t\terror(\"unable to access '%s': %s\", url, curl_errorstr);\n \t\t/* fallthrough */\ndiff --git a/http-walker.c b/http-walker.c\nindex e886e64866..9f06f47de1 100644\n--- a/http-walker.c\n+++ b/http-walker.c\n@@ -414,6 +414,11 @@ static int fetch_indices(struct walker *walker, struct alt_base *repo)\n \t\trepo->got_indices = 1;\n \t\tret = 0;\n \t\tbreak;\n+\tcase HTTP_RATE_LIMITED:\n+\t\terror(\"rate limited by '%s', please try again later\", repo->base);\n+\t\trepo->got_indices = 0;\n+\t\tret = -1;\n+\t\tbreak;\n \tdefault:\n \t\trepo->got_indices = 0;\n \t\tret = -1;\ndiff --git a/http.c b/http.c\nindex 41f850db16..212805cad5 100644\n--- a/http.c\n+++ b/http.c\n@@ -22,6 +22,7 @@\n #include \"object-file.h\"\n #include \"odb.h\"\n #include \"tempfile.h\"\n+#include \"date.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -149,6 +150,14 @@ static char *cached_accept_language;\n static char *http_ssl_backend;\n \n static int http_schannel_check_revoke = 1;\n+\n+/* Retry configuration */\n+static long http_retry_after = -1; /* Default retry-after in seconds when header is missing (-1 means not set, exit with 128) */\n+static long http_max_retries = 0; /* Maximum number of retry attempts (0 means retries are disabled) */\n+static long http_max_retry_time = 300; /* Maximum time to wait for a single retry (default 5 minutes) */\n+\n+/* Store retry_after value from 429 responses for retry logic (-1 = not set, 0 = retry immediately, >0 = delay in seconds) */\n+static long last_retry_after = -1;\n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n  * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n@@ -209,13 +218,14 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)\n \treturn size && (*ptr == ' ' || *ptr == '\\t');\n }\n \n-static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n+static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p)\n {\n \tsize_t size = eltsize * nmemb;\n \tstruct strvec *values = &http_auth.wwwauth_headers;\n \tstruct strbuf buf = STRBUF_INIT;\n \tconst char *val;\n \tsize_t val_len;\n+\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n \n \t/*\n \t * Header lines may not come NULL-terminated from libcurl so we must\n@@ -257,6 +267,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n \t\tgoto exit;\n \t}\n \n+\t/* Parse Retry-After header for rate limiting */\n+\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n+\t\tstrbuf_add(&buf, val, val_len);\n+\t\tstrbuf_trim(&buf);\n+\n+\t\tif (slot && slot->results) {\n+\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n+\t\t\tchar *endptr;\n+\t\t\tlong retry_after;\n+\n+\t\t\terrno = 0;\n+\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n+\n+\t\t\t/* Check if it's a valid integer (delay-seconds format) */\n+\t\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n+\t\t\t    errno != ERANGE && retry_after > 0) {\n+\t\t\t\tslot->results->retry_after = retry_after;\n+\t\t\t} else {\n+\t\t\t\t/* Try parsing as HTTP-date format */\n+\t\t\t\ttimestamp_t timestamp;\n+\t\t\t\tint offset;\n+\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n+\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n+\t\t\t\t\ttimestamp_t now = time(NULL);\n+\t\t\t\t\tif (timestamp > now) {\n+\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n+\t\t\t\t\t} else {\n+\t\t\t\t\t\t/* Past date means retry immediately */\n+\t\t\t\t\t\tslot->results->retry_after = 0;\n+\t\t\t\t\t}\n+\t\t\t\t} else {\n+\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n+\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n+\t\t\t\t}\n+\t\t\t}\n+\t\t}\n+\n+\t\thttp_auth.header_is_last_match = 1;\n+\t\tgoto exit;\n+\t}\n+\n \t/*\n \t * This line could be a continuation of the previously matched header\n \t * field. If this is the case then we should append this value to the\n@@ -575,6 +626,21 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retryafter\", var)) {\n+\t\thttp_retry_after = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretries\", var)) {\n+\t\thttp_max_retries = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretrytime\", var)) {\n+\t\thttp_max_retry_time = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1422,6 +1488,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n \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+\n \tcurl_default = get_curl_handle();\n }\n \n@@ -1871,6 +1941,10 @@ 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\t/* Store the retry_after value for use in retry logic */\n+\t\tlast_retry_after = results->retry_after;\n+\t\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n \t\t\tcredential_reject(the_repository, &proxy_auth);\n@@ -1886,6 +1960,8 @@ int run_one_slot(struct active_request_slot *slot,\n \t\t struct slot_results *results)\n {\n \tslot->results = results;\n+\t/* Initialize retry_after to -1 (not set) */\n+\tresults->retry_after = -1;\n \tif (!start_active_slot(slot)) {\n \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n \t\t\t  \"failed to start HTTP request\");\n@@ -2149,6 +2225,7 @@ static int http_request(const char *url,\n \t}\n \n \tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n \n \taccept_language = http_get_accept_language_header();\n \n@@ -2253,19 +2330,36 @@ static int update_url_from_redirect(struct strbuf *base,\n \treturn 1;\n }\n \n+/*\n+ * Sleep for the specified number of seconds before retrying.\n+ */\n+static void sleep_for_retry(long retry_after)\n+{\n+\tif (retry_after > 0) {\n+\t\tunsigned int remaining;\n+\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n+\t\tremaining = sleep(retry_after);\n+\t\twhile (remaining > 0) {\n+\t\t\t/* Sleep was interrupted, continue sleeping */\n+\t\t\tremaining = sleep(remaining);\n+\t\t}\n+\t}\n+}\n+\n static int http_request_reauth(const char *url,\n \t\t\t       void *result, int target,\n \t\t\t       struct http_get_options *options)\n {\n \tint i = 3;\n \tint ret;\n+\tint rate_limit_retries = http_max_retries;\n \n \tif (always_auth_proactively())\n \t\tcredential_fill(the_repository, &http_auth, 1);\n \n \tret = http_request(url, result, target, options);\n \n-\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\n+\tif (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)\n \t\treturn ret;\n \n \tif (options && options->effective_url && options->base_url) {\n@@ -2276,7 +2370,7 @@ static int http_request_reauth(const char *url,\n \t\t}\n \t}\n \n-\twhile (ret == HTTP_REAUTH && --i) {\n+\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\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@@ -2302,7 +2396,54 @@ static int http_request_reauth(const char *url,\n \t\t\tBUG(\"Unknown http_request target\");\n \t\t}\n \n-\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\tif (ret == HTTP_RATE_LIMITED) {\n+\t\t\t/* Handle rate limiting with retry logic */\n+\t\t\tint retry_attempt = http_max_retries - rate_limit_retries + 1;\n+\n+\t\t\tif (rate_limit_retries <= 0) {\n+\t\t\t\t/* Retries are disabled or exhausted */\n+\t\t\t\tif (http_max_retries > 0) {\n+\t\t\t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t\t\t}\n+\t\t\t\treturn HTTP_ERROR;\n+\t\t\t}\n+\n+\t\t\t/* Decrement retries counter */\n+\t\t\trate_limit_retries--;\n+\n+\t\t\t/* Use the stored retry_after value or configured default */\n+\t\t\tif (last_retry_after >= 0) {\n+\t\t\t\t/* Check if retry delay exceeds maximum allowed */\n+\t\t\t\tif (last_retry_after > http_max_retry_time) {\n+\t\t\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n+\t\t\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n+\t\t\t\t\t      last_retry_after, http_max_retry_time);\n+\t\t\t\t\tlast_retry_after = -1; /* Reset after use */\n+\t\t\t\t\treturn HTTP_ERROR;\n+\t\t\t\t}\n+\t\t\t\tsleep_for_retry(last_retry_after);\n+\t\t\t\tlast_retry_after = -1; /* Reset after use */\n+\t\t\t} else {\n+\t\t\t\t/* No Retry-After header provided */\n+\t\t\t\tif (http_retry_after < 0) {\n+\t\t\t\t\t/* Not configured - exit with error */\n+\t\t\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n+\t\t\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n+\t\t\t\t\treturn HTTP_ERROR;\n+\t\t\t\t}\n+\t\t\t\t/* Check if configured default exceeds maximum allowed */\n+\t\t\t\tif (http_retry_after > http_max_retry_time) {\n+\t\t\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n+\t\t\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n+\t\t\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\t\t\treturn HTTP_ERROR;\n+\t\t\t\t}\n+\t\t\t\t/* Use configured default retry-after value */\n+\t\t\t\tsleep_for_retry(http_retry_after);\n+\t\t\t}\n+\t\t} else if (ret == HTTP_REAUTH) {\n+\t\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\t}\n \n \t\tret = http_request(url, result, target, options);\n \t}\ndiff --git a/http.h b/http.h\nindex f9d4593404..eb40456450 100644\n--- a/http.h\n+++ b/http.h\n@@ -20,6 +20,7 @@ struct slot_results {\n \tlong http_code;\n \tlong auth_avail;\n \tlong http_connectcode;\n+\tlong retry_after;\n };\n \n struct active_request_slot {\n@@ -167,6 +168,7 @@ struct http_get_options {\n #define HTTP_REAUTH\t4\n #define HTTP_NOAUTH\t5\n #define HTTP_NOMATCHPUBLICKEY\t6\n+#define HTTP_RATE_LIMITED\t7\n \n /*\n  * Requests a URL and stores the result in a strbuf.\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 69f919454a..5959461cd3 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -529,6 +529,10 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \t\tshow_http_message(&type, &charset, &buffer);\n \t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n \t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\tcase HTTP_RATE_LIMITED:\n+\t\tshow_http_message(&type, &charset, &buffer);\n+\t\tdie(_(\"rate limited by '%s', please try again later\"),\n+\t\t    transport_anonymize_url(url.buf));\n \tdefault:\n \t\tshow_http_message(&type, &charset, &buffer);\n \t\tdie(_(\"unable to access '%s': %s\"),\ndiff --git a/t/meson.build b/t/meson.build\nindex dc43d69636..98bd6949e6 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -698,6 +698,7 @@ integration_tests = [\n   't5581-http-curl-verbose.sh',\n   't5582-fetch-negative-refspec.sh',\n   't5583-push-branches.sh',\n+  't5584-http-429-retry.sh',\n   't5600-clone-fail-cleanup.sh',\n   't5601-clone.sh',\n   't5602-clone-remote-exec.sh',\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nnew file mode 100755\nindex 0000000000..8bcc382763\n--- /dev/null\n+++ b/t/t5584-http-429-retry.sh\n@@ -0,0 +1,429 @@\n+#!/bin/sh\n+\n+test_description='test HTTP 429 Too Many Requests retry logic'\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/lib-httpd.sh\n+\n+start_httpd\n+\n+test_expect_success 'setup test repository' '\n+\ttest_commit initial &&\n+\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n+\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n+'\n+\n+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 1\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Set maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should fail immediately without any retry attempt\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message in stderr)\n+\t! grep -i \"waiting.*retry\" err &&\n+\n+\t# The one-time script will be consumed on first request (not a retry)\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 permanent should fail after max retries' '\n+\t# Install a permanent error script to prove retries are limited\n+\twrite_script \"$HTTPD_ROOT_PATH/http-429-permanent.sh\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 1\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Permanently rate limited\\n\"\n+\tEOF\n+\n+\t# Enable retries with a limit\n+\ttest_config http.maxRetries 2 &&\n+\n+\t# Git should retry but eventually fail when 429 persists\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/error/http-429-permanent.sh/repo.git\" 2>err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n+\t# Create a one-time script that returns 429 with Retry-After header\n+\t# on the first request. Subsequent requests will succeed.\n+\t# This contrasts with the permanent 429 above - proving retry works\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\t# Return HTTP 429 response instead of git response\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 1\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited - please retry after 1 second\\n\"\n+\t# Output something different from input so the script gets removed\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry after receiving 429 and eventually succeed\n+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\n+\t# The one-time script should have been consumed (proving retry happened)\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 without Retry-After uses configured default' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited - no retry info\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Enable retries and configure default delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Git should retry using configured default and succeed\n+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 retry delays are respected' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 2\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Time the operation - it should take at least 2 seconds due to retry delay\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Verify it took at least 2 seconds (allowing some tolerance)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 100\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited with long delay\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure max retry time to 3 seconds (much less than requested 100)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 3 &&\n+\n+\t# Should fail immediately without waiting\n+\tstart=$(date +%s) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"exceeds http.maxRetryTime\" err &&\n+\n+\t# The one-time script will be consumed on first request\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n+\t# Test misconfiguration: retryAfter > maxRetryTime\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited without header\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure retryAfter larger than maxRetryTime\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 100 &&\n+\ttest_config http.maxRetryTime 5 &&\n+\n+\t# Should fail immediately with configuration error\n+\tstart=$(date +%s) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n+\t# Test HTTP-date format (RFC 2822) in Retry-After header\n+\t# Generate a date 2 seconds in the future\n+\tfuture_date=$(TZ=GMT date -d \"+2 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      TZ=GMT date -v+2S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      echo \"skip\") &&\n+\n+\tif test \"$future_date\" = \"skip\"\n+\tthen\n+\t\tskip_all=\"date command does not support required format\" &&\n+\t\ttest_done\n+\tfi &&\n+\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\\\r\\\\n\"\n+\tprintf \"Retry-After: $future_date\\\\r\\\\n\"\n+\tprintf \"Content-Type: text/plain\\\\r\\\\n\"\n+\tprintf \"\\\\r\\\\n\"\n+\tprintf \"Rate limited with HTTP-date\\\\n\"\n+\tcat \"\\$1\" >/dev/null\n+\tEOF\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should parse the HTTP-date and retry after the delay\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should take at least 1 second (allowing tolerance for processing time)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n+\t# Generate a date 200 seconds in the future\n+\tfuture_date=$(TZ=GMT date -d \"+200 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      TZ=GMT date -v+200S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      echo \"skip\") &&\n+\n+\tif test \"$future_date\" = \"skip\"\n+\tthen\n+\t\tskip_all=\"date command does not support required format\" &&\n+\t\ttest_done\n+\tfi &&\n+\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\\\r\\\\n\"\n+\tprintf \"Retry-After: $future_date\\\\r\\\\n\"\n+\tprintf \"Content-Type: text/plain\\\\r\\\\n\"\n+\tprintf \"\\\\r\\\\n\"\n+\tprintf \"Rate limited with long HTTP-date\\\\n\"\n+\tcat \"\\$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure max retry time much less than the 200 second delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 10 &&\n+\n+\t# Should fail immediately without waiting 200 seconds\n+\tstart=$(date +%s) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly (not wait 200 seconds)\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"exceeds http.maxRetryTime\" err &&\n+\n+\t# The one-time script will be consumed on first request\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n+\tpast_date=$(TZ=GMT date -d \"-10 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t    TZ=GMT date -v-10S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t    echo \"skip\") &&\n+\n+\tif test \"$past_date\" = \"skip\"\n+\tthen\n+\t\tskip_all=\"date command does not support required format\" &&\n+\t\ttest_done\n+\tfi &&\n+\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\\\r\\\\n\"\n+\tprintf \"Retry-After: $past_date\\\\r\\\\n\"\n+\tprintf \"Content-Type: text/plain\\\\r\\\\n\"\n+\tprintf \"\\\\r\\\\n\"\n+\tprintf \"Rate limited with past date\\\\n\"\n+\tcat \"\\$1\" >/dev/null\n+\tEOF\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry immediately without waiting\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should complete quickly (less than 2 seconds)\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited with malformed header\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure default retry-after\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should use configured default (1 second) since header is invalid\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should take at least 1 second (the configured default)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'HTTP 429 will not be retried without config' '\n+\t# Default config means http.maxRetries=0 (retries disabled)\n+\t# When 429 is received, it should fail immediately without retry\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 1\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n+\n+\t# Should fail immediately without retry\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message)\n+\t! grep -i \"waiting.*retry\" err &&\n+\n+\t# Should get 429 error\n+\ttest_grep \"429\" err &&\n+\n+\t# The one-time script should be consumed on first request\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited - no Retry-After header\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure retryAfter to 10 seconds\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 10 &&\n+\n+\t# Override with environment variable to 1 second\n+\tstart=$(date +%s) &&\n+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should use env var (1 second), not config (10 seconds)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest \"$duration\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 1\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Override with environment variable to enable retries\n+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n+\n+\t# Should retry (env var enables it despite config saying disabled)\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n+\tprintf \"Retry-After: 50\\r\\n\"\n+\tprintf \"Content-Type: text/plain\\r\\n\"\n+\tprintf \"\\r\\n\"\n+\tprintf \"Rate limited with long delay\\n\"\n+\tcat \"$1\" >/dev/null\n+\tEOF\n+\n+\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 100 &&\n+\n+\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n+\tstart=$(date +%s) &&\n+\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n+\t\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n+\ttest \"$duration\" -lt 5 &&\n+\ttest_grep \"exceeds http.maxRetryTime\" err &&\n+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n+'\n+\n+test_expect_success 'verify normal repository access still works' '\n+\tgit ls-remote \"$HTTPD_URL/smart/repo.git\" >output &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"531294","messageId":"438223792264169082db8a1be5cb419b657bda26.1764160227.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.git.1764160227.gitgitgadget@gmail.com","subject":"[PATCH 2/3] remote-curl: fix memory leak in show_http_message()","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-11-26T12:30:26Z","receivedAt":"2025-11-26T12:30:35Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nFix a memory leak in show_http_message() that was triggered when\ndisplaying HTTP error messages before die(). The function would call\nstrbuf_reencode() which modifies the caller's strbuf in place,\nallocating new memory for the re-encoded string. Since this function\nis only called immediately before die(), the allocated memory was\nnever explicitly freed, causing leak detectors to report it.\n\nThe leak became visible when HTTP 429 rate limit retry support was\nadded, which introduced the HTTP_RATE_LIMITED error case. However,\nthe issue existed in pre-existing error paths as well\n(HTTP_MISSING_TARGET, HTTP_NOAUTH, HTTP_NOMATCHPUBLICKEY) - the new\nretry logic just made it more visible in tests because retries\nexercise the error paths more frequently.\n\nThe leak was detected by LeakSanitizer in t5584 tests that enable\nretries (maxRetries > 0). Tests with retries disabled passed because\nthey took a different code path or timing.\n\nFix this by making show_http_message() work on a local copy of the\nmessage buffer instead of modifying the caller's buffer in place:\n\n1. Create a local strbuf and copy the message into it\n2. Perform re-encoding on the local copy if needed\n3. Display the message from the local copy\n4. Properly release the local copy before returning\n\nThis ensures all memory allocated by strbuf_reencode() is freed\nbefore the function returns, even though die() is called immediately\nafter, eliminating the leak.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n remote-curl.c | 14 ++++++++++----\n 1 file changed, 10 insertions(+), 4 deletions(-)\n\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 5959461cd3..dd0680e5ae 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -371,6 +371,7 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t\t\t     struct strbuf *msg)\n {\n \tconst char *p, *eol;\n+\tstruct strbuf msgbuf = STRBUF_INIT;\n \n \t/*\n \t * We only show text/plain parts, as other types are likely\n@@ -378,19 +379,24 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t */\n \tif (strcmp(type->buf, \"text/plain\"))\n \t\treturn -1;\n+\n+\tstrbuf_addbuf(&msgbuf, msg);\n \tif (charset->len)\n-\t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n+\t\tstrbuf_reencode(&msgbuf, charset->buf, get_log_output_encoding());\n \n-\tstrbuf_trim(msg);\n-\tif (!msg->len)\n+\tstrbuf_trim(&msgbuf);\n+\tif (!msgbuf.len) {\n+\t\tstrbuf_release(&msgbuf);\n \t\treturn -1;\n+\t}\n \n-\tp = msg->buf;\n+\tp = msgbuf.buf;\n \tdo {\n \t\teol = strchrnul(p, '\\n');\n \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n \t\tp = eol + 1;\n \t} while(*eol);\n+\tstrbuf_release(&msgbuf);\n \treturn 0;\n }\n \n-- \ngitgitgadget\n\n"},{"id":"531295","messageId":"adbcc0251faba4f86dd7da1f01e312fc38c4ee26.1764160227.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.git.1764160227.gitgitgadget@gmail.com","subject":"[PATCH 3/3] http: add trace2 logging for retry operations","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-11-26T12:30:27Z","receivedAt":"2025-11-26T12:30:37Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd trace2 instrumentation to HTTP 429 retry operations to enable\nmonitoring and debugging of rate limit scenarios in production\nenvironments.\n\nThe trace2 logging captures:\n\n  * Retry attempt numbers (http/429-retry-attempt) to track retry\n    progression and identify how many attempts were needed\n\n  * Retry-After header values (http/429-retry-after) from server\n    responses to understand server-requested delays\n\n  * Actual sleep durations (http/retry-sleep-seconds) within trace2\n    regions (http/retry-sleep) to measure time spent waiting\n\n  * Error conditions (http/429-error) such as \"retries-exhausted\",\n    \"exceeds-max-retry-time\", \"no-retry-after-config\", and\n    \"config-exceeds-max-retry-time\" for diagnosing failures\n\n  * Retry source (http/429-retry-source) indicating whether delay\n    came from server header or config default\n\nThis instrumentation provides complete visibility into retry behavior,\nenabling operators to monitor rate limiting patterns, diagnose retry\nfailures, and optimize retry configuration based on real-world data.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n http.c | 40 +++++++++++++++++++++++++++++++---------\n 1 file changed, 31 insertions(+), 9 deletions(-)\n\ndiff --git a/http.c b/http.c\nindex 212805cad5..f318e2fbe8 100644\n--- a/http.c\n+++ b/http.c\n@@ -23,6 +23,7 @@\n #include \"odb.h\"\n #include \"tempfile.h\"\n #include \"date.h\"\n+#include \"trace2.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -1944,6 +1945,8 @@ static int handle_curl_result(struct slot_results *results)\n \t} else if (results->http_code == 429) {\n \t\t/* Store the retry_after value for use in retry logic */\n \t\tlast_retry_after = results->retry_after;\n+\t\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n+\t\t\t\t   last_retry_after);\n \t\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n@@ -2338,11 +2341,15 @@ static void sleep_for_retry(long retry_after)\n \tif (retry_after > 0) {\n \t\tunsigned int remaining;\n \t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n+\t\ttrace2_region_enter(\"http\", \"retry-sleep\", the_repository);\n+\t\ttrace2_data_intmax(\"http\", the_repository, \"http/retry-sleep-seconds\",\n+\t\t\t\tretry_after);\n \t\tremaining = sleep(retry_after);\n \t\twhile (remaining > 0) {\n \t\t\t/* Sleep was interrupted, continue sleeping */\n \t\t\tremaining = sleep(remaining);\n \t\t}\n+\t\ttrace2_region_leave(\"http\", \"retry-sleep\", the_repository);\n \t}\n }\n \n@@ -2400,10 +2407,15 @@ static int http_request_reauth(const char *url,\n \t\t\t/* Handle rate limiting with retry logic */\n \t\t\tint retry_attempt = http_max_retries - rate_limit_retries + 1;\n \n+\t\t\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n+\t\t\t\t\tretry_attempt);\n+\n \t\t\tif (rate_limit_retries <= 0) {\n \t\t\t\t/* Retries are disabled or exhausted */\n \t\t\t\tif (http_max_retries > 0) {\n \t\t\t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\t\t\"http/429-error\", \"retries-exhausted\");\n \t\t\t\t}\n \t\t\t\treturn HTTP_ERROR;\n \t\t\t}\n@@ -2418,6 +2430,10 @@ static int http_request_reauth(const char *url,\n \t\t\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n \t\t\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n \t\t\t\t\t      last_retry_after, http_max_retry_time);\n+\t\t\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\t\t\"http/429-error\", \"exceeds-max-retry-time\");\n+\t\t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t\t\t\t\"http/429-requested-delay\", last_retry_after);\n \t\t\t\t\tlast_retry_after = -1; /* Reset after use */\n \t\t\t\t\treturn HTTP_ERROR;\n \t\t\t\t}\n@@ -2429,17 +2445,23 @@ static int http_request_reauth(const char *url,\n \t\t\t\t\t/* Not configured - exit with error */\n \t\t\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n \t\t\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n+\t\t\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\t\t\"http/429-error\", \"no-retry-after-config\");\n \t\t\t\t\treturn HTTP_ERROR;\n \t\t\t\t}\n-\t\t\t\t/* Check if configured default exceeds maximum allowed */\n-\t\t\t\tif (http_retry_after > http_max_retry_time) {\n-\t\t\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n-\t\t\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n-\t\t\t\t\t      http_retry_after, http_max_retry_time);\n-\t\t\t\t\treturn HTTP_ERROR;\n-\t\t\t\t}\n-\t\t\t\t/* Use configured default retry-after value */\n-\t\t\t\tsleep_for_retry(http_retry_after);\n+\t\t\t/* Check if configured default exceeds maximum allowed */\n+\t\t\tif (http_retry_after > http_max_retry_time) {\n+\t\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n+\t\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n+\t\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n+\t\t\t\treturn HTTP_ERROR;\n+\t\t\t}\n+\t\t\t/* Use configured default retry-after value */\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\"http/429-retry-source\", \"config-default\");\n+\t\t\tsleep_for_retry(http_retry_after);\n \t\t\t}\n \t\t} else if (ret == HTTP_REAUTH) {\n \t\t\tcredential_fill(the_repository, &http_auth, 1);\n-- \ngitgitgadget\n"},{"id":"531936","messageId":"aTitfzeb7J8TUTYQ@nand.local","threadId":"64535","inReplyTo":"ae0087cd1c7fbb6b748d6767b476c1bd1a19996f.1764160227.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 1/3] http: add support for HTTP 429 rate limit retries","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2025-12-09T23:15:11Z","receivedAt":"2025-12-09T23:15:13Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Wed, Nov 26, 2025 at 12:30:25PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n> Retry behavior is controlled by three new configuration options:\n>\n>   * http.maxRetries: Maximum number of retry attempts (default: 0,\n>     meaning retries are disabled by default). Users must explicitly\n>     opt-in to retry behavior.\n>\n>   * http.retryAfter: Default delay in seconds when the server doesn't\n>     provide a Retry-After header (default: -1, meaning fail if no\n>     header is provided). This serves as a fallback mechanism.\n>\n>   * http.maxRetryTime: Maximum delay in seconds for a single retry\n>     (default: 300). If the server requests a delay exceeding this\n>     limit, Git fails immediately rather than waiting. This prevents\n>     indefinite blocking on unreasonable server requests.\n>\n> All three options can be overridden via environment variables:\n> GIT_HTTP_MAX_RETRIES, GIT_HTTP_RETRY_AFTER, and\n> GIT_HTTP_MAX_RETRY_TIME.\n\nThis is great information, and I am glad that it is written down in\nhttp.adoc so that it shows up in git-config(1). I think that it's fine\nto omit this level of detail from the commit message, since it\nduplicates information from the authoritative source on configuration\nknobs.\n\nIt might be reasonable to say something like:\n\n    Retry behavior is controlled by three new configuration options\n    (http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\n    documented in git-config(1).\n\nor something.\n\n> diff --git a/http-push.c b/http-push.c\n> index d86ce77119..a602a302ec 100644\n> --- a/http-push.c\n> +++ b/http-push.c\n> @@ -716,6 +716,10 @@ static int fetch_indices(void)\n>  \tcase HTTP_MISSING_TARGET:\n>  \t\tret = 0;\n>  \t\tbreak;\n> +\tcase HTTP_RATE_LIMITED:\n> +\t\terror(\"rate limited by '%s', please try again later\", repo->url);\n> +\t\tret = -1;\n\nOther strings in this file aren't marked for translation, but I think\nwe can/should mark this one like so:\n\n    error(_(\"rate limited by %s ...\"), repo->url);\n\n> diff --git a/http.c b/http.c\n> index 41f850db16..212805cad5 100644\n> --- a/http.c\n> +++ b/http.c\n> @@ -22,6 +22,7 @@\n>  #include \"object-file.h\"\n>  #include \"odb.h\"\n>  #include \"tempfile.h\"\n> +#include \"date.h\"\n>\n>  static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n>  static int trace_curl_data = 1;\n> @@ -149,6 +150,14 @@ static char *cached_accept_language;\n>  static char *http_ssl_backend;\n>\n>  static int http_schannel_check_revoke = 1;\n> +\n> +/* Retry configuration */\n> +static long http_retry_after = -1; /* Default retry-after in seconds when header is missing (-1 means not set, exit with 128) */\n> +static long http_max_retries = 0; /* Maximum number of retry attempts (0 means retries are disabled) */\n> +static long http_max_retry_time = 300; /* Maximum time to wait for a single retry (default 5 minutes) */\n\nThese comments should be OK to drop, the variables indicate what Git\nconfiguration they correspond to (e.g., http_retry_after ->\nhttp.retryAfter), so git-config(1) is the authoritative source for\ndocumentation here.\n\n> @@ -257,6 +267,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n>  \t\tgoto exit;\n>  \t}\n>\n> +\t/* Parse Retry-After header for rate limiting */\n> +\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n\nMakes sense, though I wonder if we should rename this function, since\nfwrite_wwwauth is now doing more than just handling WWW-Authenticate\nheaders.\n\nPerhaps we should have a single top-level function that is registered as\nour CURLOPT_HEADERFUNCTION that dispatches calls to header-specific\nfunctions? Otherwise the actual parsing of the Retry-After header looks\ngood to me.\n\n> @@ -1422,6 +1488,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n>  \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n>  \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n>\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> +\n\nThe configuration handling and overrides look good to me.\n\n> @@ -2253,19 +2330,36 @@ static int update_url_from_redirect(struct strbuf *base,\n>  \treturn 1;\n>  }\n>\n> +/*\n> + * Sleep for the specified number of seconds before retrying.\n> + */\n> +static void sleep_for_retry(long retry_after)\n> +{\n> +\tif (retry_after > 0) {\n> +\t\tunsigned int remaining;\n> +\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n> +\t\tremaining = sleep(retry_after);\n\nWhat should we do if there are other active request slots? It has been a\ncouple of years since I have looked at Git's HTTP code, but I imagine\nthat we should be able to continue processing other requests while\nwaiting for the retry-after period to elapse here.\n\n> @@ -2302,7 +2396,54 @@ static int http_request_reauth(const char *url,\n>  \t\t\tBUG(\"Unknown http_request target\");\n>  \t\t}\n>\n> -\t\tcredential_fill(the_repository, &http_auth, 1);\n> +\t\tif (ret == HTTP_RATE_LIMITED) {\n\nShould handling the retry behavior be moved into a separate function? I\nthink that http_request_reauth() might be clearer if it read:\n\n    if (ret == HTTP_RATE_LIMITED)\n      apply_rate_limit(...); /* presumably with a better name */\n    else\n      credential_fill(...);\n\n, and likewise, should we rename this function as it is no longer just\nre-authenticating HTTP requests?\n\n> diff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\n> new file mode 100755\n> index 0000000000..8bcc382763\n> --- /dev/null\n> +++ b/t/t5584-http-429-retry.sh\n> @@ -0,0 +1,429 @@\n> +#!/bin/sh\n> +\n> +test_description='test HTTP 429 Too Many Requests retry logic'\n> +\n> +. ./test-lib.sh\n> +\n> +. \"$TEST_DIRECTORY\"/lib-httpd.sh\n> +\n> +start_httpd\n> +\n> +test_expect_success 'setup test repository' '\n> +\ttest_commit initial &&\n> +\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n> +\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n> +'\n> +\n> +test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n> +\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n> +\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n> +\tprintf \"Retry-After: 1\\r\\n\"\n> +\tprintf \"Content-Type: text/plain\\r\\n\"\n> +\tprintf \"\\r\\n\"\n> +\tprintf \"Rate limited\\n\"\n> +\tcat \"$1\" >/dev/null\n> +\tEOF\n\nTo avoid having to write this script multiple write, you can write it as\na separate script in t/lib-httpd and then make sure to list it in\nprepare_httpd() (from t/lib-httpd.sh).\n\nYou can then list it in the apache.conf in the same directory and invoke\nit however you like. If you need to take in arguments to the script\n(e.g., to change the Retry-After value), you can use a ScriptAliasMatch\ninstead of a normal ScriptAlias to pass in extra parameters from the URL.\n\nThe one-time-script mechanism here will cause the test harness to delete\nthe script after its first (and only) use, which can be useful for some\ncases but I suspect is not necessary for all of these tests.\n> +\n> +\t# Set maxRetries to 0 (disabled)\n> +\ttest_config http.maxRetries 0 &&\n> +\ttest_config http.retryAfter 1 &&\n> +\n> +\t# Should fail immediately without any retry attempt\n> +\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n> +\n> +\t# Verify no retry happened (no \"waiting\" message in stderr)\n> +\t! grep -i \"waiting.*retry\" err &&\n\ntest_grep can be helpful when reading the output of test failures, since\nit dumps the contents of the file it was searching. Just make sure to\nwrite \"test_grep !\" instead of \"! test_grep\" (there are a few such\ninstances of the latter that I just wrote patches to clean up).\n\n\"! test_grep\" isn't *wrong* per-se, but it will pollute the test output\nwith \"couldn't find xyz in abc\".\n\nI skimmed through the the remainder of the tests since I imagine that\nthey will change substantially after writing the script out explicitly\ninstead of using one-time-script, so I'll hold off on reviewing that\nportion in more detail until then.\n\nThanks,\nTaylor\n"},{"id":"531937","messageId":"aTi2W0f03kwf0ONx@nand.local","threadId":"64535","inReplyTo":"438223792264169082db8a1be5cb419b657bda26.1764160227.git.gitgitgadget@gmail.com","subject":"Re: [PATCH 2/3] remote-curl: fix memory leak in show_http_message()","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2025-12-09T23:52:59Z","receivedAt":"2025-12-09T23:53:01Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Wed, Nov 26, 2025 at 12:30:26PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n> diff --git a/remote-curl.c b/remote-curl.c\n> index 5959461cd3..dd0680e5ae 100644\n> --- a/remote-curl.c\n> +++ b/remote-curl.c\n> @@ -371,6 +371,7 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n>  \t\t\t     struct strbuf *msg)\n>  {\n>  \tconst char *p, *eol;\n> +\tstruct strbuf msgbuf = STRBUF_INIT;\n>\n>  \t/*\n>  \t * We only show text/plain parts, as other types are likely\n> @@ -378,19 +379,24 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n>  \t */\n>  \tif (strcmp(type->buf, \"text/plain\"))\n>  \t\treturn -1;\n> +\n> +\tstrbuf_addbuf(&msgbuf, msg);\n\nHmm. Looking at the list of show_http_message() callers, it looks like\nthey all follow the pattern of constructing a strbuf \"msg\", passing it\nto this function, and then calling die() with some user-friendly\nmessage.\n\nI agree that the patch here does address that leak, but I wonder if we\nshould do it in a way that doesn't involve copying the \"msg\" buffer. One\nthing we could do is rename 'show_http_message()' to make it clear that\nit's fatal and then free the re-encoded buffer ourselves (along with the\nother buffers type and charset), perhaps like so (on top of the previous\npatch in lieu of this one):\n\n--- 8< ---\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 5959461cd34..9d8359665ee 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -367,23 +367,25 @@ static void free_discovery(struct discovery *d)\n \t}\n }\n\n-static int show_http_message(struct strbuf *type, struct strbuf *charset,\n-\t\t\t     struct strbuf *msg)\n+static void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n+\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n {\n \tconst char *p, *eol;\n+\tva_list ap;\n+\treport_fn die_message_routine = get_die_message_routine();\n\n \t/*\n \t * We only show text/plain parts, as other types are likely\n \t * to be ugly to look at on the user's terminal.\n \t */\n \tif (strcmp(type->buf, \"text/plain\"))\n-\t\treturn -1;\n+\t\tgoto out;\n \tif (charset->len)\n \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n\n \tstrbuf_trim(msg);\n \tif (!msg->len)\n-\t\treturn -1;\n+\t\tgoto out;\n\n \tp = msg->buf;\n \tdo {\n@@ -391,7 +393,15 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n \t\tp = eol + 1;\n \t} while(*eol);\n-\treturn 0;\n+\n+out:\n+\tstrbuf_release(type);\n+\tstrbuf_release(charset);\n+\tstrbuf_release(msg);\n+\n+\tva_start(ap, fmt);\n+\tdie_message_routine(fmt, ap);\n+\tva_end(ap);\n }\n\n static int get_protocol_http_header(enum protocol_version version,\n@@ -518,25 +528,27 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \tcase HTTP_OK:\n \t\tbreak;\n \tcase HTTP_MISSING_TARGET:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"repository '%s' not found\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"repository '%s' not found\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n--- >8 ---\n\n(...and so on for the remaining cases).\n\nThanks,\nTaylor\n"},{"id":"532067","messageId":"CAGjQmDOCeYMvvuct2ZkOJKhzpuSH6qqnWMDCbZ2OwcxQ_2DfpQ@mail.gmail.com","threadId":"64535","inReplyTo":"aTitfzeb7J8TUTYQ@nand.local","subject":"Re: [PATCH 1/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas","fromEmail":"vaidas.pilkauskas@shopify.com","sentAt":"2025-12-12T12:36:49Z","receivedAt":"2025-12-12T12:37:04Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"On Wed, Dec 10, 2025 at 1:15 AM Taylor Blau <me@ttaylorr.com> wrote:\n> > +/*\n> > + * Sleep for the specified number of seconds before retrying.\n> > + */\n> > +static void sleep_for_retry(long retry_after)\n> > +{\n> > +     if (retry_after > 0) {\n> > +             unsigned int remaining;\n> > +             warning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n> > +             remaining = sleep(retry_after);\n>\n> What should we do if there are other active request slots? It has been a\n> couple of years since I have looked at Git's HTTP code, but I imagine\n> that we should be able to continue processing other requests while\n> waiting for the retry-after period to elapse here.\n\nThis is a very good catch - I'll rewrite this to a non-blocking wait.\n\nThanks for the review, Taylor, I'll work to address this and other\ncomments in the next version of the patch.\n\nOn Wed, Dec 10, 2025 at 1:15 AM Taylor Blau <me@ttaylorr.com> wrote:\n>\n> On Wed, Nov 26, 2025 at 12:30:25PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n> > Retry behavior is controlled by three new configuration options:\n> >\n> >   * http.maxRetries: Maximum number of retry attempts (default: 0,\n> >     meaning retries are disabled by default). Users must explicitly\n> >     opt-in to retry behavior.\n> >\n> >   * http.retryAfter: Default delay in seconds when the server doesn't\n> >     provide a Retry-After header (default: -1, meaning fail if no\n> >     header is provided). This serves as a fallback mechanism.\n> >\n> >   * http.maxRetryTime: Maximum delay in seconds for a single retry\n> >     (default: 300). If the server requests a delay exceeding this\n> >     limit, Git fails immediately rather than waiting. This prevents\n> >     indefinite blocking on unreasonable server requests.\n> >\n> > All three options can be overridden via environment variables:\n> > GIT_HTTP_MAX_RETRIES, GIT_HTTP_RETRY_AFTER, and\n> > GIT_HTTP_MAX_RETRY_TIME.\n>\n> This is great information, and I am glad that it is written down in\n> http.adoc so that it shows up in git-config(1). I think that it's fine\n> to omit this level of detail from the commit message, since it\n> duplicates information from the authoritative source on configuration\n> knobs.\n>\n> It might be reasonable to say something like:\n>\n>     Retry behavior is controlled by three new configuration options\n>     (http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\n>     documented in git-config(1).\n>\n> or something.\n>\n> > diff --git a/http-push.c b/http-push.c\n> > index d86ce77119..a602a302ec 100644\n> > --- a/http-push.c\n> > +++ b/http-push.c\n> > @@ -716,6 +716,10 @@ static int fetch_indices(void)\n> >       case HTTP_MISSING_TARGET:\n> >               ret = 0;\n> >               break;\n> > +     case HTTP_RATE_LIMITED:\n> > +             error(\"rate limited by '%s', please try again later\", repo->url);\n> > +             ret = -1;\n>\n> Other strings in this file aren't marked for translation, but I think\n> we can/should mark this one like so:\n>\n>     error(_(\"rate limited by %s ...\"), repo->url);\n>\n> > diff --git a/http.c b/http.c\n> > index 41f850db16..212805cad5 100644\n> > --- a/http.c\n> > +++ b/http.c\n> > @@ -22,6 +22,7 @@\n> >  #include \"object-file.h\"\n> >  #include \"odb.h\"\n> >  #include \"tempfile.h\"\n> > +#include \"date.h\"\n> >\n> >  static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n> >  static int trace_curl_data = 1;\n> > @@ -149,6 +150,14 @@ static char *cached_accept_language;\n> >  static char *http_ssl_backend;\n> >\n> >  static int http_schannel_check_revoke = 1;\n> > +\n> > +/* Retry configuration */\n> > +static long http_retry_after = -1; /* Default retry-after in seconds when header is missing (-1 means not set, exit with 128) */\n> > +static long http_max_retries = 0; /* Maximum number of retry attempts (0 means retries are disabled) */\n> > +static long http_max_retry_time = 300; /* Maximum time to wait for a single retry (default 5 minutes) */\n>\n> These comments should be OK to drop, the variables indicate what Git\n> configuration they correspond to (e.g., http_retry_after ->\n> http.retryAfter), so git-config(1) is the authoritative source for\n> documentation here.\n>\n> > @@ -257,6 +267,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n> >               goto exit;\n> >       }\n> >\n> > +     /* Parse Retry-After header for rate limiting */\n> > +     if (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n>\n> Makes sense, though I wonder if we should rename this function, since\n> fwrite_wwwauth is now doing more than just handling WWW-Authenticate\n> headers.\n>\n> Perhaps we should have a single top-level function that is registered as\n> our CURLOPT_HEADERFUNCTION that dispatches calls to header-specific\n> functions? Otherwise the actual parsing of the Retry-After header looks\n> good to me.\n>\n> > @@ -1422,6 +1488,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n> >       set_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n> >       set_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n> >\n> > +     set_long_from_env(&http_retry_after, \"GIT_HTTP_RETRY_AFTER\");\n> > +     set_long_from_env(&http_max_retries, \"GIT_HTTP_MAX_RETRIES\");\n> > +     set_long_from_env(&http_max_retry_time, \"GIT_HTTP_MAX_RETRY_TIME\");\n> > +\n>\n> The configuration handling and overrides look good to me.\n>\n> > @@ -2253,19 +2330,36 @@ static int update_url_from_redirect(struct strbuf *base,\n> >       return 1;\n> >  }\n> >\n> > +/*\n> > + * Sleep for the specified number of seconds before retrying.\n> > + */\n> > +static void sleep_for_retry(long retry_after)\n> > +{\n> > +     if (retry_after > 0) {\n> > +             unsigned int remaining;\n> > +             warning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n> > +             remaining = sleep(retry_after);\n>\n> What should we do if there are other active request slots? It has been a\n> couple of years since I have looked at Git's HTTP code, but I imagine\n> that we should be able to continue processing other requests while\n> waiting for the retry-after period to elapse here.\n>\n> > @@ -2302,7 +2396,54 @@ static int http_request_reauth(const char *url,\n> >                       BUG(\"Unknown http_request target\");\n> >               }\n> >\n> > -             credential_fill(the_repository, &http_auth, 1);\n> > +             if (ret == HTTP_RATE_LIMITED) {\n>\n> Should handling the retry behavior be moved into a separate function? I\n> think that http_request_reauth() might be clearer if it read:\n>\n>     if (ret == HTTP_RATE_LIMITED)\n>       apply_rate_limit(...); /* presumably with a better name */\n>     else\n>       credential_fill(...);\n>\n> , and likewise, should we rename this function as it is no longer just\n> re-authenticating HTTP requests?\n>\n> > diff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\n> > new file mode 100755\n> > index 0000000000..8bcc382763\n> > --- /dev/null\n> > +++ b/t/t5584-http-429-retry.sh\n> > @@ -0,0 +1,429 @@\n> > +#!/bin/sh\n> > +\n> > +test_description='test HTTP 429 Too Many Requests retry logic'\n> > +\n> > +. ./test-lib.sh\n> > +\n> > +. \"$TEST_DIRECTORY\"/lib-httpd.sh\n> > +\n> > +start_httpd\n> > +\n> > +test_expect_success 'setup test repository' '\n> > +     test_commit initial &&\n> > +     git clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n> > +     git --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n> > +'\n> > +\n> > +test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n> > +     write_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n> > +     printf \"Status: 429 Too Many Requests\\r\\n\"\n> > +     printf \"Retry-After: 1\\r\\n\"\n> > +     printf \"Content-Type: text/plain\\r\\n\"\n> > +     printf \"\\r\\n\"\n> > +     printf \"Rate limited\\n\"\n> > +     cat \"$1\" >/dev/null\n> > +     EOF\n>\n> To avoid having to write this script multiple write, you can write it as\n> a separate script in t/lib-httpd and then make sure to list it in\n> prepare_httpd() (from t/lib-httpd.sh).\n>\n> You can then list it in the apache.conf in the same directory and invoke\n> it however you like. If you need to take in arguments to the script\n> (e.g., to change the Retry-After value), you can use a ScriptAliasMatch\n> instead of a normal ScriptAlias to pass in extra parameters from the URL.\n>\n> The one-time-script mechanism here will cause the test harness to delete\n> the script after its first (and only) use, which can be useful for some\n> cases but I suspect is not necessary for all of these tests.\n> > +\n> > +     # Set maxRetries to 0 (disabled)\n> > +     test_config http.maxRetries 0 &&\n> > +     test_config http.retryAfter 1 &&\n> > +\n> > +     # Should fail immediately without any retry attempt\n> > +     test_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n> > +\n> > +     # Verify no retry happened (no \"waiting\" message in stderr)\n> > +     ! grep -i \"waiting.*retry\" err &&\n>\n> test_grep can be helpful when reading the output of test failures, since\n> it dumps the contents of the file it was searching. Just make sure to\n> write \"test_grep !\" instead of \"! test_grep\" (there are a few such\n> instances of the latter that I just wrote patches to clean up).\n>\n> \"! test_grep\" isn't *wrong* per-se, but it will pollute the test output\n> with \"couldn't find xyz in abc\".\n>\n> I skimmed through the the remainder of the tests since I imagine that\n> they will change substantially after writing the script out explicitly\n> instead of using one-time-script, so I'll hold off on reviewing that\n> portion in more detail until then.\n>\n> Thanks,\n> Taylor\n"},{"id":"532469","messageId":"pull.2008.v2.git.1766069088.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.git.1764160227.gitgitgadget@gmail.com","subject":"[PATCH v2 0/2] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-12-18T14:44:46Z","receivedAt":"2025-12-18T14:44:52Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"Changes since v1:\n\n * removed configuration options from commit message\n * marked \"rate limited by %s ...\" message for translation in http-push.c\n * dropped redundant comments from retry configuration variables\n * renamed \"fwrite_wwwauth\" to \"fwrite_headers\". Due to complexity related\n   with header continuation handling, I've decide not to split into header\n   specific functions.\n * rewritten the sleep logic into non-blocking handling, so that the rest of\n   the slots can be processed at the time retry delay is requested\n * renamed \"http_request_reauth\" to \"http_request_recoverable\" to better\n   reflect that it does not only reauth , but also other recoverable\n   handling\n * updated test setup code to use setup script to reduce repetition in the\n   test code\n * updated to use test_grep instead of grep\n * dropped memory leak fix patch in afvor to rename of the\n   \"show_http_message\" to \"show_http_message_fatal\".\n * Adjusted alloc size in \"strbuf_reencode\" for \"strbuf_attach\" call, which\n   seems to solve the leak problem\n\nThe implementation includes:\n\nPatch 1: Core HTTP 429 retry logic with support for RFC-compliant\nRetry-After headers (both delay-seconds and HTTP-date formats),\ncomprehensive configuration options, and fail-fast behavior for excessive\ndelays. Includes extensive test coverage.\n\nPatch 2: Adds trace2 instrumentation to enable monitoring and debugging of\nretry operations in production environments.\n\nVaidas Pilkauskas (2):\n  http: add support for HTTP 429 rate limit retries\n  http: add trace2 logging for retry operations\n\n Documentation/config/http.adoc |  24 +++\n http-push.c                    |   8 +\n http-walker.c                  |   5 +\n http.c                         | 321 +++++++++++++++++++++++++++++----\n http.h                         |   4 +\n remote-curl.c                  |  49 +++--\n strbuf.c                       |   2 +-\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 286 +++++++++++++++++++++++++++++\n 12 files changed, 750 insertions(+), 57 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\n\nbase-commit: c4a0c8845e2426375ad257b6c221a3a7d92ecfda\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2008%2Fvaidas-shopify%2Fretry-after-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2008/vaidas-shopify/retry-after-v2\nPull-Request: https://github.com/gitgitgadget/git/pull/2008\n\nRange-diff vs v1:\n\n 1:  ae0087cd1c ! 1:  d80ce07703 http: add support for HTTP 429 rate limit retries\n     @@ Commit message\n          both delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\n          past date is provided, Git retries immediately without waiting.\n      \n     -    Retry behavior is controlled by three new configuration options:\n     -\n     -      * http.maxRetries: Maximum number of retry attempts (default: 0,\n     -        meaning retries are disabled by default). Users must explicitly\n     -        opt-in to retry behavior.\n     -\n     -      * http.retryAfter: Default delay in seconds when the server doesn't\n     -        provide a Retry-After header (default: -1, meaning fail if no\n     -        header is provided). This serves as a fallback mechanism.\n     -\n     -      * http.maxRetryTime: Maximum delay in seconds for a single retry\n     -        (default: 300). If the server requests a delay exceeding this\n     -        limit, Git fails immediately rather than waiting. This prevents\n     -        indefinite blocking on unreasonable server requests.\n     -\n     -    All three options can be overridden via environment variables:\n     -    GIT_HTTP_MAX_RETRIES, GIT_HTTP_RETRY_AFTER, and\n     -    GIT_HTTP_MAX_RETRY_TIME.\n     +    Retry behavior is controlled by three new configuration options\n     +    (http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\n     +    documented in git-config(1).\n      \n          The retry logic implements a fail-fast approach: if any delay\n          (whether from server header or configuration) exceeds maxRetryTime,\n     @@ http-push.c: static int fetch_indices(void)\n       \t\tret = 0;\n       \t\tbreak;\n      +\tcase HTTP_RATE_LIMITED:\n     -+\t\terror(\"rate limited by '%s', please try again later\", repo->url);\n     ++\t\terror(_(\"rate limited by '%s', please try again later\"), repo->url);\n      +\t\tret = -1;\n      +\t\tbreak;\n       \tdefault:\n     @@ http-push.c: static int remote_exists(const char *path)\n       \t\tret = 0;\n       \t\tbreak;\n      +\tcase HTTP_RATE_LIMITED:\n     -+\t\terror(\"rate limited by '%s', please try again later\", url);\n     ++\t\terror(_(\"rate limited by '%s', please try again later\"), url);\n      +\t\tret = -1;\n      +\t\tbreak;\n       \tcase HTTP_ERROR:\n     @@ http.c: static char *cached_accept_language;\n       \n       static int http_schannel_check_revoke = 1;\n      +\n     -+/* Retry configuration */\n     -+static long http_retry_after = -1; /* Default retry-after in seconds when header is missing (-1 means not set, exit with 128) */\n     -+static long http_max_retries = 0; /* Maximum number of retry attempts (0 means retries are disabled) */\n     -+static long http_max_retry_time = 300; /* Maximum time to wait for a single retry (default 5 minutes) */\n     ++static long http_retry_after = -1;\n     ++static long http_max_retries = 0;\n     ++static long http_max_retry_time = 300;\n      +\n     -+/* Store retry_after value from 429 responses for retry logic (-1 = not set, 0 = retry immediately, >0 = delay in seconds) */\n     -+static long last_retry_after = -1;\n       /*\n        * With the backend being set to `schannel`, setting sslCAinfo would override\n        * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n     @@ http.c: static inline int is_hdr_continuation(const char *ptr, const size_t size\n       }\n       \n      -static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n     -+static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p)\n     ++static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p)\n       {\n       \tsize_t size = eltsize * nmemb;\n       \tstruct strvec *values = &http_auth.wwwauth_headers;\n     @@ http.c: void http_init(struct remote *remote, const char *url, int proactive_aut\n       \tcurl_default = get_curl_handle();\n       }\n       \n     +@@ http.c: struct active_request_slot *get_active_slot(void)\n     + \tslot->finished = NULL;\n     + \tslot->callback_data = NULL;\n     + \tslot->callback_func = NULL;\n     ++\tslot->retry_delay_seconds = -1;\n     ++\tmemset(&slot->retry_delay_start, 0, sizeof(slot->retry_delay_start));\n     + \n     + \tif (curl_cookie_file && !strcmp(curl_cookie_file, \"-\")) {\n     + \t\twarning(_(\"refusing to read cookies from http.cookiefile '-'\"));\n     +@@ http.c: void run_active_slot(struct active_request_slot *slot)\n     + \tfd_set excfds;\n     + \tint max_fd;\n     + \tstruct timeval select_timeout;\n     ++\tlong curl_timeout;\n     ++\tstruct timeval start_time = {0}, current_time, elapsed_time = {0};\n     ++\tlong remaining_seconds;\n     + \tint finished = 0;\n     ++\tint slot_not_started = (slot->finished == NULL);\n     ++\tint waiting_for_delay = (slot->retry_delay_seconds > 0);\n     ++\n     ++\tif (waiting_for_delay) {\n     ++\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), slot->retry_delay_seconds);\n     ++\t\tstart_time = slot->retry_delay_start;\n     ++\t}\n     + \n     + \tslot->finished = &finished;\n     +-\twhile (!finished) {\n     ++\twhile (waiting_for_delay || !finished) {\n     ++\t\tif (waiting_for_delay) {\n     ++\t\t\tgettimeofday(&current_time, NULL);\n     ++\t\t\telapsed_time.tv_sec = current_time.tv_sec - start_time.tv_sec;\n     ++\t\t\telapsed_time.tv_usec = current_time.tv_usec - start_time.tv_usec;\n     ++\t\t\tif (elapsed_time.tv_usec < 0) {\n     ++\t\t\t\telapsed_time.tv_sec--;\n     ++\t\t\t\telapsed_time.tv_usec += 1000000;\n     ++\t\t\t}\n     ++\n     ++\t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n     ++\t\t\t\tslot->retry_delay_seconds = -1;\n     ++\t\t\t\twaiting_for_delay = 0;\n     ++\n     ++\t\t\t\tif (slot_not_started)\n     ++\t\t\t\t\treturn;\n     ++\t\t\t}\n     ++\t\t}\n     ++\n     + \t\tstep_active_slots();\n     + \n     +-\t\tif (slot->in_use) {\n     +-\t\t\tlong curl_timeout;\n     +-\t\t\tcurl_multi_timeout(curlm, &curl_timeout);\n     +-\t\t\tif (curl_timeout == 0) {\n     ++\t\tif (!waiting_for_delay && !slot->in_use)\n     ++\t\t\tcontinue;\n     ++\n     ++\t\tcurl_multi_timeout(curlm, &curl_timeout);\n     ++\t\tif (curl_timeout == 0) {\n     ++\t\t\tif (!waiting_for_delay)\n     + \t\t\t\tcontinue;\n     +-\t\t\t} else if (curl_timeout == -1) {\n     +-\t\t\t\tselect_timeout.tv_sec  = 0;\n     +-\t\t\t\tselect_timeout.tv_usec = 50000;\n     ++\t\t\tselect_timeout.tv_sec = 0;\n     ++\t\t\tselect_timeout.tv_usec = 50000; /* 50ms */\n     ++\t\t} else if (curl_timeout == -1) {\n     ++\t\t\tselect_timeout.tv_sec = 0;\n     ++\t\t\tselect_timeout.tv_usec = 50000;\n     ++\t\t} else {\n     ++\t\t\tlong curl_timeout_sec = curl_timeout / 1000;\n     ++\t\t\tlong curl_timeout_usec = (curl_timeout % 1000) * 1000;\n     ++\n     ++\t\t\tif (waiting_for_delay) {\n     ++\t\t\t\tremaining_seconds = slot->retry_delay_seconds - elapsed_time.tv_sec;\n     ++\t\t\t\tif (curl_timeout_sec < remaining_seconds) {\n     ++\t\t\t\t\tselect_timeout.tv_sec = curl_timeout_sec;\n     ++\t\t\t\t\tselect_timeout.tv_usec = curl_timeout_usec;\n     ++\t\t\t\t} else {\n     ++\t\t\t\t\tselect_timeout.tv_sec = remaining_seconds;\n     ++\t\t\t\t\tselect_timeout.tv_usec = 0;\n     ++\t\t\t\t}\n     + \t\t\t} else {\n     +-\t\t\t\tselect_timeout.tv_sec  =  curl_timeout / 1000;\n     +-\t\t\t\tselect_timeout.tv_usec = (curl_timeout % 1000) * 1000;\n     ++\t\t\t\tselect_timeout.tv_sec = curl_timeout_sec;\n     ++\t\t\t\tselect_timeout.tv_usec = curl_timeout_usec;\n     + \t\t\t}\n     ++\t\t}\n     + \n     +-\t\t\tmax_fd = -1;\n     +-\t\t\tFD_ZERO(&readfds);\n     +-\t\t\tFD_ZERO(&writefds);\n     +-\t\t\tFD_ZERO(&excfds);\n     +-\t\t\tcurl_multi_fdset(curlm, &readfds, &writefds, &excfds, &max_fd);\n     ++\t\tmax_fd = -1;\n     ++\t\tFD_ZERO(&readfds);\n     ++\t\tFD_ZERO(&writefds);\n     ++\t\tFD_ZERO(&excfds);\n     ++\t\tcurl_multi_fdset(curlm, &readfds, &writefds, &excfds, &max_fd);\n     + \n     +-\t\t\t/*\n     +-\t\t\t * It can happen that curl_multi_timeout returns a pathologically\n     +-\t\t\t * long timeout when curl_multi_fdset returns no file descriptors\n     +-\t\t\t * to read.  See commit message for more details.\n     +-\t\t\t */\n     +-\t\t\tif (max_fd < 0 &&\n     +-\t\t\t    (select_timeout.tv_sec > 0 ||\n     +-\t\t\t     select_timeout.tv_usec > 50000)) {\n     +-\t\t\t\tselect_timeout.tv_sec  = 0;\n     +-\t\t\t\tselect_timeout.tv_usec = 50000;\n     +-\t\t\t}\n     ++\t\t/*\n     ++\t\t * It can happen that curl_multi_timeout returns a pathologically\n     ++\t\t * long timeout when curl_multi_fdset returns no file descriptors\n     ++\t\t * to read.  See commit message for more details.\n     ++\t\t */\n     ++\t\tif (max_fd < 0 &&\n     ++\t\t    (select_timeout.tv_sec > 0 ||\n     ++\t\t     select_timeout.tv_usec > 50000)) {\n     ++\t\t\tselect_timeout.tv_sec = 0;\n     ++\t\t\tselect_timeout.tv_usec = 50000;\n     ++\t\t}\n     + \n     +-\t\t\tselect(max_fd+1, &readfds, &writefds, &excfds, &select_timeout);\n     ++\t\t/*\n     ++\t\t * If curl_multi_fdset returns no file descriptors but we have\n     ++\t\t * a timeout, still use select() to wait for the timeout period.\n     ++\t\t */\n     ++\t\tif (max_fd < 0) {\n     ++\t\t\t/* No file descriptors, just wait for timeout */\n     ++\t\t\tselect(0, NULL, NULL, NULL, &select_timeout);\n     ++\t\t} else {\n     ++\t\t\tselect(max_fd + 1, &readfds, &writefds, &excfds, &select_timeout);\n     + \t\t}\n     + \t}\n     + \n      @@ http.c: 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\t/* Store the retry_after value for use in retry logic */\n     -+\t\tlast_retry_after = results->retry_after;\n      +\t\treturn HTTP_RATE_LIMITED;\n       \t} else {\n       \t\tif (results->http_connectcode == 407)\n     @@ http.c: int run_one_slot(struct active_request_slot *slot,\n       \tslot->results = results;\n      +\t/* Initialize retry_after to -1 (not set) */\n      +\tresults->retry_after = -1;\n     ++\n     ++\t/* If there's a retry delay, wait for it before starting the slot */\n     ++\tif (slot->retry_delay_seconds > 0) {\n     ++\t\trun_active_slot(slot);\n     ++\t}\n     ++\n       \tif (!start_active_slot(slot)) {\n       \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n       \t\t\t  \"failed to start HTTP request\");\n     +@@ http.c: static void http_opt_request_remainder(CURL *curl, off_t pos)\n     + #define HTTP_REQUEST_STRBUF\t0\n     + #define HTTP_REQUEST_FILE\t1\n     + \n     ++static void sleep_for_retry(struct active_request_slot *slot, long retry_after);\n     ++\n     + static int http_request(const char *url,\n     + \t\t\tvoid *result, int target,\n     +-\t\t\tconst struct http_get_options *options)\n     ++\t\t\tconst struct http_get_options *options,\n     ++\t\t\tlong *retry_after_out,\n     ++\t\t\tlong retry_delay)\n     + {\n     + \tstruct active_request_slot *slot;\n     + \tstruct slot_results results;\n      @@ http.c: static int http_request(const char *url,\n     + \tint ret;\n     + \n     + \tslot = get_active_slot();\n     ++\t/* Mark slot for delay if retry delay is provided */\n     ++\tif (retry_delay > 0) {\n     ++\t\tsleep_for_retry(slot, retry_delay);\n     ++\t}\n     + \tcurl_easy_setopt(slot->curl, CURLOPT_HTTPGET, 1L);\n     + \n     + \tif (!result) {\n     +@@ http.c: static int http_request(const char *url,\n     + \t\t\t\t\t fwrite_buffer);\n       \t}\n       \n     - \tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n     +-\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n     ++\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n      +\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n       \n       \taccept_language = http_get_accept_language_header();\n       \n     +@@ http.c: static int http_request(const char *url,\n     + \n     + \tret = run_one_slot(slot, &results);\n     + \n     ++\t/* Store retry_after from slot results if output parameter provided */\n     ++\tif (retry_after_out)\n     ++\t\t*retry_after_out = results.retry_after;\n     ++\n     + \tif (options && options->content_type) {\n     + \t\tstruct strbuf raw = STRBUF_INIT;\n     + \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n      @@ http.c: static int update_url_from_redirect(struct strbuf *base,\n       \treturn 1;\n       }\n       \n     +-static int http_request_reauth(const char *url,\n      +/*\n     -+ * Sleep for the specified number of seconds before retrying.\n     ++ * Mark slot to be delayed for retry. The actual delay will be handled\n     ++ * in run_active_slot when the slot is executed.\n      + */\n     -+static void sleep_for_retry(long retry_after)\n     ++static void sleep_for_retry(struct active_request_slot *slot, long retry_after)\n      +{\n     -+\tif (retry_after > 0) {\n     -+\t\tunsigned int remaining;\n     ++\tif (retry_after > 0 && slot) {\n      +\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n     -+\t\tremaining = sleep(retry_after);\n     -+\t\twhile (remaining > 0) {\n     -+\t\t\t/* Sleep was interrupted, continue sleeping */\n     -+\t\t\tremaining = sleep(remaining);\n     ++\t\tslot->retry_delay_seconds = retry_after;\n     ++\t\tgettimeofday(&slot->retry_delay_start, NULL);\n     ++\t}\n     ++}\n     ++\n     ++/*\n     ++ * Handle rate limiting retry logic for HTTP 429 responses.\n     ++ * Uses slot-specific retry_after value to support concurrent slots.\n     ++ * Returns a negative value if retries are exhausted or configuration is invalid,\n     ++ * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n     ++ */\n     ++static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n     ++{\n     ++\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n     ++\tif (*rate_limit_retries <= 0) {\n     ++\t\t/* Retries are disabled or exhausted */\n     ++\t\tif (http_max_retries > 0) {\n     ++\t\t\terror(_(\"too many rate limit retries, giving up\"));\n     ++\t\t}\n     ++\t\treturn -1;\n     ++\t}\n     ++\n     ++\t/* Decrement retries counter */\n     ++\t(*rate_limit_retries)--;\n     ++\n     ++\t/* Use the slot-specific retry_after value or configured default */\n     ++\tif (slot_retry_after >= 0) {\n     ++\t\t/* Check if retry delay exceeds maximum allowed */\n     ++\t\tif (slot_retry_after > http_max_retry_time) {\n     ++\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n     ++\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n     ++\t\t\t      slot_retry_after, http_max_retry_time);\n     ++\t\t\treturn -1;\n     ++\t\t}\n     ++\t\treturn slot_retry_after;\n     ++\t} else {\n     ++\t\t/* No Retry-After header provided */\n     ++\t\tif (http_retry_after < 0) {\n     ++\t\t\t/* Not configured - exit with error */\n     ++\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n     ++\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n     ++\t\t\treturn -1;\n      +\t\t}\n     ++\t\t/* Check if configured default exceeds maximum allowed */\n     ++\t\tif (http_retry_after > http_max_retry_time) {\n     ++\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n     ++\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n     ++\t\t\t      http_retry_after, http_max_retry_time);\n     ++\t\t\treturn -1;\n     ++\t\t}\n     ++\n     ++\t\treturn http_retry_after;\n      +\t}\n      +}\n      +\n     - static int http_request_reauth(const char *url,\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       {\n       \tint i = 3;\n       \tint ret;\n      +\tint rate_limit_retries = http_max_retries;\n     ++\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n       \n       \tif (always_auth_proactively())\n       \t\tcredential_fill(the_repository, &http_auth, 1);\n       \n     - \tret = http_request(url, result, target, options);\n     +-\tret = http_request(url, result, target, options);\n     ++\tret = http_request(url, result, target, options, &slot_retry_after, -1);\n       \n      -\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 == 0)\n     ++\t\treturn HTTP_ERROR;\n     ++\n       \tif (options && 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      @@ http.c: static int http_request_reauth(const char *url,\n       \t\t}\n       \t}\n       \n      -\twhile (ret == HTTP_REAUTH && --i) {\n      +\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\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       \t\t * should clear it out before making our next request.\n      @@ http.c: static int http_request_reauth(const char *url,\n     + \t\tdefault:\n       \t\t\tBUG(\"Unknown http_request target\");\n       \t\t}\n     - \n     --\t\tcredential_fill(the_repository, &http_auth, 1);\n      +\t\tif (ret == HTTP_RATE_LIMITED) {\n     -+\t\t\t/* Handle rate limiting with retry logic */\n     -+\t\t\tint retry_attempt = http_max_retries - rate_limit_retries + 1;\n     -+\n     -+\t\t\tif (rate_limit_retries <= 0) {\n     -+\t\t\t\t/* Retries are disabled or exhausted */\n     -+\t\t\t\tif (http_max_retries > 0) {\n     -+\t\t\t\t\terror(_(\"too many rate limit retries, giving up\"));\n     -+\t\t\t\t}\n     ++\t\t\tretry_delay = handle_rate_limit_retry(&rate_limit_retries, slot_retry_after);\n     ++\t\t\tif (retry_delay < 0)\n      +\t\t\t\treturn HTTP_ERROR;\n     -+\t\t\t}\n     -+\n     -+\t\t\t/* Decrement retries counter */\n     -+\t\t\trate_limit_retries--;\n     -+\n     -+\t\t\t/* Use the stored retry_after value or configured default */\n     -+\t\t\tif (last_retry_after >= 0) {\n     -+\t\t\t\t/* Check if retry delay exceeds maximum allowed */\n     -+\t\t\t\tif (last_retry_after > http_max_retry_time) {\n     -+\t\t\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n     -+\t\t\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n     -+\t\t\t\t\t      last_retry_after, http_max_retry_time);\n     -+\t\t\t\t\tlast_retry_after = -1; /* Reset after use */\n     -+\t\t\t\t\treturn HTTP_ERROR;\n     -+\t\t\t\t}\n     -+\t\t\t\tsleep_for_retry(last_retry_after);\n     -+\t\t\t\tlast_retry_after = -1; /* Reset after use */\n     -+\t\t\t} else {\n     -+\t\t\t\t/* No Retry-After header provided */\n     -+\t\t\t\tif (http_retry_after < 0) {\n     -+\t\t\t\t\t/* Not configured - exit with error */\n     -+\t\t\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n     -+\t\t\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n     -+\t\t\t\t\treturn HTTP_ERROR;\n     -+\t\t\t\t}\n     -+\t\t\t\t/* Check if configured default exceeds maximum allowed */\n     -+\t\t\t\tif (http_retry_after > http_max_retry_time) {\n     -+\t\t\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n     -+\t\t\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n     -+\t\t\t\t\t      http_retry_after, http_max_retry_time);\n     -+\t\t\t\t\treturn HTTP_ERROR;\n     -+\t\t\t\t}\n     -+\t\t\t\t/* Use configured default retry-after value */\n     -+\t\t\t\tsleep_for_retry(http_retry_after);\n     -+\t\t\t}\n     ++\t\t\tslot_retry_after = -1; /* Reset after use */\n      +\t\t} else if (ret == HTTP_REAUTH) {\n      +\t\t\tcredential_fill(the_repository, &http_auth, 1);\n      +\t\t}\n       \n     - \t\tret = http_request(url, result, target, options);\n     +-\t\tcredential_fill(the_repository, &http_auth, 1);\n     +-\n     +-\t\tret = http_request(url, result, target, options);\n     ++\t\tret = http_request(url, result, target, options, &slot_retry_after, retry_delay);\n       \t}\n     + \treturn ret;\n     + }\n     +@@ http.c: int http_get_strbuf(const char *url,\n     + \t\t    struct strbuf *result,\n     + \t\t    struct http_get_options *options)\n     + {\n     +-\treturn http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);\n     ++\treturn http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);\n     + }\n     + \n     + /*\n     +@@ http.c: int http_get_file(const char *url, const char *filename,\n     + \t\tgoto cleanup;\n     + \t}\n     + \n     +-\tret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);\n     ++\tret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);\n     + \tfclose(result);\n     + \n     + \tif (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))\n      \n       ## http.h ##\n      @@ http.h: struct slot_results {\n     @@ http.h: struct slot_results {\n       };\n       \n       struct active_request_slot {\n     +@@ http.h: struct active_request_slot {\n     + \tvoid *callback_data;\n     + \tvoid (*callback_func)(void *data);\n     + \tstruct active_request_slot *next;\n     ++\tlong retry_delay_seconds;\n     ++\tstruct timeval retry_delay_start;\n     + };\n     + \n     + struct buffer {\n      @@ http.h: struct http_get_options {\n       #define HTTP_REAUTH\t4\n       #define HTTP_NOAUTH\t5\n     @@ http.h: struct http_get_options {\n        * Requests a URL and stores the result in a strbuf.\n      \n       ## remote-curl.c ##\n     +@@ remote-curl.c: static void free_discovery(struct discovery *d)\n     + \t}\n     + }\n     + \n     +-static int show_http_message(struct strbuf *type, struct strbuf *charset,\n     +-\t\t\t     struct strbuf *msg)\n     ++static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n     ++\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n     + {\n     + \tconst char *p, *eol;\n     ++\tva_list ap;\n     ++\treport_fn die_message_routine = get_die_message_routine();\n     + \n     + \t/*\n     + \t * We only show text/plain parts, as other types are likely\n     + \t * to be ugly to look at on the user's terminal.\n     + \t */\n     + \tif (strcmp(type->buf, \"text/plain\"))\n     +-\t\treturn -1;\n     ++\t\tgoto out;\n     + \tif (charset->len)\n     + \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n     + \n     + \tstrbuf_trim(msg);\n     + \tif (!msg->len)\n     +-\t\treturn -1;\n     ++\t\tgoto out;\n     + \n     + \tp = msg->buf;\n     + \tdo {\n     +@@ remote-curl.c: static int show_http_message(struct strbuf *type, struct strbuf *charset,\n     + \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n     + \t\tp = eol + 1;\n     + \t} while(*eol);\n     +-\treturn 0;\n     ++\n     ++out:\n     ++\tstrbuf_release(type);\n     ++\tstrbuf_release(charset);\n     ++\tstrbuf_release(msg);\n     ++\n     ++\tva_start(ap, fmt);\n     ++\tdie_message_routine(fmt, ap);\n     ++\tva_end(ap);\n     ++\texit(128);\n     + }\n     + \n     + static int get_protocol_http_header(enum protocol_version version,\n      @@ remote-curl.c: static struct discovery *discover_refs(const char *service, int for_push)\n     - \t\tshow_http_message(&type, &charset, &buffer);\n     - \t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     - \t\t    transport_anonymize_url(url.buf), curl_errorstr);\n     + \tcase HTTP_OK:\n     + \t\tbreak;\n     + \tcase HTTP_MISSING_TARGET:\n     +-\t\tshow_http_message(&type, &charset, &buffer);\n     +-\t\tdie(_(\"repository '%s' not found\"),\n     +-\t\t    transport_anonymize_url(url.buf));\n     ++\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     ++\t\t\t\t\t_(\"repository '%s' not found\"),\n     ++\t\t\t\t\ttransport_anonymize_url(url.buf));\n     + \tcase HTTP_NOAUTH:\n     +-\t\tshow_http_message(&type, &charset, &buffer);\n     +-\t\tdie(_(\"Authentication failed for '%s'\"),\n     +-\t\t    transport_anonymize_url(url.buf));\n     ++\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     ++\t\t\t\t\t_(\"Authentication failed for '%s'\"),\n     ++\t\t\t\t\ttransport_anonymize_url(url.buf));\n     + \tcase HTTP_NOMATCHPUBLICKEY:\n     +-\t\tshow_http_message(&type, &charset, &buffer);\n     +-\t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     +-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n     ++\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     ++\t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     ++\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n      +\tcase HTTP_RATE_LIMITED:\n     -+\t\tshow_http_message(&type, &charset, &buffer);\n     -+\t\tdie(_(\"rate limited by '%s', please try again later\"),\n     -+\t\t    transport_anonymize_url(url.buf));\n     ++\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     ++\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n     ++\t\t\t\t\ttransport_anonymize_url(url.buf));\n       \tdefault:\n     - \t\tshow_http_message(&type, &charset, &buffer);\n     - \t\tdie(_(\"unable to access '%s': %s\"),\n     +-\t\tshow_http_message(&type, &charset, &buffer);\n     +-\t\tdie(_(\"unable to access '%s': %s\"),\n     +-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n     ++\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     ++\t\t\t\t\t_(\"unable to access '%s': %s\"),\n     ++\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n     + \t}\n     + \n     + \tif (options.verbosity && !starts_with(refs_url.buf, url.buf)) {\n     +\n     + ## strbuf.c ##\n     +@@ strbuf.c: int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n     + \tif (!out)\n     + \t\treturn -1;\n     + \n     +-\tstrbuf_attach(sb, out, len, len);\n     ++\tstrbuf_attach(sb, out, len, len + 1);\n     + \treturn 0;\n     + }\n     + \n     +\n     + ## t/lib-httpd.sh ##\n     +@@ t/lib-httpd.sh: prepare_httpd() {\n     + \tinstall_script error.sh\n     + \tinstall_script apply-one-time-script.sh\n     + \tinstall_script nph-custom-auth.sh\n     ++\tinstall_script http-429.sh\n     + \n     + \tln -s \"$LIB_HTTPD_MODULE_PATH\" \"$HTTPD_ROOT_PATH/modules\"\n     + \n     +\n     + ## t/lib-httpd/apache.conf ##\n     +@@ t/lib-httpd/apache.conf: SetEnv PERL_PATH ${PERL_PATH}\n     + \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n     + \tSetEnv GIT_HTTP_EXPORT_ALL\n     + </LocationMatch>\n     ++<LocationMatch /http_429/>\n     ++\tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n     ++\tSetEnv GIT_HTTP_EXPORT_ALL\n     ++</LocationMatch>\n     + <LocationMatch /smart_v0/>\n     + \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n     + \tSetEnv GIT_HTTP_EXPORT_ALL\n     +@@ t/lib-httpd/apache.conf: ScriptAlias /broken_smart/ broken-smart-http.sh/\n     + ScriptAlias /error_smart/ error-smart-http.sh/\n     + ScriptAlias /error/ error.sh/\n     + ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1\n     ++ScriptAliasMatch /http_429/(.*) http-429.sh/$1\n     + ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n     + <Directory ${GIT_EXEC_PATH}>\n     + \tOptions FollowSymlinks\n     +@@ t/lib-httpd/apache.conf: ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n     + <Files apply-one-time-script.sh>\n     + \tOptions ExecCGI\n     + </Files>\n     ++<Files http-429.sh>\n     ++\tOptions ExecCGI\n     ++</Files>\n     + <Files ${GIT_EXEC_PATH}/git-http-backend>\n     + \tOptions ExecCGI\n     + </Files>\n     +\n     + ## t/lib-httpd/http-429.sh (new) ##\n     +@@\n     ++#!/bin/sh\n     ++\n     ++# Script to return HTTP 429 Too Many Requests responses for testing retry logic.\n     ++# Usage: /http_429/<test-context>/<retry-after-value>/<repo-path>\n     ++#\n     ++# The test-context is a unique identifier for each test to isolate state files.\n     ++# The retry-after-value can be:\n     ++#   - A number (e.g., \"1\", \"2\", \"100\") - sets Retry-After header to that many seconds\n     ++#   - \"none\" - no Retry-After header\n     ++#   - \"invalid\" - invalid Retry-After format\n     ++#   - \"permanent\" - always return 429 (never succeed)\n     ++#   - An HTTP-date string (RFC 2822 format) - sets Retry-After to that date\n     ++#\n     ++# On first call, returns 429. On subsequent calls (after retry), forwards to git-http-backend\n     ++# unless retry-after-value is \"permanent\".\n     ++\n     ++# Extract test context, retry-after value and repo path from PATH_INFO\n     ++# PATH_INFO format: /<test-context>/<retry-after-value>/<repo-path>\n     ++path_info=\"${PATH_INFO#/}\"  # Remove leading slash\n     ++test_context=\"${path_info%%/*}\"  # Get first component (test context)\n     ++remaining=\"${path_info#*/}\"  # Get rest\n     ++retry_after=\"${remaining%%/*}\"  # Get second component (retry-after value)\n     ++repo_path=\"${remaining#*/}\"  # Get rest (repo path)\n     ++\n     ++# Extract repository name from repo_path (e.g., \"repo.git\" from \"repo.git/info/refs\")\n     ++# The repo name is the first component before any \"/\"\n     ++repo_name=\"${repo_path%%/*}\"\n     ++\n     ++# Use current directory (HTTPD_ROOT_PATH) for state file\n     ++# Create a safe filename from test_context, retry_after and repo_name\n     ++# This ensures all requests for the same test context share the same state file\n     ++safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n     ++state_file=\"http-429-state-${safe_name}\"\n     ++\n     ++# Check if this is the first call (no state file exists)\n     ++if test -f \"$state_file\"\n     ++then\n     ++\t# Already returned 429 once, 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\n     ++\tPATH_INFO=\"/$repo_path\"\n     ++\texport PATH_INFO\n     ++\t# GIT_PROJECT_ROOT points to the document root where repositories are stored\n     ++\t# The script runs from HTTPD_ROOT_PATH, and www/ is the document root\n     ++\tif test -z \"$GIT_PROJECT_ROOT\"\n     ++\tthen\n     ++\t\t# Construct path: current directory (HTTPD_ROOT_PATH) + /www\n     ++\t\tGIT_PROJECT_ROOT=\"$(pwd)/www\"\n     ++\t\texport GIT_PROJECT_ROOT\n     ++\tfi\n     ++\texec \"$GIT_EXEC_PATH/git-http-backend\"\n     ++fi\n     ++\n     ++# Mark that we've returned 429\n     ++touch \"$state_file\"\n     ++\n     ++# Output HTTP 429 response\n     ++printf \"Status: 429 Too Many Requests\\r\\n\"\n     ++\n     ++# Set Retry-After header based on retry_after value\n     ++case \"$retry_after\" in\n     ++\tnone)\n     ++\t\t# No Retry-After header\n     ++\t\t;;\n     ++\tinvalid)\n     ++\t\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n     ++\t\t;;\n     ++\tpermanent)\n     ++\t\t# Always return 429, don't set state file for success\n     ++\t\trm -f \"$state_file\"\n     ++\t\tprintf \"Retry-After: 1\\r\\n\"\n     ++\t\tprintf \"Content-Type: text/plain\\r\\n\"\n     ++\t\tprintf \"\\r\\n\"\n     ++\t\tprintf \"Permanently rate limited\\n\"\n     ++\t\texit 0\n     ++\t\t;;\n     ++\t*)\n     ++\t\t# Check if it's a number\n     ++\t\tcase \"$retry_after\" in\n     ++\t\t\t[0-9]*)\n     ++\t\t\t\t# Numeric value\n     ++\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_after\"\n     ++\t\t\t\t;;\n     ++\t\t\t*)\n     ++\t\t\t\t# Assume it's an HTTP-date format (passed as-is, URL decoded)\n     ++\t\t\t\t# Apache may URL-encode the path, so decode common URL-encoded characters\n     ++\t\t\t\t# %20 = space, %2C = comma, %3A = colon\n     ++\t\t\t\tretry_value=$(echo \"$retry_after\" | sed -e 's/%20/ /g' -e 's/%2C/,/g' -e 's/%3A/:/g')\n     ++\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_value\"\n     ++\t\t\t\t;;\n     ++\t\tesac\n     ++\t\t;;\n     ++esac\n     ++\n     ++printf \"Content-Type: text/plain\\r\\n\"\n     ++printf \"\\r\\n\"\n     ++printf \"Rate limited\\n\"\n      \n       ## t/meson.build ##\n      @@ t/meson.build: integration_tests = [\n     @@ t/t5584-http-429-retry.sh (new)\n      +\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n      +'\n      +\n     -+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 1\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     ++# This test suite uses a special HTTP 429 endpoint at /http_429/ that simulates\n     ++# rate limiting. The endpoint format is:\n     ++#   /http_429/<test-context>/<retry-after-value>/<repo-path>\n     ++# The http-429.sh script (in t/lib-httpd) returns a 429 response with the\n     ++# specified Retry-After header on the first request for each test context,\n     ++# then forwards subsequent requests to git-http-backend. Each test context\n     ++# is isolated, allowing multiple tests to run independently.\n      +\n     ++test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n      +\t# Set maxRetries to 0 (disabled)\n      +\ttest_config http.maxRetries 0 &&\n      +\ttest_config http.retryAfter 1 &&\n      +\n      +\t# Should fail immediately without any retry attempt\n     -+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n     ++\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retries-disabled/1/repo.git\" 2>err &&\n      +\n      +\t# Verify no retry happened (no \"waiting\" message in stderr)\n     -+\t! grep -i \"waiting.*retry\" err &&\n     -+\n     -+\t# The one-time script will be consumed on first request (not a retry)\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep ! -i \"waiting.*retry\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 permanent should fail after max retries' '\n     -+\t# Install a permanent error script to prove retries are limited\n     -+\twrite_script \"$HTTPD_ROOT_PATH/http-429-permanent.sh\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 1\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Permanently rate limited\\n\"\n     -+\tEOF\n     -+\n      +\t# Enable retries with a limit\n      +\ttest_config http.maxRetries 2 &&\n      +\n      +\t# Git should retry but eventually fail when 429 persists\n     -+\ttest_must_fail git ls-remote \"$HTTPD_URL/error/http-429-permanent.sh/repo.git\" 2>err\n     ++\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/permanent-fail/permanent/repo.git\" 2>err\n      +'\n      +\n      +test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n     -+\t# Create a one-time script that returns 429 with Retry-After header\n     -+\t# on the first request. Subsequent requests will succeed.\n     -+\t# This contrasts with the permanent 429 above - proving retry works\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\t# Return HTTP 429 response instead of git response\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 1\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited - please retry after 1 second\\n\"\n     -+\t# Output something different from input so the script gets removed\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Enable retries\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Git should retry after receiving 429 and eventually succeed\n     -+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     -+\ttest_grep \"refs/heads/\" output &&\n     -+\n     -+\t# The one-time script should have been consumed (proving retry happened)\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\tgit ls-remote \"$HTTPD_URL/http_429/retry-succeeds/1/repo.git\" >output 2>err &&\n     ++\ttest_grep \"refs/heads/\" output\n      +'\n      +\n      +test_expect_success 'HTTP 429 without Retry-After uses configured default' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited - no retry info\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Enable retries and configure default delay\n      +\ttest_config http.maxRetries 3 &&\n      +\ttest_config http.retryAfter 1 &&\n      +\n      +\t# Git should retry using configured default and succeed\n     -+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     -+\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\tgit ls-remote \"$HTTPD_URL/http_429/no-retry-after-header/none/repo.git\" >output 2>err &&\n     ++\ttest_grep \"refs/heads/\" output\n      +'\n      +\n      +test_expect_success 'HTTP 429 retry delays are respected' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 2\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Enable retries\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Time the operation - it should take at least 2 seconds due to retry delay\n      +\tstart=$(date +%s) &&\n     -+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     ++\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Verify it took at least 2 seconds (allowing some tolerance)\n      +\ttest \"$duration\" -ge 1 &&\n     -+\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"refs/heads/\" output\n      +'\n      +\n      +test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 100\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited with long delay\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Configure max retry time to 3 seconds (much less than requested 100)\n      +\ttest_config http.maxRetries 3 &&\n      +\ttest_config http.maxRetryTime 3 &&\n      +\n      +\t# Should fail immediately without waiting\n      +\tstart=$(date +%s) &&\n     -+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n     ++\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n      +\ttest \"$duration\" -lt 2 &&\n     -+\ttest_grep \"exceeds http.maxRetryTime\" err &&\n     -+\n     -+\t# The one-time script will be consumed on first request\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"exceeds http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n      +\t# Test misconfiguration: retryAfter > maxRetryTime\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited without header\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Configure retryAfter larger than maxRetryTime\n      +\ttest_config http.maxRetries 3 &&\n      +\ttest_config http.retryAfter 100 &&\n     @@ t/t5584-http-429-retry.sh (new)\n      +\n      +\t# Should fail immediately with configuration error\n      +\tstart=$(date +%s) &&\n     -+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n     ++\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\t\ttest_done\n      +\tfi &&\n      +\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\\\r\\\\n\"\n     -+\tprintf \"Retry-After: $future_date\\\\r\\\\n\"\n     -+\tprintf \"Content-Type: text/plain\\\\r\\\\n\"\n     -+\tprintf \"\\\\r\\\\n\"\n     -+\tprintf \"Rate limited with HTTP-date\\\\n\"\n     -+\tcat \"\\$1\" >/dev/null\n     -+\tEOF\n     ++\t# URL-encode the date (replace spaces with %20)\n     ++\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n      +\n      +\t# Enable retries\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Git should parse the HTTP-date and retry after the delay\n      +\tstart=$(date +%s) &&\n     -+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     ++\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Should take at least 1 second (allowing tolerance for processing time)\n      +\ttest \"$duration\" -ge 1 &&\n     -+\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"refs/heads/\" output\n      +'\n      +\n      +test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n     @@ t/t5584-http-429-retry.sh (new)\n      +\t\ttest_done\n      +\tfi &&\n      +\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\\\r\\\\n\"\n     -+\tprintf \"Retry-After: $future_date\\\\r\\\\n\"\n     -+\tprintf \"Content-Type: text/plain\\\\r\\\\n\"\n     -+\tprintf \"\\\\r\\\\n\"\n     -+\tprintf \"Rate limited with long HTTP-date\\\\n\"\n     -+\tcat \"\\$1\" >/dev/null\n     -+\tEOF\n     ++\t# URL-encode the date (replace spaces with %20)\n     ++\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n      +\n      +\t# Configure max retry time much less than the 200 second delay\n      +\ttest_config http.maxRetries 3 &&\n     @@ t/t5584-http-429-retry.sh (new)\n      +\n      +\t# Should fail immediately without waiting 200 seconds\n      +\tstart=$(date +%s) &&\n     -+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n     ++\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Should fail quickly (not wait 200 seconds)\n      +\ttest \"$duration\" -lt 2 &&\n     -+\ttest_grep \"exceeds http.maxRetryTime\" err &&\n     -+\n     -+\t# The one-time script will be consumed on first request\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"exceeds http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n     @@ t/t5584-http-429-retry.sh (new)\n      +\t\ttest_done\n      +\tfi &&\n      +\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\\\r\\\\n\"\n     -+\tprintf \"Retry-After: $past_date\\\\r\\\\n\"\n     -+\tprintf \"Content-Type: text/plain\\\\r\\\\n\"\n     -+\tprintf \"\\\\r\\\\n\"\n     -+\tprintf \"Rate limited with past date\\\\n\"\n     -+\tcat \"\\$1\" >/dev/null\n     -+\tEOF\n     ++\t# URL-encode the date (replace spaces with %20)\n     ++\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n      +\n      +\t# Enable retries\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Git should retry immediately without waiting\n      +\tstart=$(date +%s) &&\n     -+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     ++\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Should complete quickly (less than 2 seconds)\n      +\ttest \"$duration\" -lt 2 &&\n     -+\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"refs/heads/\" output\n      +'\n      +\n      +test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited with malformed header\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Configure default retry-after\n      +\ttest_config http.maxRetries 3 &&\n      +\ttest_config http.retryAfter 1 &&\n      +\n      +\t# Should use configured default (1 second) since header is invalid\n      +\tstart=$(date +%s) &&\n     -+\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     ++\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Should take at least 1 second (the configured default)\n      +\ttest \"$duration\" -ge 1 &&\n      +\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_grep \"waiting.*retry\" err &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"waiting.*retry\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 will not be retried without config' '\n      +\t# Default config means http.maxRetries=0 (retries disabled)\n      +\t# When 429 is received, it should fail immediately without retry\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 1\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n      +\n      +\t# Should fail immediately without retry\n     -+\ttest_must_fail git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n     ++\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/no-retry-without-config/1/repo.git\" 2>err &&\n      +\n      +\t# Verify no retry happened (no \"waiting\" message)\n     -+\t! grep -i \"waiting.*retry\" err &&\n     ++\ttest_grep ! -i \"waiting.*retry\" err &&\n      +\n      +\t# Should get 429 error\n     -+\ttest_grep \"429\" err &&\n     -+\n     -+\t# The one-time script should be consumed on first request\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"429\" err\n      +'\n      +\n      +test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited - no Retry-After header\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Configure retryAfter to 10 seconds\n      +\ttest_config http.maxRetries 3 &&\n      +\ttest_config http.retryAfter 10 &&\n      +\n      +\t# Override with environment variable to 1 second\n      +\tstart=$(date +%s) &&\n     -+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     ++\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest \"$duration\" -ge 1 &&\n      +\ttest \"$duration\" -lt 5 &&\n      +\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_grep \"waiting.*retry\" err &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"waiting.*retry\" err\n      +'\n      +\n      +test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 1\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Configure maxRetries to 0 (disabled)\n      +\ttest_config http.maxRetries 0 &&\n      +\ttest_config http.retryAfter 1 &&\n      +\n      +\t# Override with environment variable to enable retries\n     -+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/one_time_script/repo.git\" >output 2>err &&\n     ++\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/http_429/env-max-retries-override/1/repo.git\" >output 2>err &&\n      +\n      +\t# Should retry (env var enables it despite config saying disabled)\n      +\ttest_grep \"refs/heads/\" output &&\n     -+\ttest_grep \"waiting.*retry\" err &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"waiting.*retry\" err\n      +'\n      +\n      +test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n     -+\twrite_script \"$HTTPD_ROOT_PATH/one-time-script\" <<-\\EOF &&\n     -+\tprintf \"Status: 429 Too Many Requests\\r\\n\"\n     -+\tprintf \"Retry-After: 50\\r\\n\"\n     -+\tprintf \"Content-Type: text/plain\\r\\n\"\n     -+\tprintf \"\\r\\n\"\n     -+\tprintf \"Rate limited with long delay\\n\"\n     -+\tcat \"$1\" >/dev/null\n     -+\tEOF\n     -+\n      +\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n      +\ttest_config http.maxRetries 3 &&\n      +\ttest_config http.maxRetryTime 100 &&\n     @@ t/t5584-http-429-retry.sh (new)\n      +\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n      +\tstart=$(date +%s) &&\n      +\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n     -+\t\tgit ls-remote \"$HTTPD_URL/one_time_script/repo.git\" 2>err &&\n     ++\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n      +\tend=$(date +%s) &&\n      +\tduration=$((end - start)) &&\n      +\n      +\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n      +\ttest \"$duration\" -lt 5 &&\n     -+\ttest_grep \"exceeds http.maxRetryTime\" err &&\n     -+\ttest_path_is_missing \"$HTTPD_ROOT_PATH/one-time-script\"\n     ++\ttest_grep \"exceeds http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'verify normal repository access still works' '\n 2:  4382237922 < -:  ---------- remote-curl: fix memory leak in show_http_message()\n 3:  adbcc0251f ! 2:  ad4495fc94 http: add trace2 logging for retry operations\n     @@ http.c\n       \n       static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n       static int trace_curl_data = 1;\n     +@@ http.c: void run_active_slot(struct active_request_slot *slot)\n     + \n     + \tif (waiting_for_delay) {\n     + \t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), slot->retry_delay_seconds);\n     ++\ttrace2_data_intmax(\"http\", the_repository, \"http/retry-sleep-seconds\",\n     ++\t\tslot->retry_delay_seconds);\n     + \t\tstart_time = slot->retry_delay_start;\n     + \t}\n     + \n     +@@ http.c: void run_active_slot(struct active_request_slot *slot)\n     + \t\t\t}\n     + \n     + \t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n     ++\t\t\t\ttrace2_region_leave(\"http\", \"retry-sleep\", the_repository);\n     + \t\t\t\tslot->retry_delay_seconds = -1;\n     + \t\t\t\twaiting_for_delay = 0;\n     + \n      @@ http.c: static int handle_curl_result(struct slot_results *results)\n     + \t\t\treturn HTTP_REAUTH;\n     + \t\t}\n       \t} else if (results->http_code == 429) {\n     - \t\t/* Store the retry_after value for use in retry logic */\n     - \t\tlast_retry_after = results->retry_after;\n      +\t\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n     -+\t\t\t\t   last_retry_after);\n     ++\t\t\tresults->retry_after);\n       \t\treturn HTTP_RATE_LIMITED;\n       \t} else {\n       \t\tif (results->http_connectcode == 407)\n     -@@ http.c: static void sleep_for_retry(long retry_after)\n     - \tif (retry_after > 0) {\n     - \t\tunsigned int remaining;\n     - \t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n     -+\t\ttrace2_region_enter(\"http\", \"retry-sleep\", the_repository);\n     -+\t\ttrace2_data_intmax(\"http\", the_repository, \"http/retry-sleep-seconds\",\n     -+\t\t\t\tretry_after);\n     - \t\tremaining = sleep(retry_after);\n     - \t\twhile (remaining > 0) {\n     - \t\t\t/* Sleep was interrupted, continue sleeping */\n     - \t\t\tremaining = sleep(remaining);\n     +@@ http.c: static void sleep_for_retry(struct active_request_slot *slot, long retry_after)\n     + static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n     + {\n     + \tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n     ++\n     ++\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n     ++\t\tretry_attempt);\n     ++\n     + \tif (*rate_limit_retries <= 0) {\n     + \t\t/* Retries are disabled or exhausted */\n     + \t\tif (http_max_retries > 0) {\n     + \t\t\terror(_(\"too many rate limit retries, giving up\"));\n     ++\t\t\ttrace2_data_string(\"http\", the_repository,\n     ++\t\t\t\t\"http/429-error\", \"retries-exhausted\");\n       \t\t}\n     -+\t\ttrace2_region_leave(\"http\", \"retry-sleep\", the_repository);\n     + \t\treturn -1;\n       \t}\n     - }\n     - \n     -@@ http.c: static int http_request_reauth(const char *url,\n     - \t\t\t/* Handle rate limiting with retry logic */\n     - \t\t\tint retry_attempt = http_max_retries - rate_limit_retries + 1;\n     - \n     -+\t\t\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n     -+\t\t\t\t\tretry_attempt);\n     -+\n     - \t\t\tif (rate_limit_retries <= 0) {\n     - \t\t\t\t/* Retries are disabled or exhausted */\n     - \t\t\t\tif (http_max_retries > 0) {\n     - \t\t\t\t\terror(_(\"too many rate limit retries, giving up\"));\n     -+\t\t\t\t\ttrace2_data_string(\"http\", the_repository,\n     -+\t\t\t\t\t\t\t\"http/429-error\", \"retries-exhausted\");\n     - \t\t\t\t}\n     - \t\t\t\treturn HTTP_ERROR;\n     - \t\t\t}\n     -@@ http.c: static int http_request_reauth(const char *url,\n     - \t\t\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n     - \t\t\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n     - \t\t\t\t\t      last_retry_after, http_max_retry_time);\n     -+\t\t\t\t\ttrace2_data_string(\"http\", the_repository,\n     -+\t\t\t\t\t\t\t\"http/429-error\", \"exceeds-max-retry-time\");\n     -+\t\t\t\t\ttrace2_data_intmax(\"http\", the_repository,\n     -+\t\t\t\t\t\t\t\"http/429-requested-delay\", last_retry_after);\n     - \t\t\t\t\tlast_retry_after = -1; /* Reset after use */\n     - \t\t\t\t\treturn HTTP_ERROR;\n     - \t\t\t\t}\n     -@@ http.c: static int http_request_reauth(const char *url,\n     - \t\t\t\t\t/* Not configured - exit with error */\n     - \t\t\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n     - \t\t\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n     -+\t\t\t\t\ttrace2_data_string(\"http\", the_repository,\n     -+\t\t\t\t\t\t\t\"http/429-error\", \"no-retry-after-config\");\n     - \t\t\t\t\treturn HTTP_ERROR;\n     - \t\t\t\t}\n     --\t\t\t\t/* Check if configured default exceeds maximum allowed */\n     --\t\t\t\tif (http_retry_after > http_max_retry_time) {\n     --\t\t\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n     --\t\t\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n     --\t\t\t\t\t      http_retry_after, http_max_retry_time);\n     --\t\t\t\t\treturn HTTP_ERROR;\n     --\t\t\t\t}\n     --\t\t\t\t/* Use configured default retry-after value */\n     --\t\t\t\tsleep_for_retry(http_retry_after);\n     -+\t\t\t/* Check if configured default exceeds maximum allowed */\n     -+\t\t\tif (http_retry_after > http_max_retry_time) {\n     -+\t\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n     -+\t\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n     -+\t\t\t\t      http_retry_after, http_max_retry_time);\n     -+\t\t\t\ttrace2_data_string(\"http\", the_repository,\n     -+\t\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n     -+\t\t\t\treturn HTTP_ERROR;\n     -+\t\t\t}\n     -+\t\t\t/* Use configured default retry-after value */\n     +@@ http.c: static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_aft\n     + \t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n     + \t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n     + \t\t\t      slot_retry_after, http_max_retry_time);\n      +\t\t\ttrace2_data_string(\"http\", the_repository,\n     -+\t\t\t\t\t\"http/429-retry-source\", \"config-default\");\n     -+\t\t\tsleep_for_retry(http_retry_after);\n     - \t\t\t}\n     - \t\t} else if (ret == HTTP_REAUTH) {\n     - \t\t\tcredential_fill(the_repository, &http_auth, 1);\n     ++\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n     ++\t\t\ttrace2_data_intmax(\"http\", the_repository,\n     ++\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n     + \t\t\treturn -1;\n     + \t\t}\n     + \t\treturn slot_retry_after;\n     +@@ http.c: static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_aft\n     + \t\t\t/* Not configured - exit with error */\n     + \t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n     + \t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n     ++\t\t\ttrace2_data_string(\"http\", the_repository,\n     ++\t\t\t\t\"http/429-error\", \"no-retry-after-config\");\n     + \t\t\treturn -1;\n     + \t\t}\n     + \t\t/* Check if configured default exceeds maximum allowed */\n     +@@ http.c: static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_aft\n     + \t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n     + \t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n     + \t\t\t      http_retry_after, http_max_retry_time);\n     ++\t\t\ttrace2_data_string(\"http\", the_repository,\n     ++\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n     + \t\t\treturn -1;\n     + \t\t}\n     +-\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}\n     + }\n\n-- \ngitgitgadget\n"},{"id":"532471","messageId":"d80ce077038bab96aca26b0b0ad706c91ea1d8a8.1766069088.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v2.git.1766069088.gitgitgadget@gmail.com","subject":"[PATCH v2 1/2] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-12-18T14:44:47Z","receivedAt":"2025-12-18T14:44:53Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd retry logic for HTTP 429 (Too Many Requests) responses to handle\nserver-side rate limiting gracefully. When Git's HTTP client receives\na 429 response, it can now automatically retry the request after an\nappropriate delay, respecting the server's rate limits.\n\nThe implementation supports the RFC-compliant Retry-After header in\nboth delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\npast date is provided, Git retries immediately without waiting.\n\nRetry behavior is controlled by three new configuration options\n(http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\ndocumented in git-config(1).\n\nThe retry logic implements a fail-fast approach: if any delay\n(whether from server header or configuration) exceeds maxRetryTime,\nGit fails immediately with a clear error message rather than capping\nthe delay. This provides better visibility into rate limiting issues.\n\nThe implementation includes extensive test coverage for basic retry\nbehavior, Retry-After header formats (integer and HTTP-date),\nconfiguration combinations, maxRetryTime limits, invalid header\nhandling, environment variable overrides, and edge cases.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n Documentation/config/http.adoc |  24 +++\n http-push.c                    |   8 +\n http-walker.c                  |   5 +\n http.c                         | 300 ++++++++++++++++++++++++++++-----\n http.h                         |   4 +\n remote-curl.c                  |  49 ++++--\n strbuf.c                       |   2 +-\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 +++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 286 +++++++++++++++++++++++++++++++\n 12 files changed, 729 insertions(+), 57 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 9da5c298cc..9e3c888df4 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -315,6 +315,30 @@ http.keepAliveCount::\n \tunset, curl's default value is used. Can be overridden by the\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+\tHTTP 429 (Too Many Requests) without a Retry-After header. If set\n+\tto -1 (the default), Git will fail immediately when encountering\n+\ta 429 response without a Retry-After header. When a Retry-After\n+\theader is present, its value takes precedence over this setting.\n+\tCan be overridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n+\tSee also `http.maxRetries` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n+\tSee also `http.retryAfter` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n+\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\n \tThis can be helpful with some \"poor\" ftp servers which don't\ndiff --git a/http-push.c b/http-push.c\nindex 60a9b75620..ddb9948352 100644\n--- a/http-push.c\n+++ b/http-push.c\n@@ -716,6 +716,10 @@ static int fetch_indices(void)\n \tcase HTTP_MISSING_TARGET:\n \t\tret = 0;\n \t\tbreak;\n+\tcase HTTP_RATE_LIMITED:\n+\t\terror(_(\"rate limited by '%s', please try again later\"), repo->url);\n+\t\tret = -1;\n+\t\tbreak;\n \tdefault:\n \t\tret = -1;\n \t}\n@@ -1548,6 +1552,10 @@ static int remote_exists(const char *path)\n \tcase HTTP_MISSING_TARGET:\n \t\tret = 0;\n \t\tbreak;\n+\tcase HTTP_RATE_LIMITED:\n+\t\terror(_(\"rate limited by '%s', please try again later\"), url);\n+\t\tret = -1;\n+\t\tbreak;\n \tcase HTTP_ERROR:\n \t\terror(\"unable to access '%s': %s\", url, curl_errorstr);\n \t\t/* fallthrough */\ndiff --git a/http-walker.c b/http-walker.c\nindex e886e64866..9f06f47de1 100644\n--- a/http-walker.c\n+++ b/http-walker.c\n@@ -414,6 +414,11 @@ static int fetch_indices(struct walker *walker, struct alt_base *repo)\n \t\trepo->got_indices = 1;\n \t\tret = 0;\n \t\tbreak;\n+\tcase HTTP_RATE_LIMITED:\n+\t\terror(\"rate limited by '%s', please try again later\", repo->base);\n+\t\trepo->got_indices = 0;\n+\t\tret = -1;\n+\t\tbreak;\n \tdefault:\n \t\trepo->got_indices = 0;\n \t\tret = -1;\ndiff --git a/http.c b/http.c\nindex 41f850db16..60e0364f57 100644\n--- a/http.c\n+++ b/http.c\n@@ -22,6 +22,7 @@\n #include \"object-file.h\"\n #include \"odb.h\"\n #include \"tempfile.h\"\n+#include \"date.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -149,6 +150,11 @@ static char *cached_accept_language;\n static char *http_ssl_backend;\n \n static int http_schannel_check_revoke = 1;\n+\n+static long http_retry_after = -1;\n+static long http_max_retries = 0;\n+static long http_max_retry_time = 300;\n+\n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n  * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n@@ -209,13 +215,14 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)\n \treturn size && (*ptr == ' ' || *ptr == '\\t');\n }\n \n-static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n+static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p)\n {\n \tsize_t size = eltsize * nmemb;\n \tstruct strvec *values = &http_auth.wwwauth_headers;\n \tstruct strbuf buf = STRBUF_INIT;\n \tconst char *val;\n \tsize_t val_len;\n+\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n \n \t/*\n \t * Header lines may not come NULL-terminated from libcurl so we must\n@@ -257,6 +264,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n \t\tgoto exit;\n \t}\n \n+\t/* Parse Retry-After header for rate limiting */\n+\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n+\t\tstrbuf_add(&buf, val, val_len);\n+\t\tstrbuf_trim(&buf);\n+\n+\t\tif (slot && slot->results) {\n+\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n+\t\t\tchar *endptr;\n+\t\t\tlong retry_after;\n+\n+\t\t\terrno = 0;\n+\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n+\n+\t\t\t/* Check if it's a valid integer (delay-seconds format) */\n+\t\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n+\t\t\t    errno != ERANGE && retry_after > 0) {\n+\t\t\t\tslot->results->retry_after = retry_after;\n+\t\t\t} else {\n+\t\t\t\t/* Try parsing as HTTP-date format */\n+\t\t\t\ttimestamp_t timestamp;\n+\t\t\t\tint offset;\n+\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n+\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n+\t\t\t\t\ttimestamp_t now = time(NULL);\n+\t\t\t\t\tif (timestamp > now) {\n+\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n+\t\t\t\t\t} else {\n+\t\t\t\t\t\t/* Past date means retry immediately */\n+\t\t\t\t\t\tslot->results->retry_after = 0;\n+\t\t\t\t\t}\n+\t\t\t\t} else {\n+\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n+\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n+\t\t\t\t}\n+\t\t\t}\n+\t\t}\n+\n+\t\thttp_auth.header_is_last_match = 1;\n+\t\tgoto exit;\n+\t}\n+\n \t/*\n \t * This line could be a continuation of the previously matched header\n \t * field. If this is the case then we should append this value to the\n@@ -575,6 +623,21 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retryafter\", var)) {\n+\t\thttp_retry_after = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretries\", var)) {\n+\t\thttp_max_retries = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretrytime\", var)) {\n+\t\thttp_max_retry_time = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1422,6 +1485,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n \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+\n \tcurl_default = get_curl_handle();\n }\n \n@@ -1529,6 +1596,8 @@ struct active_request_slot *get_active_slot(void)\n \tslot->finished = NULL;\n \tslot->callback_data = NULL;\n \tslot->callback_func = NULL;\n+\tslot->retry_delay_seconds = -1;\n+\tmemset(&slot->retry_delay_start, 0, sizeof(slot->retry_delay_start));\n \n \tif (curl_cookie_file && !strcmp(curl_cookie_file, \"-\")) {\n \t\twarning(_(\"refusing to read cookies from http.cookiefile '-'\"));\n@@ -1660,44 +1729,98 @@ void run_active_slot(struct active_request_slot *slot)\n \tfd_set excfds;\n \tint max_fd;\n \tstruct timeval select_timeout;\n+\tlong curl_timeout;\n+\tstruct timeval start_time = {0}, current_time, elapsed_time = {0};\n+\tlong remaining_seconds;\n \tint finished = 0;\n+\tint slot_not_started = (slot->finished == NULL);\n+\tint waiting_for_delay = (slot->retry_delay_seconds > 0);\n+\n+\tif (waiting_for_delay) {\n+\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), slot->retry_delay_seconds);\n+\t\tstart_time = slot->retry_delay_start;\n+\t}\n \n \tslot->finished = &finished;\n-\twhile (!finished) {\n+\twhile (waiting_for_delay || !finished) {\n+\t\tif (waiting_for_delay) {\n+\t\t\tgettimeofday(&current_time, NULL);\n+\t\t\telapsed_time.tv_sec = current_time.tv_sec - start_time.tv_sec;\n+\t\t\telapsed_time.tv_usec = current_time.tv_usec - start_time.tv_usec;\n+\t\t\tif (elapsed_time.tv_usec < 0) {\n+\t\t\t\telapsed_time.tv_sec--;\n+\t\t\t\telapsed_time.tv_usec += 1000000;\n+\t\t\t}\n+\n+\t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n+\t\t\t\tslot->retry_delay_seconds = -1;\n+\t\t\t\twaiting_for_delay = 0;\n+\n+\t\t\t\tif (slot_not_started)\n+\t\t\t\t\treturn;\n+\t\t\t}\n+\t\t}\n+\n \t\tstep_active_slots();\n \n-\t\tif (slot->in_use) {\n-\t\t\tlong curl_timeout;\n-\t\t\tcurl_multi_timeout(curlm, &curl_timeout);\n-\t\t\tif (curl_timeout == 0) {\n+\t\tif (!waiting_for_delay && !slot->in_use)\n+\t\t\tcontinue;\n+\n+\t\tcurl_multi_timeout(curlm, &curl_timeout);\n+\t\tif (curl_timeout == 0) {\n+\t\t\tif (!waiting_for_delay)\n \t\t\t\tcontinue;\n-\t\t\t} else if (curl_timeout == -1) {\n-\t\t\t\tselect_timeout.tv_sec  = 0;\n-\t\t\t\tselect_timeout.tv_usec = 50000;\n+\t\t\tselect_timeout.tv_sec = 0;\n+\t\t\tselect_timeout.tv_usec = 50000; /* 50ms */\n+\t\t} else if (curl_timeout == -1) {\n+\t\t\tselect_timeout.tv_sec = 0;\n+\t\t\tselect_timeout.tv_usec = 50000;\n+\t\t} else {\n+\t\t\tlong curl_timeout_sec = curl_timeout / 1000;\n+\t\t\tlong curl_timeout_usec = (curl_timeout % 1000) * 1000;\n+\n+\t\t\tif (waiting_for_delay) {\n+\t\t\t\tremaining_seconds = slot->retry_delay_seconds - elapsed_time.tv_sec;\n+\t\t\t\tif (curl_timeout_sec < remaining_seconds) {\n+\t\t\t\t\tselect_timeout.tv_sec = curl_timeout_sec;\n+\t\t\t\t\tselect_timeout.tv_usec = curl_timeout_usec;\n+\t\t\t\t} else {\n+\t\t\t\t\tselect_timeout.tv_sec = remaining_seconds;\n+\t\t\t\t\tselect_timeout.tv_usec = 0;\n+\t\t\t\t}\n \t\t\t} else {\n-\t\t\t\tselect_timeout.tv_sec  =  curl_timeout / 1000;\n-\t\t\t\tselect_timeout.tv_usec = (curl_timeout % 1000) * 1000;\n+\t\t\t\tselect_timeout.tv_sec = curl_timeout_sec;\n+\t\t\t\tselect_timeout.tv_usec = curl_timeout_usec;\n \t\t\t}\n+\t\t}\n \n-\t\t\tmax_fd = -1;\n-\t\t\tFD_ZERO(&readfds);\n-\t\t\tFD_ZERO(&writefds);\n-\t\t\tFD_ZERO(&excfds);\n-\t\t\tcurl_multi_fdset(curlm, &readfds, &writefds, &excfds, &max_fd);\n+\t\tmax_fd = -1;\n+\t\tFD_ZERO(&readfds);\n+\t\tFD_ZERO(&writefds);\n+\t\tFD_ZERO(&excfds);\n+\t\tcurl_multi_fdset(curlm, &readfds, &writefds, &excfds, &max_fd);\n \n-\t\t\t/*\n-\t\t\t * It can happen that curl_multi_timeout returns a pathologically\n-\t\t\t * long timeout when curl_multi_fdset returns no file descriptors\n-\t\t\t * to read.  See commit message for more details.\n-\t\t\t */\n-\t\t\tif (max_fd < 0 &&\n-\t\t\t    (select_timeout.tv_sec > 0 ||\n-\t\t\t     select_timeout.tv_usec > 50000)) {\n-\t\t\t\tselect_timeout.tv_sec  = 0;\n-\t\t\t\tselect_timeout.tv_usec = 50000;\n-\t\t\t}\n+\t\t/*\n+\t\t * It can happen that curl_multi_timeout returns a pathologically\n+\t\t * long timeout when curl_multi_fdset returns no file descriptors\n+\t\t * to read.  See commit message for more details.\n+\t\t */\n+\t\tif (max_fd < 0 &&\n+\t\t    (select_timeout.tv_sec > 0 ||\n+\t\t     select_timeout.tv_usec > 50000)) {\n+\t\t\tselect_timeout.tv_sec = 0;\n+\t\t\tselect_timeout.tv_usec = 50000;\n+\t\t}\n \n-\t\t\tselect(max_fd+1, &readfds, &writefds, &excfds, &select_timeout);\n+\t\t/*\n+\t\t * If curl_multi_fdset returns no file descriptors but we have\n+\t\t * a timeout, still use select() to wait for the timeout period.\n+\t\t */\n+\t\tif (max_fd < 0) {\n+\t\t\t/* No file descriptors, just wait for timeout */\n+\t\t\tselect(0, NULL, NULL, NULL, &select_timeout);\n+\t\t} else {\n+\t\t\tselect(max_fd + 1, &readfds, &writefds, &excfds, &select_timeout);\n \t\t}\n \t}\n \n@@ -1871,6 +1994,8 @@ 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\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n \t\t\tcredential_reject(the_repository, &proxy_auth);\n@@ -1886,6 +2011,14 @@ int run_one_slot(struct active_request_slot *slot,\n \t\t struct slot_results *results)\n {\n \tslot->results = results;\n+\t/* Initialize retry_after to -1 (not set) */\n+\tresults->retry_after = -1;\n+\n+\t/* If there's a retry delay, wait for it before starting the slot */\n+\tif (slot->retry_delay_seconds > 0) {\n+\t\trun_active_slot(slot);\n+\t}\n+\n \tif (!start_active_slot(slot)) {\n \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n \t\t\t  \"failed to start HTTP request\");\n@@ -2117,9 +2250,13 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n #define HTTP_REQUEST_STRBUF\t0\n #define HTTP_REQUEST_FILE\t1\n \n+static void sleep_for_retry(struct active_request_slot *slot, long retry_after);\n+\n static int http_request(const char *url,\n \t\t\tvoid *result, int target,\n-\t\t\tconst struct http_get_options *options)\n+\t\t\tconst struct http_get_options *options,\n+\t\t\tlong *retry_after_out,\n+\t\t\tlong retry_delay)\n {\n \tstruct active_request_slot *slot;\n \tstruct slot_results results;\n@@ -2129,6 +2266,10 @@ static int http_request(const char *url,\n \tint ret;\n \n \tslot = get_active_slot();\n+\t/* Mark slot for delay if retry delay is provided */\n+\tif (retry_delay > 0) {\n+\t\tsleep_for_retry(slot, retry_delay);\n+\t}\n \tcurl_easy_setopt(slot->curl, CURLOPT_HTTPGET, 1L);\n \n \tif (!result) {\n@@ -2148,7 +2289,8 @@ static int http_request(const char *url,\n \t\t\t\t\t fwrite_buffer);\n \t}\n \n-\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n \n \taccept_language = http_get_accept_language_header();\n \n@@ -2183,6 +2325,10 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n+\t/* Store retry_after from slot results if output parameter provided */\n+\tif (retry_after_out)\n+\t\t*retry_after_out = results.retry_after;\n+\n \tif (options && options->content_type) {\n \t\tstruct strbuf raw = STRBUF_INIT;\n \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n@@ -2253,21 +2399,90 @@ static int update_url_from_redirect(struct strbuf *base,\n \treturn 1;\n }\n \n-static int http_request_reauth(const char *url,\n+/*\n+ * Mark slot to be delayed for retry. The actual delay will be handled\n+ * in run_active_slot when the slot is executed.\n+ */\n+static void sleep_for_retry(struct active_request_slot *slot, long retry_after)\n+{\n+\tif (retry_after > 0 && slot) {\n+\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n+\t\tslot->retry_delay_seconds = retry_after;\n+\t\tgettimeofday(&slot->retry_delay_start, NULL);\n+\t}\n+}\n+\n+/*\n+ * Handle rate limiting retry logic for HTTP 429 responses.\n+ * Uses slot-specific retry_after value to support concurrent slots.\n+ * Returns a negative value if retries are exhausted or configuration is invalid,\n+ * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n+ */\n+static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n+{\n+\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n+\tif (*rate_limit_retries <= 0) {\n+\t\t/* Retries are disabled or exhausted */\n+\t\tif (http_max_retries > 0) {\n+\t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t}\n+\t\treturn -1;\n+\t}\n+\n+\t/* Decrement retries counter */\n+\t(*rate_limit_retries)--;\n+\n+\t/* Use the slot-specific retry_after value or configured default */\n+\tif (slot_retry_after >= 0) {\n+\t\t/* Check if retry delay exceeds maximum allowed */\n+\t\tif (slot_retry_after > http_max_retry_time) {\n+\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n+\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n+\t\t\t      slot_retry_after, http_max_retry_time);\n+\t\t\treturn -1;\n+\t\t}\n+\t\treturn slot_retry_after;\n+\t} else {\n+\t\t/* No Retry-After header provided */\n+\t\tif (http_retry_after < 0) {\n+\t\t\t/* Not configured - exit with error */\n+\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n+\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n+\t\t\treturn -1;\n+\t\t}\n+\t\t/* Check if configured default exceeds maximum allowed */\n+\t\tif (http_retry_after > http_max_retry_time) {\n+\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n+\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n+\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\treturn -1;\n+\t\t}\n+\n+\t\treturn http_retry_after;\n+\t}\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 {\n \tint i = 3;\n \tint ret;\n+\tint rate_limit_retries = http_max_retries;\n+\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n \n \tif (always_auth_proactively())\n \t\tcredential_fill(the_repository, &http_auth, 1);\n \n-\tret = http_request(url, result, target, options);\n+\tret = http_request(url, result, target, options, &slot_retry_after, -1);\n \n-\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 == 0)\n+\t\treturn HTTP_ERROR;\n+\n \tif (options && 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@@ -2276,7 +2491,8 @@ static int http_request_reauth(const char *url,\n \t\t}\n \t}\n \n-\twhile (ret == HTTP_REAUTH && --i) {\n+\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\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 \t\t * should clear it out before making our next request.\n@@ -2301,10 +2517,16 @@ static int http_request_reauth(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(&rate_limit_retries, slot_retry_after);\n+\t\t\tif (retry_delay < 0)\n+\t\t\t\treturn HTTP_ERROR;\n+\t\t\tslot_retry_after = -1; /* Reset after use */\n+\t\t} else if (ret == HTTP_REAUTH) {\n+\t\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\t}\n \n-\t\tcredential_fill(the_repository, &http_auth, 1);\n-\n-\t\tret = http_request(url, result, target, options);\n+\t\tret = http_request(url, result, target, options, &slot_retry_after, retry_delay);\n \t}\n \treturn ret;\n }\n@@ -2313,7 +2535,7 @@ int http_get_strbuf(const char *url,\n \t\t    struct strbuf *result,\n \t\t    struct http_get_options *options)\n {\n-\treturn http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);\n+\treturn http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);\n }\n \n /*\n@@ -2337,7 +2559,7 @@ int http_get_file(const char *url, const char *filename,\n \t\tgoto cleanup;\n \t}\n \n-\tret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);\n+\tret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);\n \tfclose(result);\n \n \tif (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))\ndiff --git a/http.h b/http.h\nindex f9d4593404..6ee809ec01 100644\n--- a/http.h\n+++ b/http.h\n@@ -20,6 +20,7 @@ struct slot_results {\n \tlong http_code;\n \tlong auth_avail;\n \tlong http_connectcode;\n+\tlong retry_after;\n };\n \n struct active_request_slot {\n@@ -32,6 +33,8 @@ struct active_request_slot {\n \tvoid *callback_data;\n \tvoid (*callback_func)(void *data);\n \tstruct active_request_slot *next;\n+\tlong retry_delay_seconds;\n+\tstruct timeval retry_delay_start;\n };\n \n struct buffer {\n@@ -167,6 +170,7 @@ struct http_get_options {\n #define HTTP_REAUTH\t4\n #define HTTP_NOAUTH\t5\n #define HTTP_NOMATCHPUBLICKEY\t6\n+#define HTTP_RATE_LIMITED\t7\n \n /*\n  * Requests a URL and stores the result in a strbuf.\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 69f919454a..c122dcedaa 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -367,23 +367,25 @@ static void free_discovery(struct discovery *d)\n \t}\n }\n \n-static int show_http_message(struct strbuf *type, struct strbuf *charset,\n-\t\t\t     struct strbuf *msg)\n+static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n+\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n {\n \tconst char *p, *eol;\n+\tva_list ap;\n+\treport_fn die_message_routine = get_die_message_routine();\n \n \t/*\n \t * We only show text/plain parts, as other types are likely\n \t * to be ugly to look at on the user's terminal.\n \t */\n \tif (strcmp(type->buf, \"text/plain\"))\n-\t\treturn -1;\n+\t\tgoto out;\n \tif (charset->len)\n \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n \n \tstrbuf_trim(msg);\n \tif (!msg->len)\n-\t\treturn -1;\n+\t\tgoto out;\n \n \tp = msg->buf;\n \tdo {\n@@ -391,7 +393,16 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n \t\tp = eol + 1;\n \t} while(*eol);\n-\treturn 0;\n+\n+out:\n+\tstrbuf_release(type);\n+\tstrbuf_release(charset);\n+\tstrbuf_release(msg);\n+\n+\tva_start(ap, fmt);\n+\tdie_message_routine(fmt, ap);\n+\tva_end(ap);\n+\texit(128);\n }\n \n static int get_protocol_http_header(enum protocol_version version,\n@@ -518,21 +529,25 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \tcase HTTP_OK:\n \t\tbreak;\n \tcase HTTP_MISSING_TARGET:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"repository '%s' not found\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"repository '%s' not found\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOAUTH:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"Authentication failed for '%s'\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"Authentication failed for '%s'\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOMATCHPUBLICKEY:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n+\tcase HTTP_RATE_LIMITED:\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tdefault:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s': %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s': %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \t}\n \n \tif (options.verbosity && !starts_with(refs_url.buf, url.buf)) {\ndiff --git a/strbuf.c b/strbuf.c\nindex 6c3851a7f8..1d3860869e 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n \tif (!out)\n \t\treturn -1;\n \n-\tstrbuf_attach(sb, out, len, len);\n+\tstrbuf_attach(sb, out, len, len + 1);\n \treturn 0;\n }\n \ndiff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\nindex 5091db949b..8a43261ffc 100644\n--- a/t/lib-httpd.sh\n+++ b/t/lib-httpd.sh\n@@ -167,6 +167,7 @@ prepare_httpd() {\n \tinstall_script error.sh\n \tinstall_script apply-one-time-script.sh\n \tinstall_script nph-custom-auth.sh\n+\tinstall_script http-429.sh\n \n \tln -s \"$LIB_HTTPD_MODULE_PATH\" \"$HTTPD_ROOT_PATH/modules\"\n \ndiff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf\nindex e631ab0eb5..6bdef603cd 100644\n--- a/t/lib-httpd/apache.conf\n+++ b/t/lib-httpd/apache.conf\n@@ -139,6 +139,10 @@ SetEnv PERL_PATH ${PERL_PATH}\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n </LocationMatch>\n+<LocationMatch /http_429/>\n+\tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n+\tSetEnv GIT_HTTP_EXPORT_ALL\n+</LocationMatch>\n <LocationMatch /smart_v0/>\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n@@ -160,6 +164,7 @@ ScriptAlias /broken_smart/ broken-smart-http.sh/\n ScriptAlias /error_smart/ error-smart-http.sh/\n ScriptAlias /error/ error.sh/\n ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1\n+ScriptAliasMatch /http_429/(.*) http-429.sh/$1\n ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Directory ${GIT_EXEC_PATH}>\n \tOptions FollowSymlinks\n@@ -185,6 +190,9 @@ ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Files apply-one-time-script.sh>\n \tOptions ExecCGI\n </Files>\n+<Files http-429.sh>\n+\tOptions ExecCGI\n+</Files>\n <Files ${GIT_EXEC_PATH}/git-http-backend>\n \tOptions ExecCGI\n </Files>\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nnew file mode 100644\nindex 0000000000..c97b16145b\n--- /dev/null\n+++ b/t/lib-httpd/http-429.sh\n@@ -0,0 +1,98 @@\n+#!/bin/sh\n+\n+# Script to return HTTP 429 Too Many Requests responses for testing retry logic.\n+# Usage: /http_429/<test-context>/<retry-after-value>/<repo-path>\n+#\n+# The test-context is a unique identifier for each test to isolate state files.\n+# The retry-after-value can be:\n+#   - A number (e.g., \"1\", \"2\", \"100\") - sets Retry-After header to that many seconds\n+#   - \"none\" - no Retry-After header\n+#   - \"invalid\" - invalid Retry-After format\n+#   - \"permanent\" - always return 429 (never succeed)\n+#   - An HTTP-date string (RFC 2822 format) - sets Retry-After to that date\n+#\n+# On first call, returns 429. On subsequent calls (after retry), forwards to git-http-backend\n+# unless retry-after-value is \"permanent\".\n+\n+# Extract test context, retry-after value and repo path from PATH_INFO\n+# PATH_INFO format: /<test-context>/<retry-after-value>/<repo-path>\n+path_info=\"${PATH_INFO#/}\"  # Remove leading slash\n+test_context=\"${path_info%%/*}\"  # Get first component (test context)\n+remaining=\"${path_info#*/}\"  # Get rest\n+retry_after=\"${remaining%%/*}\"  # Get second component (retry-after value)\n+repo_path=\"${remaining#*/}\"  # Get rest (repo path)\n+\n+# Extract repository name from repo_path (e.g., \"repo.git\" from \"repo.git/info/refs\")\n+# The repo name is the first component before any \"/\"\n+repo_name=\"${repo_path%%/*}\"\n+\n+# Use current directory (HTTPD_ROOT_PATH) for state file\n+# Create a safe filename from test_context, retry_after and repo_name\n+# This ensures all requests for the same test context share the same state file\n+safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n+state_file=\"http-429-state-${safe_name}\"\n+\n+# Check if this is the first call (no state file exists)\n+if test -f \"$state_file\"\n+then\n+\t# Already returned 429 once, 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\n+\tPATH_INFO=\"/$repo_path\"\n+\texport PATH_INFO\n+\t# GIT_PROJECT_ROOT points to the document root where repositories are stored\n+\t# The script runs from HTTPD_ROOT_PATH, and www/ is the document root\n+\tif test -z \"$GIT_PROJECT_ROOT\"\n+\tthen\n+\t\t# Construct path: current directory (HTTPD_ROOT_PATH) + /www\n+\t\tGIT_PROJECT_ROOT=\"$(pwd)/www\"\n+\t\texport GIT_PROJECT_ROOT\n+\tfi\n+\texec \"$GIT_EXEC_PATH/git-http-backend\"\n+fi\n+\n+# Mark that we've returned 429\n+touch \"$state_file\"\n+\n+# Output HTTP 429 response\n+printf \"Status: 429 Too Many Requests\\r\\n\"\n+\n+# Set Retry-After header based on retry_after value\n+case \"$retry_after\" in\n+\tnone)\n+\t\t# No Retry-After header\n+\t\t;;\n+\tinvalid)\n+\t\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n+\t\t;;\n+\tpermanent)\n+\t\t# Always return 429, don't set state file for success\n+\t\trm -f \"$state_file\"\n+\t\tprintf \"Retry-After: 1\\r\\n\"\n+\t\tprintf \"Content-Type: text/plain\\r\\n\"\n+\t\tprintf \"\\r\\n\"\n+\t\tprintf \"Permanently rate limited\\n\"\n+\t\texit 0\n+\t\t;;\n+\t*)\n+\t\t# Check if it's a number\n+\t\tcase \"$retry_after\" in\n+\t\t\t[0-9]*)\n+\t\t\t\t# Numeric value\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_after\"\n+\t\t\t\t;;\n+\t\t\t*)\n+\t\t\t\t# Assume it's an HTTP-date format (passed as-is, URL decoded)\n+\t\t\t\t# Apache may URL-encode the path, so decode common URL-encoded characters\n+\t\t\t\t# %20 = space, %2C = comma, %3A = colon\n+\t\t\t\tretry_value=$(echo \"$retry_after\" | sed -e 's/%20/ /g' -e 's/%2C/,/g' -e 's/%3A/:/g')\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_value\"\n+\t\t\t\t;;\n+\t\tesac\n+\t\t;;\n+esac\n+\n+printf \"Content-Type: text/plain\\r\\n\"\n+printf \"\\r\\n\"\n+printf \"Rate limited\\n\"\ndiff --git a/t/meson.build b/t/meson.build\nindex 459c52a489..ee82450333 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -700,6 +700,7 @@ integration_tests = [\n   't5581-http-curl-verbose.sh',\n   't5582-fetch-negative-refspec.sh',\n   't5583-push-branches.sh',\n+  't5584-http-429-retry.sh',\n   't5600-clone-fail-cleanup.sh',\n   't5601-clone.sh',\n   't5602-clone-remote-exec.sh',\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nnew file mode 100755\nindex 0000000000..c0d30c5387\n--- /dev/null\n+++ b/t/t5584-http-429-retry.sh\n@@ -0,0 +1,286 @@\n+#!/bin/sh\n+\n+test_description='test HTTP 429 Too Many Requests retry logic'\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/lib-httpd.sh\n+\n+start_httpd\n+\n+test_expect_success 'setup test repository' '\n+\ttest_commit initial &&\n+\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n+\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n+'\n+\n+# This test suite uses a special HTTP 429 endpoint at /http_429/ that simulates\n+# rate limiting. The endpoint format is:\n+#   /http_429/<test-context>/<retry-after-value>/<repo-path>\n+# The http-429.sh script (in t/lib-httpd) returns a 429 response with the\n+# specified Retry-After header on the first request for each test context,\n+# then forwards subsequent requests to git-http-backend. Each test context\n+# is isolated, allowing multiple tests to run independently.\n+\n+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n+\t# Set maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should fail immediately without any retry attempt\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retries-disabled/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message in stderr)\n+\ttest_grep ! -i \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 permanent should fail after max retries' '\n+\t# Enable retries with a limit\n+\ttest_config http.maxRetries 2 &&\n+\n+\t# Git should retry but eventually fail when 429 persists\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/permanent-fail/permanent/repo.git\" 2>err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry after receiving 429 and eventually succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-succeeds/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\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+\ttest_config http.retryAfter 1 &&\n+\n+\t# Git should retry using configured default and succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/no-retry-after-header/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 retry delays are respected' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Time the operation - it should take at least 2 seconds due to retry delay\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Verify it took at least 2 seconds (allowing some tolerance)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n+\t# Configure max retry time to 3 seconds (much less than requested 100)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 3 &&\n+\n+\t# Should fail immediately without waiting\n+\tstart=$(date +%s) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"exceeds http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n+\t# Test misconfiguration: retryAfter > maxRetryTime\n+\t# Configure retryAfter larger than maxRetryTime\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 100 &&\n+\ttest_config http.maxRetryTime 5 &&\n+\n+\t# Should fail immediately with configuration error\n+\tstart=$(date +%s) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n+\t# Test HTTP-date format (RFC 2822) in Retry-After header\n+\t# Generate a date 2 seconds in the future\n+\tfuture_date=$(TZ=GMT date -d \"+2 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      TZ=GMT date -v+2S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      echo \"skip\") &&\n+\n+\tif test \"$future_date\" = \"skip\"\n+\tthen\n+\t\tskip_all=\"date command does not support required format\" &&\n+\t\ttest_done\n+\tfi &&\n+\n+\t# URL-encode the date (replace spaces with %20)\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should parse the HTTP-date and retry after the delay\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should take at least 1 second (allowing tolerance for processing time)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n+\t# Generate a date 200 seconds in the future\n+\tfuture_date=$(TZ=GMT date -d \"+200 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      TZ=GMT date -v+200S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t      echo \"skip\") &&\n+\n+\tif test \"$future_date\" = \"skip\"\n+\tthen\n+\t\tskip_all=\"date command does not support required format\" &&\n+\t\ttest_done\n+\tfi &&\n+\n+\t# URL-encode the date (replace spaces with %20)\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Configure max retry time much less than the 200 second delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 10 &&\n+\n+\t# Should fail immediately without waiting 200 seconds\n+\tstart=$(date +%s) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly (not wait 200 seconds)\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"exceeds http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n+\tpast_date=$(TZ=GMT date -d \"-10 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t    TZ=GMT date -v-10S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n+\t\t    echo \"skip\") &&\n+\n+\tif test \"$past_date\" = \"skip\"\n+\tthen\n+\t\tskip_all=\"date command does not support required format\" &&\n+\t\ttest_done\n+\tfi &&\n+\n+\t# URL-encode the date (replace spaces with %20)\n+\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry immediately without waiting\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should complete quickly (less than 2 seconds)\n+\ttest \"$duration\" -lt 2 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n+\t# Configure default retry-after\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should use configured default (1 second) since header is invalid\n+\tstart=$(date +%s) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should take at least 1 second (the configured default)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 will not be retried without config' '\n+\t# Default config means http.maxRetries=0 (retries disabled)\n+\t# When 429 is received, it should fail immediately without retry\n+\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n+\n+\t# Should fail immediately without retry\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/no-retry-without-config/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message)\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\n+\t# Should get 429 error\n+\ttest_grep \"429\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n+\t# Configure retryAfter to 10 seconds\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 10 &&\n+\n+\t# Override with environment variable to 1 second\n+\tstart=$(date +%s) &&\n+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should use env var (1 second), not config (10 seconds)\n+\ttest \"$duration\" -ge 1 &&\n+\ttest \"$duration\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n+\t# Configure maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Override with environment variable to enable retries\n+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/http_429/env-max-retries-override/1/repo.git\" >output 2>err &&\n+\n+\t# Should retry (env var enables it despite config saying disabled)\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n+\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 100 &&\n+\n+\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n+\tstart=$(date +%s) &&\n+\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n+\tend=$(date +%s) &&\n+\tduration=$((end - start)) &&\n+\n+\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n+\ttest \"$duration\" -lt 5 &&\n+\ttest_grep \"exceeds http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'verify normal repository access still works' '\n+\tgit ls-remote \"$HTTPD_URL/smart/repo.git\" >output &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_done\n-- \ngitgitgadget\n\n"},{"id":"532470","messageId":"ad4495fc94a4bcdcf7f299ffd8514afce88f2d6c.1766069088.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v2.git.1766069088.gitgitgadget@gmail.com","subject":"[PATCH v2 2/2] http: add trace2 logging for retry operations","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2025-12-18T14:44:48Z","receivedAt":"2025-12-18T14:44:55Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd trace2 instrumentation to HTTP 429 retry operations to enable\nmonitoring and debugging of rate limit scenarios in production\nenvironments.\n\nThe trace2 logging captures:\n\n  * Retry attempt numbers (http/429-retry-attempt) to track retry\n    progression and identify how many attempts were needed\n\n  * Retry-After header values (http/429-retry-after) from server\n    responses to understand server-requested delays\n\n  * Actual sleep durations (http/retry-sleep-seconds) within trace2\n    regions (http/retry-sleep) to measure time spent waiting\n\n  * Error conditions (http/429-error) such as \"retries-exhausted\",\n    \"exceeds-max-retry-time\", \"no-retry-after-config\", and\n    \"config-exceeds-max-retry-time\" for diagnosing failures\n\n  * Retry source (http/429-retry-source) indicating whether delay\n    came from server header or config default\n\nThis instrumentation provides complete visibility into retry behavior,\nenabling operators to monitor rate limiting patterns, diagnose retry\nfailures, and optimize retry configuration based on real-world data.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n http.c | 23 ++++++++++++++++++++++-\n 1 file changed, 22 insertions(+), 1 deletion(-)\n\ndiff --git a/http.c b/http.c\nindex 60e0364f57..ded791af87 100644\n--- a/http.c\n+++ b/http.c\n@@ -23,6 +23,7 @@\n #include \"odb.h\"\n #include \"tempfile.h\"\n #include \"date.h\"\n+#include \"trace2.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -1738,6 +1739,8 @@ void run_active_slot(struct active_request_slot *slot)\n \n \tif (waiting_for_delay) {\n \t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), slot->retry_delay_seconds);\n+\ttrace2_data_intmax(\"http\", the_repository, \"http/retry-sleep-seconds\",\n+\t\tslot->retry_delay_seconds);\n \t\tstart_time = slot->retry_delay_start;\n \t}\n \n@@ -1753,6 +1756,7 @@ void run_active_slot(struct active_request_slot *slot)\n \t\t\t}\n \n \t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n+\t\t\t\ttrace2_region_leave(\"http\", \"retry-sleep\", the_repository);\n \t\t\t\tslot->retry_delay_seconds = -1;\n \t\t\t\twaiting_for_delay = 0;\n \n@@ -1995,6 +1999,8 @@ static int handle_curl_result(struct slot_results *results)\n \t\t\treturn HTTP_REAUTH;\n \t\t}\n \t} else if (results->http_code == 429) {\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 \t} else {\n \t\tif (results->http_connectcode == 407)\n@@ -2421,10 +2427,16 @@ static void sleep_for_retry(struct active_request_slot *slot, long retry_after)\n static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n {\n \tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n+\n+\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n+\t\tretry_attempt);\n+\n \tif (*rate_limit_retries <= 0) {\n \t\t/* Retries are disabled or exhausted */\n \t\tif (http_max_retries > 0) {\n \t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\"http/429-error\", \"retries-exhausted\");\n \t\t}\n \t\treturn -1;\n \t}\n@@ -2439,6 +2451,10 @@ static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_aft\n \t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n \t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n \t\t\t      slot_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n+\t\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n \t\t\treturn -1;\n \t\t}\n \t\treturn slot_retry_after;\n@@ -2448,6 +2464,8 @@ static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_aft\n \t\t\t/* Not configured - exit with error */\n \t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n \t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\"http/429-error\", \"no-retry-after-config\");\n \t\t\treturn -1;\n \t\t}\n \t\t/* Check if configured default exceeds maximum allowed */\n@@ -2455,9 +2473,12 @@ static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_aft\n \t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n \t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n \t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n \t\t\treturn -1;\n \t\t}\n-\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}\n }\n-- \ngitgitgadget\n"},{"id":"535727","messageId":"aYvV2W5pcvqZig8S@nand.local","threadId":"64535","inReplyTo":"d80ce077038bab96aca26b0b0ad706c91ea1d8a8.1766069088.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 1/2] http: add support for HTTP 429 rate limit retries","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2026-02-11T01:05:29Z","receivedAt":"2026-02-11T01:05:34Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Thu, Dec 18, 2025 at 02:44:47PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n> From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n>\n> Add retry logic for HTTP 429 (Too Many Requests) responses to handle\n> server-side rate limiting gracefully. When Git's HTTP client receives\n> a 429 response, it can now automatically retry the request after an\n> appropriate delay, respecting the server's rate limits.\n>\n> The implementation supports the RFC-compliant Retry-After header in\n> both delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\n> past date is provided, Git retries immediately without waiting.\n>\n> Retry behavior is controlled by three new configuration options\n> (http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\n> documented in git-config(1).\n>\n> The retry logic implements a fail-fast approach: if any delay\n> (whether from server header or configuration) exceeds maxRetryTime,\n> Git fails immediately with a clear error message rather than capping\n> the delay. This provides better visibility into rate limiting issues.\n>\n> The implementation includes extensive test coverage for basic retry\n> behavior, Retry-After header formats (integer and HTTP-date),\n> configuration combinations, maxRetryTime limits, invalid header\n> handling, environment variable overrides, and edge cases.\n\n\n\n\n> +http.retryAfter::\n> +\tDefault wait time in seconds before retrying when a server returns\n> +\tHTTP 429 (Too Many Requests) without a Retry-After header. If set\n> +\tto -1 (the default), Git will fail immediately when encountering\n\nWhile reviewing, I originally wrote:\n\n  Setting the default as \"-1\" makes sense to me. The current behavior is\n  to give up when we receive a HTTP 429 response with or without a\n  Retry-After header, so retaining that behavior makes sense and seems\n  like a sensible path.\n\n, but I'm not sure that I am sold on that line of thinking. This is\ncontrolling how long we'll wait after a 429 response before retrying,\nnot how many times we'll retry (which is `http.maxRetries` below).\n\nShould the default here be zero? We would \"retry\" immediately, but that\nretry would fail since the maximum retries is set to \"zero\" by default.\n\n> diff --git a/http-push.c b/http-push.c\n> index 60a9b75620..ddb9948352 100644\n> --- a/http-push.c\n> +++ b/http-push.c\n> @@ -716,6 +716,10 @@ static int fetch_indices(void)\n>  \tcase HTTP_MISSING_TARGET:\n>  \t\tret = 0;\n>  \t\tbreak;\n> +\tcase HTTP_RATE_LIMITED:\n> +\t\terror(_(\"rate limited by '%s', please try again later\"), repo->url);\n> +\t\tret = -1;\n> +\t\tbreak;\n>  \tdefault:\n>  \t\tret = -1;\n>  \t}\n> @@ -1548,6 +1552,10 @@ static int remote_exists(const char *path)\n>  \tcase HTTP_MISSING_TARGET:\n>  \t\tret = 0;\n>  \t\tbreak;\n> +\tcase HTTP_RATE_LIMITED:\n> +\t\terror(_(\"rate limited by '%s', please try again later\"), url);\n> +\t\tret = -1;\n> +\t\tbreak;\n\nI wonder if there is an opportunity to DRY this up a bit? I think the\ncase in fetch_indices() is very similar to remote_Exists(), and ditto\nfor fetch_indices() in the http-walker.c code.\n\nThe only exception I could see is http-walker.c's fetch_indices() needs\nto also set repo->got_indices, but I think that could be done as a\nseparate pass.\n\nIf you end up going in that direction, I would suggest pulling out a\nfunction as a preparatory commit before introducing the changes in this\npatch so that you when you are ready to add the \"rate limited by '%s'\"\nerror(), you only have to do so once.\n\n> -static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n> +static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p)\n\nThanks for making this change. I think that handling both\nwww-authenticate and retry-after headers in the same function makes a\nlot of sense, and the new name reflects that appropriately.\n\n\n>  {\n>  \tsize_t size = eltsize * nmemb;\n>  \tstruct strvec *values = &http_auth.wwwauth_headers;\n>  \tstruct strbuf buf = STRBUF_INIT;\n>  \tconst char *val;\n>  \tsize_t val_len;\n> +\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n>\n>  \t/*\n>  \t * Header lines may not come NULL-terminated from libcurl so we must\n> @@ -257,6 +264,47 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n>  \t\tgoto exit;\n>  \t}\n>\n> +\t/* Parse Retry-After header for rate limiting */\n> +\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n> +\t\tstrbuf_add(&buf, val, val_len);\n> +\t\tstrbuf_trim(&buf);\n> +\n> +\t\tif (slot && slot->results) {\n> +\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n> +\t\t\tchar *endptr;\n> +\t\t\tlong retry_after;\n> +\n> +\t\t\terrno = 0;\n> +\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n> +\n> +\t\t\t/* Check if it's a valid integer (delay-seconds format) */\n> +\t\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n> +\t\t\t    errno != ERANGE && retry_after > 0) {\n\nShould we handle \"Retry-After: 0\" here? I think that this means \"retry\nimmediately\", so I imagine that we should change this to read \"&&\nretry_after >= 0\" instead.\n\n> +\t\t\t\tslot->results->retry_after = retry_after;\n> +\t\t\t} else {\n> +\t\t\t\t/* Try parsing as HTTP-date format */\n> +\t\t\t\ttimestamp_t timestamp;\n> +\t\t\t\tint offset;\n> +\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n> +\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n> +\t\t\t\t\ttimestamp_t now = time(NULL);\n> +\t\t\t\t\tif (timestamp > now) {\n> +\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n> +\t\t\t\t\t} else {\n> +\t\t\t\t\t\t/* Past date means retry immediately */\n> +\t\t\t\t\t\tslot->results->retry_after = 0;\n> +\t\t\t\t\t}\n> +\t\t\t\t} else {\n> +\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n> +\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n> +\t\t\t\t}\n> +\t\t\t}\n> +\t\t}\n> +\n> +\t\thttp_auth.header_is_last_match = 1;\n\nCould you help me understand why we're setting header_is_last_match\nhere? I think since we immediately \"goto exit\" this line isn't strictly\nnecessary.\n\nAs a separate but related note, I don't know if this function properly\nhandles header continuations for Retry-After headers, but in practice I\nsuspect it doesn't matter, as servers should not be continuing\nRetry-After headers across multiple lines.\n\n> @@ -1660,44 +1729,98 @@ void run_active_slot(struct active_request_slot *slot)\n>  \tfd_set excfds;\n>  \tint max_fd;\n>  \tstruct timeval select_timeout;\n> +\tlong curl_timeout;\n> +\tstruct timeval start_time = {0}, current_time, elapsed_time = {0};\n> +\tlong remaining_seconds;\n>  \tint finished = 0;\n> +\tint slot_not_started = (slot->finished == NULL);\n> +\tint waiting_for_delay = (slot->retry_delay_seconds > 0);\n> +\n> +\tif (waiting_for_delay) {\n> +\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), slot->retry_delay_seconds);\n> +\t\tstart_time = slot->retry_delay_start;\n> +\t}\n>\n>  \tslot->finished = &finished;\n> -\twhile (!finished) {\n> +\twhile (waiting_for_delay || !finished) {\n> +\t\tif (waiting_for_delay) {\n> +\t\t\tgettimeofday(&current_time, NULL);\n> +\t\t\telapsed_time.tv_sec = current_time.tv_sec - start_time.tv_sec;\n> +\t\t\telapsed_time.tv_usec = current_time.tv_usec - start_time.tv_usec;\n> +\t\t\tif (elapsed_time.tv_usec < 0) {\n> +\t\t\t\telapsed_time.tv_sec--;\n> +\t\t\t\telapsed_time.tv_usec += 1000000;\n> +\t\t\t}\n> +\n> +\t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n> +\t\t\t\tslot->retry_delay_seconds = -1;\n> +\t\t\t\twaiting_for_delay = 0;\n> +\n> +\t\t\t\tif (slot_not_started)\n> +\t\t\t\t\treturn;\n\nI wonder if run_active_slot() is the right place for these changes or if\nit should be handled separately. I think it may be somewhat surprising\nfor run_active_slot() to return without actually running the slot, even\nif the slot is marked as \"active\" but just waiting for a delay.\n\nOTOH, like I mentioned earlier, I am far from an expert in this part of\nthe code, so perhaps this is totally OK. shortlog says that Peff (CC'd)\nis among the most active contributors to this file in the past year, so\nI'll be curious what he thinks as well.\n\n> @@ -1871,6 +1994,8 @@ 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\treturn HTTP_RATE_LIMITED;\n>  \t} else {\n>  \t\tif (results->http_connectcode == 407)\n>  \t\t\tcredential_reject(the_repository, &proxy_auth);\n> @@ -1886,6 +2011,14 @@ int run_one_slot(struct active_request_slot *slot,\n>  \t\t struct slot_results *results)\n>  {\n>  \tslot->results = results;\n> +\t/* Initialize retry_after to -1 (not set) */\n> +\tresults->retry_after = -1;\n> +\n> +\t/* If there's a retry delay, wait for it before starting the slot */\n> +\tif (slot->retry_delay_seconds > 0) {\n> +\t\trun_active_slot(slot);\n> +\t}\n\nThis is a nitpick, but the curly braces here are unnecessary for a\nsingle-line if statement. Documentation/CodingGuidelines has more\ndetails here.\n\n> +\n>  \tif (!start_active_slot(slot)) {\n>  \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n>  \t\t\t  \"failed to start HTTP request\");\n> @@ -2117,9 +2250,13 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n>  #define HTTP_REQUEST_STRBUF\t0\n>  #define HTTP_REQUEST_FILE\t1\n>\n> +static void sleep_for_retry(struct active_request_slot *slot, long retry_after);\n> +\n>  static int http_request(const char *url,\n>  \t\t\tvoid *result, int target,\n> -\t\t\tconst struct http_get_options *options)\n> +\t\t\tconst struct http_get_options *options,\n> +\t\t\tlong *retry_after_out,\n> +\t\t\tlong retry_delay)\n>  {\n>  \tstruct active_request_slot *slot;\n>  \tstruct slot_results results;\n> @@ -2129,6 +2266,10 @@ static int http_request(const char *url,\n>  \tint ret;\n>\n>  \tslot = get_active_slot();\n> +\t/* Mark slot for delay if retry delay is provided */\n> +\tif (retry_delay > 0) {\n> +\t\tsleep_for_retry(slot, retry_delay);\n> +\t}\n\nSame note here as above.\n\n> +/*\n> + * Handle rate limiting retry logic for HTTP 429 responses.\n> + * Uses slot-specific retry_after value to support concurrent slots.\n> + * Returns a negative value if retries are exhausted or configuration is invalid,\n> + * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n> + */\n> +static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n> +{\n> +\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n> +\tif (*rate_limit_retries <= 0) {\n> +\t\t/* Retries are disabled or exhausted */\n> +\t\tif (http_max_retries > 0) {\n> +\t\t\terror(_(\"too many rate limit retries, giving up\"));\n> +\t\t}\n\nHere as well.\n\n> +\t\treturn -1;\n> +\t}\n> +\n> +\t/* Decrement retries counter */\n> +\t(*rate_limit_retries)--;\n> +\n> +\t/* Use the slot-specific retry_after value or configured default */\n> +\tif (slot_retry_after >= 0) {\n> +\t\t/* Check if retry delay exceeds maximum allowed */\n> +\t\tif (slot_retry_after > http_max_retry_time) {\n> +\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n> +\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n> +\t\t\t      slot_retry_after, http_max_retry_time);\n> +\t\t\treturn -1;\n> +\t\t}\n> +\t\treturn slot_retry_after;\n> +\t} else {\n> +\t\t/* No Retry-After header provided */\n> +\t\tif (http_retry_after < 0) {\n> +\t\t\t/* Not configured - exit with error */\n> +\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n> +\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n> +\t\t\treturn -1;\n> +\t\t}\n> +\t\t/* Check if configured default exceeds maximum allowed */\n> +\t\tif (http_retry_after > http_max_retry_time) {\n> +\t\t\terror(_(\"configured http.retryAfter (%ld seconds) exceeds \"\n> +\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n> +\t\t\t      http_retry_after, http_max_retry_time);\n> +\t\t\treturn -1;\n> +\t\t}\n\nAs a general note on these error()s, I wonder if it would be worth\nshortening them up a bit. For example, the first one reads:\n\n  \"rate limited (HTTP 429) requested %ld second delay, exceeds http.maxRetryTime of %ld seconds\"\n\nPerhaps we could shorten this to something like:\n\n  \"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"\n\nI feel like we could get it even shorter, but I think that this is a\ngood starting point.\n\nAs an additional note, I think we generally try and avoid putting\ninstructions like \"Configure http.retryAfter or [...]\" in error()\nmessages. Those would be good advise() messages, enabling the user to\nturn them off if they are not relevant to their situation, whereas\nerror() messages are fixed.\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>  {\n>  \tint i = 3;\n>  \tint ret;\n> +\tint rate_limit_retries = http_max_retries;\n> +\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n>\n>  \tif (always_auth_proactively())\n>  \t\tcredential_fill(the_repository, &http_auth, 1);\n>\n> -\tret = http_request(url, result, target, options);\n> +\tret = http_request(url, result, target, options, &slot_retry_after, -1);\n>\n> -\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 == 0)\n\nAnother minor CodingGuidelines nit, but we generally do not write \"x ==\n0\", and instead prefer \"!x\".\n\n> +\t\treturn HTTP_ERROR;\n> +\n>  \tif (options && 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> @@ -2276,7 +2491,8 @@ static int http_request_reauth(const char *url,\n>  \t\t}\n>  \t}\n>\n> -\twhile (ret == HTTP_REAUTH && --i) {\n> +\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\n\nI had to re-read this line, since I wasn't sure that decrementing i was\nthe right thing to do for both reauth and rate limited responses. But it\nis, since we pass a pointer to rate_limit_retries down to\nhandle_rate_limit_retry() which will decrement it and eventually cause\nit to return -1 when retries are exhausted, causing this loop to exit.\n\n>  static int get_protocol_http_header(enum protocol_version version,\n> @@ -518,21 +529,25 @@ static struct discovery *discover_refs(const char *service, int for_push)\n>  \tcase HTTP_OK:\n>  \t\tbreak;\n>  \tcase HTTP_MISSING_TARGET:\n> -\t\tshow_http_message(&type, &charset, &buffer);\n> -\t\tdie(_(\"repository '%s' not found\"),\n> -\t\t    transport_anonymize_url(url.buf));\n> +\t\tshow_http_message_fatal(&type, &charset, &buffer,\n> +\t\t\t\t\t_(\"repository '%s' not found\"),\n> +\t\t\t\t\ttransport_anonymize_url(url.buf));\n\nThanks for taking my suggestion here as well. I think that the end\nresult reads much cleaner, though I do think that introducing the new\nshow_http_message_fatal() function and rewriting the existing code\nshould happen in a preparatory commit before this one to more clearly\nseparate the changes.\n\n> diff --git a/strbuf.c b/strbuf.c\n> index 6c3851a7f8..1d3860869e 100644\n> --- a/strbuf.c\n> +++ b/strbuf.c\n> @@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n>  \tif (!out)\n>  \t\treturn -1;\n>\n> -\tstrbuf_attach(sb, out, len, len);\n> +\tstrbuf_attach(sb, out, len, len + 1);\n\nNot sure that I'm following this change.\n\n> diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\n> index 5091db949b..8a43261ffc 100644\n> --- a/t/lib-httpd.sh\n> +++ b/t/lib-httpd.sh\n\nI may solicit Peff's input here on the remainder of the test changes,\nsince he is much more familiar with the lib-httpd parts of the suite\nthan I am.\n\nThanks,\nTaylor\n"},{"id":"535728","messageId":"aYvWIvViGYoVDngh@nand.local","threadId":"64535","inReplyTo":"ad4495fc94a4bcdcf7f299ffd8514afce88f2d6c.1766069088.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 2/2] http: add trace2 logging for retry operations","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2026-02-11T01:06:42Z","receivedAt":"2026-02-11T01:06:44Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Thu, Dec 18, 2025 at 02:44:48PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n> ---\n>  http.c | 23 ++++++++++++++++++++++-\n>  1 file changed, 22 insertions(+), 1 deletion(-)\n\nThese changes all look reasonable to me. I think you could reaosnably\nsquash this into the previous commit, especially since that commit will\nlikely shrink as you move some hunks out into preparatory patches.\n\nThanks,\nTaylor\n"},{"id":"535741","messageId":"20260211091333.GA1868492@coredump.intra.peff.net","threadId":"64535","inReplyTo":"aYvV2W5pcvqZig8S@nand.local","subject":"Re: [PATCH v2 1/2] http: add support for HTTP 429 rate limit retries","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-02-11T09:13:33Z","receivedAt":"2026-02-11T09:13:35Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Tue, Feb 10, 2026 at 08:05:29PM -0500, Taylor Blau wrote:\n\n> > diff --git a/http-push.c b/http-push.c\n> > index 60a9b75620..ddb9948352 100644\n> > --- a/http-push.c\n> > +++ b/http-push.c\n> > @@ -716,6 +716,10 @@ static int fetch_indices(void)\n> >  \tcase HTTP_MISSING_TARGET:\n> >  \t\tret = 0;\n> >  \t\tbreak;\n> > +\tcase HTTP_RATE_LIMITED:\n> > +\t\terror(_(\"rate limited by '%s', please try again later\"), repo->url);\n> > +\t\tret = -1;\n> > +\t\tbreak;\n> >  \tdefault:\n> >  \t\tret = -1;\n> >  \t}\n> > @@ -1548,6 +1552,10 @@ static int remote_exists(const char *path)\n> >  \tcase HTTP_MISSING_TARGET:\n> >  \t\tret = 0;\n> >  \t\tbreak;\n> > +\tcase HTTP_RATE_LIMITED:\n> > +\t\terror(_(\"rate limited by '%s', please try again later\"), url);\n> > +\t\tret = -1;\n> > +\t\tbreak;\n> \n> I wonder if there is an opportunity to DRY this up a bit? I think the\n> case in fetch_indices() is very similar to remote_Exists(), and ditto\n> for fetch_indices() in the http-walker.c code.\n\nIMHO it is not worth trying to clean up http-push here. It's the dumb\npush-over-webdav implementation that nobody uses. I'd actually be happy\nto see it ripped out, but am too lazy to go through the effort of a big\ndeprecation period myself.\n\nSo I would actually consider not touching this code at all, and letting\nit continue to behave as it did before (returning -1 and not producing\nany specialized message). Though I suppose in remote_exists() we'd fail\nto even print the curl error anymore, which would be a regression.\n\nDitto for http-walker.c's fetch_indices() function. It is used only for\ndumb-http fetches (which are forbidden by most forges). And if not\ntouched at all, it would continue to function in the same way (not\nproducing any specialized message).\n\n> As a separate but related note, I don't know if this function properly\n> handles header continuations for Retry-After headers, but in practice I\n> suspect it doesn't matter, as servers should not be continuing\n> Retry-After headers across multiple lines.\n\nYeah, I noticed that, too. And all of the parsing actually makes me\nnervous. Surely curl can do some of this for us?\n\n...studies some manpages...\n\nAh, indeed. How about:\n\n  curl_off_t wait = 0;\n  curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER, &wait);\n\nYou can see how we already dig out similar info in finish_active_slot().\nAnd more extended (but optional) info in http_request(). It looks like\nCURLINFO_RETRY_AFTER was added in 7.66.0, so this would have to be a\nconditional feature at build-time. But that seems like a reasonable\ntrade-off.\n\n  Side note: the obvious question is why we need fwrite_wwwauth() in the\n  first place. And the answer is that curl does not provide structured\n  access to the information from those headers. It does make me wonder\n  if we could be using curl_easy_header() to get rid of all of this\n  manual parsing and continuation code. That was introduced in 7.83.0,\n  which would again make it conditional. But it seems like a nicer path\n  forward for us. Anyway, way out of scope for this patch.\n\n> > @@ -1660,44 +1729,98 @@ void run_active_slot(struct active_request_slot *slot)\n> [...]\n> > -\twhile (!finished) {\n> > +\twhile (waiting_for_delay || !finished) {\n> > +\t\tif (waiting_for_delay) {\n> > +\t\t\tgettimeofday(&current_time, NULL);\n> > +\t\t\telapsed_time.tv_sec = current_time.tv_sec - start_time.tv_sec;\n> > +\t\t\telapsed_time.tv_usec = current_time.tv_usec - start_time.tv_usec;\n> > +\t\t\tif (elapsed_time.tv_usec < 0) {\n> > +\t\t\t\telapsed_time.tv_sec--;\n> > +\t\t\t\telapsed_time.tv_usec += 1000000;\n> > +\t\t\t}\n> > +\n> > +\t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n> > +\t\t\t\tslot->retry_delay_seconds = -1;\n> > +\t\t\t\twaiting_for_delay = 0;\n> > +\n> > +\t\t\t\tif (slot_not_started)\n> > +\t\t\t\t\treturn;\n> \n> I wonder if run_active_slot() is the right place for these changes or if\n> it should be handled separately. I think it may be somewhat surprising\n> for run_active_slot() to return without actually running the slot, even\n> if the slot is marked as \"active\" but just waiting for a delay.\n\nYeah, I agree. The point of run_active_slot() is to run the slot to\ncompletion (I think; it has been a while since I've had to dig into any\nof this). So I'd either expect it to handle the retry and delay itself\ninternally, or to return the failed request to the caller, who will then\ndelay and initiate the retry.\n\nThat's all assuming we're making one request at a time (which I think is\nmostly all that run_active_slot() handles). There's a much more\ncomplicated question when we have multiple simultaneous requests, which\nwe'd do only with the dumb protocol (trying to fetch multiple objects at\nonce). In that case we need to be queuing requests. And I _think_ that\nmight be what this code is trying to do. But I'm not sure if it would\nactually work, as we try to advance those via step_active_slots().\n\n> OTOH, like I mentioned earlier, I am far from an expert in this part of\n> the code, so perhaps this is totally OK. shortlog says that Peff (CC'd)\n> is among the most active contributors to this file in the past year, so\n> I'll be curious what he thinks as well.\n\nMost of the details of this active slot stuff have long been paged out\nof my memory. It's all _so_ messy because of the desire for the\ndumb-http code to handle multiple requests. But for smart-http (and I\nwould be perfectly content for this feature to only apply there), we\ncould probably just focus on run_one_slot(), I'd think.\n\nI.e., what I'd expect the simplest form of the patch to look like is\nroughly:\n\n  - teach handle_curl_result() to recognize 429 and pull out the\n    retry-after value, returning HTTP_RETRY\n\n  - in run_one_slot(), recognize HTTP_RETRY and if appropriate, sleep\n    and retry\n\nI do wonder if even that might be too low-level, though. For a real\nlarge request, we'll be streaming data into the request, and I'm not\nsure we _can_ retry. We send a probe_rpc() first in that case to try to\nresolve issues like credential-filling. But there's nothing to say that\nwe can't get a 200 on the probe and a 429 on the real request.\n\nWhich I guess implies to me that http_request_reauth() should be where\nthe magic happens. And it somewhat does in this patch, but...why not do\nthe sleeping there, and why push it all the way down into\nrun_active_slot()?\n\nI know I'm kind of talking in circles here, which is indicative of my\nconfusion (and the general complexity of the http code). But as the\npatch stands, I'm not really convinced which cases it is trying to cover\n(single requests vs multi, repeatable requests vs streaming POSTs), how\nwell it covers them, and that it is doing it as simply as possible (or\nat least keeping the logic together).\n\n> > @@ -518,21 +529,25 @@ static struct discovery *discover_refs(const char *service, int for_push)\n> >  \tcase HTTP_OK:\n> >  \t\tbreak;\n> >  \tcase HTTP_MISSING_TARGET:\n> > -\t\tshow_http_message(&type, &charset, &buffer);\n> > -\t\tdie(_(\"repository '%s' not found\"),\n> > -\t\t    transport_anonymize_url(url.buf));\n> > +\t\tshow_http_message_fatal(&type, &charset, &buffer,\n> > +\t\t\t\t\t_(\"repository '%s' not found\"),\n> > +\t\t\t\t\ttransport_anonymize_url(url.buf));\n> \n> Thanks for taking my suggestion here as well. I think that the end\n> result reads much cleaner, though I do think that introducing the new\n> show_http_message_fatal() function and rewriting the existing code\n> should happen in a preparatory commit before this one to more clearly\n> separate the changes.\n\nYeah, I had the same thought.\n\n> > diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\n> > index 5091db949b..8a43261ffc 100644\n> > --- a/t/lib-httpd.sh\n> > +++ b/t/lib-httpd.sh\n> \n> I may solicit Peff's input here on the remainder of the test changes,\n> since he is much more familiar with the lib-httpd parts of the suite\n> than I am.\n\nThe lib-httpd parts looked about as I'd expect (and I found the use of\ncustom URL components to encode the retry parameters quite clever).\n\nThere were lots of uses of \"date\" that I suspect may give us portability\nproblems. \"+%s\" is not even in POSIX, but maybe it is universal enough.\nBut stuff like '-d \"+2 seconds\"' seems likely to be a GNU-ism.\n\nUsing \"test-tool date\" might get around some of that. We even understand\nrelative dates like \"2 seconds ago\", but I think only in the past. :-/\nSo you'd probably have to do:\n\n  now=$(test-tool date timestamp now | cut -d' ' -f3)\n  then=$((now + 2))\n  test-tool date show:rfc2822 $then\n\nor something.\n\n-Peff\n"},{"id":"535927","messageId":"CAGjQmDMA1sZStTP=NC7Jp62zSLaHS0d3EYweY0BS5j63m2pDNg@mail.gmail.com","threadId":"64535","inReplyTo":"aYvV2W5pcvqZig8S@nand.local","subject":"Re: [PATCH v2 1/2] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas","fromEmail":"vaidas.pilkauskas@shopify.com","sentAt":"2026-02-13T13:30:18Z","receivedAt":"2026-02-13T13:30:32Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"On Wed, Feb 11, 2026 at 3:05 AM Taylor Blau <me@ttaylorr.com> wrote:\n> > +http.retryAfter::\n> > +     Default wait time in seconds before retrying when a server returns\n> > +     HTTP 429 (Too Many Requests) without a Retry-After header. If set\n> > +     to -1 (the default), Git will fail immediately when encountering\n>\n> While reviewing, I originally wrote:\n>\n>   Setting the default as \"-1\" makes sense to me. The current behavior is\n>   to give up when we receive a HTTP 429 response with or without a\n>   Retry-After header, so retaining that behavior makes sense and seems\n>   like a sensible path.\n>\n> , but I'm not sure that I am sold on that line of thinking. This is\n> controlling how long we'll wait after a 429 response before retrying,\n> not how many times we'll retry (which is `http.maxRetries` below).\n>\n> Should the default here be zero? We would \"retry\" immediately, but that\n> retry would fail since the maximum retries is set to \"zero\" by default.\n\nI think the only reason I was using \"-1\" is to have an opportunity to advise\non existing configuration for retries, but I guess we can live without advising\nas I expect folks who are willing to configure retry handling will be advanced\nusers who are aware of the options. I'll switch to \"0\".\n\n> > diff --git a/http-push.c b/http-push.c\n> > index 60a9b75620..ddb9948352 100644\n> > --- a/http-push.c\n> > +++ b/http-push.c\n> > @@ -716,6 +716,10 @@ static int fetch_indices(void)\n> > +     case HTTP_RATE_LIMITED:\n> > +             error(_(\"rate limited by '%s', please try again later\"), url);\n> > +             ret = -1;\n> > +             break;\n>\n> I wonder if there is an opportunity to DRY this up a bit? I think the\n> case in fetch_indices() is very similar to remote_Exists(), and ditto\n> for fetch_indices() in the http-walker.c code.\n\nI'll leave this code unchanged as per Peff's suggestion.\n\n>\n> > +                             slot->results->retry_after = retry_after;\n> > +                     } else {\n> > +                             /* Try parsing as HTTP-date format */\n> > +                             timestamp_t timestamp;\n> > +                             int offset;\n> > +                             if (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n> > +                                     /* Successfully parsed as date, calculate delay from now */\n> > +                                     timestamp_t now = time(NULL);\n> > +                                     if (timestamp > now) {\n> > +                                             slot->results->retry_after = (long)(timestamp - now);\n> > +                                     } else {\n> > +                                             /* Past date means retry immediately */\n> > +                                             slot->results->retry_after = 0;\n> > +                                     }\n> > +                             } else {\n> > +                                     /* Failed to parse as either delay-seconds or HTTP-date */\n> > +                                     warning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n> > +                             }\n> > +                     }\n> > +             }\n> > +\n> > +             http_auth.header_is_last_match = 1;\n>\n> Could you help me understand why we're setting header_is_last_match\n> here? I think since we immediately \"goto exit\" this line isn't strictly\n> necessary.\n\nYes, this should not be needed - I'll remove the statement.\n\n> As a separate but related note, I don't know if this function properly\n> handles header continuations for Retry-After headers, but in practice I\n> suspect it doesn't matter, as servers should not be continuing\n> Retry-After headers across multiple lines.\n\nYes, I assume it's not applicable to Retry-After, so I'm not handling\ncontinuations.\n\n> > @@ -1660,44 +1729,98 @@ void run_active_slot(struct active_request_slot *slot)\n> I wonder if run_active_slot() is the right place for these changes or if\n> it should be handled separately. I think it may be somewhat surprising\n> for run_active_slot() to return without actually running the slot, even\n> if the slot is marked as \"active\" but just waiting for a delay.\n>\n> OTOH, like I mentioned earlier, I am far from an expert in this part of\n> the code, so perhaps this is totally OK. shortlog says that Peff (CC'd)\n> is among the most active contributors to this file in the past year, so\n> I'll be curious what he thinks as well.\n\nI'll follow Peff's review for this part.\n\n> > diff --git a/strbuf.c b/strbuf.c\n> > index 6c3851a7f8..1d3860869e 100644\n> > --- a/strbuf.c\n> > +++ b/strbuf.c\n> > @@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n> >       if (!out)\n> >               return -1;\n> >\n> > -     strbuf_attach(sb, out, len, len);\n> > +     strbuf_attach(sb, out, len, len + 1);\n\nSorry, I totally forgot about this change. I still got leak reported\nfrom CI, so I\nnarrowed it down to this line. I'll make a separate commit to discuss it.\n\n> Not sure that I'm following this change.\n\n> Thanks,\n> Taylor\n\nThanks, Taylor, for the review!\n"},{"id":"535928","messageId":"CAGjQmDMhWFx32M+2DrZ3cF-mt+T==LNEzXRO4z=R73RjZnTQPg@mail.gmail.com","threadId":"64535","inReplyTo":"20260211091333.GA1868492@coredump.intra.peff.net","subject":"Re: [PATCH v2 1/2] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas","fromEmail":"vaidas.pilkauskas@shopify.com","sentAt":"2026-02-13T13:41:55Z","receivedAt":"2026-02-13T13:42:08Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"On Wed, Feb 11, 2026 at 11:13 AM Jeff King <peff@peff.net> wrote:\n> Yeah, I noticed that, too. And all of the parsing actually makes me\n> nervous. Surely curl can do some of this for us?\n>\n> ...studies some manpages...\n>\n> Ah, indeed. How about:\n>\n>   curl_off_t wait = 0;\n>   curl_easy_getinfo(slot->curl, CURLINFO_RETRY_AFTER, &wait);\n>\n> You can see how we already dig out similar info in finish_active_slot().\n> And more extended (but optional) info in http_request(). It looks like\n> CURLINFO_RETRY_AFTER was added in 7.66.0, so this would have to be a\n> conditional feature at build-time. But that seems like a reasonable\n> trade-off.\n\nI'll add parsing with libcurl under conditional feature.\n\n> Most of the details of this active slot stuff have long been paged out\n> of my memory. It's all _so_ messy because of the desire for the\n> dumb-http code to handle multiple requests. But for smart-http (and I\n> would be perfectly content for this feature to only apply there), we\n> could probably just focus on run_one_slot(), I'd think.\n>\n> I.e., what I'd expect the simplest form of the patch to look like is\n> roughly:\n>\n>   - teach handle_curl_result() to recognize 429 and pull out the\n>     retry-after value, returning HTTP_RETRY\n>\n>   - in run_one_slot(), recognize HTTP_RETRY and if appropriate, sleep\n>     and retry\n>\n\nThis greatly simplifies implementation. I think following similar pattern like\nauth handling does makes a lot of sense. So, instead of sleeping in\nrun_one_slot(), I think it makes sense to sleep in http_request_recoverable()\nwhere HTTP_REAUTH is handled.\n\n> > I may solicit Peff's input here on the remainder of the test changes,\n> > since he is much more familiar with the lib-httpd parts of the suite\n> > than I am.\n>\n> The lib-httpd parts looked about as I'd expect (and I found the use of\n> custom URL components to encode the retry parameters quite clever).\n>\n> There were lots of uses of \"date\" that I suspect may give us portability\n> problems. \"+%s\" is not even in POSIX, but maybe it is universal enough.\n> But stuff like '-d \"+2 seconds\"' seems likely to be a GNU-ism.\n>\n> Using \"test-tool date\" might get around some of that. We even understand\n> relative dates like \"2 seconds ago\", but I think only in the past. :-/\n> So you'd probably have to do:\n>\n>   now=$(test-tool date timestamp now | cut -d' ' -f3)\n>   then=$((now + 2))\n>   test-tool date show:rfc2822 $then\n>\n> or something.\n\nI was not aware about test-tool, thanks!\n\n> -Peff\n\nThanks, Peff, for the review!\n"},{"id":"536051","messageId":"20260215091346.GB696020@coredump.intra.peff.net","threadId":"64535","inReplyTo":"CAGjQmDMhWFx32M+2DrZ3cF-mt+T==LNEzXRO4z=R73RjZnTQPg@mail.gmail.com","subject":"Re: [PATCH v2 1/2] http: add support for HTTP 429 rate limit retries","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-02-15T09:13:46Z","receivedAt":"2026-02-15T09:13:47Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Fri, Feb 13, 2026 at 03:41:55PM +0200, Vaidas Pilkauskas wrote:\n\n> > There were lots of uses of \"date\" that I suspect may give us portability\n> > problems. \"+%s\" is not even in POSIX, but maybe it is universal enough.\n> > But stuff like '-d \"+2 seconds\"' seems likely to be a GNU-ism.\n> >\n> > Using \"test-tool date\" might get around some of that. We even understand\n> > relative dates like \"2 seconds ago\", but I think only in the past. :-/\n> > So you'd probably have to do:\n> >\n> >   now=$(test-tool date timestamp now | cut -d' ' -f3)\n> >   then=$((now + 2))\n> >   test-tool date show:rfc2822 $then\n> >\n> > or something.\n> \n> I was not aware about test-tool, thanks!\n\nIt might be a little awkward to bend it to your will, especially since\nit likes to print \"input -> output\" instead of just the output you want.\nIf it gets too hairy, I wouldn't be opposed to teaching it a new option\nor even a new command-mode for doing this kind of computed date stuff.\n\n-Peff\n"},{"id":"536170","messageId":"pull.2008.v3.git.1771326521.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v2.git.1766069088.gitgitgadget@gmail.com","subject":"[PATCH v3 0/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-17T11:08:37Z","receivedAt":"2026-02-17T11:08:44Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"Changes since v2:\n\n * New preparatory patch: Introduced show_http_message_fatal() helper\n   function to reduce code duplication in remote-curl.c (suggested by Taylor\n   Blau)\n\n * Removed specific HTTP_RATE_LIMITED error handling from http-push.c and\n   http-walker.c for the obsolete \"dumb\" protocol, allowing generic error\n   handling to take over (suggested by Jeff King)\n\n * Added support for CURLINFO_RETRY_AFTER on curl >= 7.66.0, falling back to\n   manual header parsing on older versions\n\n * Simplified retry/delay architecture: replaced complex non-blocking\n   \"delayed slot\" mechanism with simple blocking sleep() call in the retry\n   loop, removing ~66 lines of timing logic (suggested by Jeff King)\n\n * Fixed Retry-After: 0 handling to allow immediate retry as specified by\n   RFC 9110\n\n * Changed http.retryAfter default from -1 to 0, so Git will retry\n   immediately when encountering HTTP 429 without a Retry-After header,\n   rather than failing with a configuration error\n\n * Improved error messages: shortened to be more concise\n\n * Fixed coding style issues: removed unnecessary curly braces, changed x ==\n   0 to !x (per CodingGuidelines)\n\n * Improved test portability: replaced non-portable date(1) commands with\n   test-tool date, added nanosecond-precision timing with getnanos, replaced\n   cut(1) with POSIX shell parameter expansion\n\n * Split out strbuf.c bugfix into separate preparatory patch (the\n   strbuf_reencode alloc size fix is unrelated to HTTP 429 support)\n\n * Squashed separate trace2 logging patch into main HTTP 429 retry support\n   commit\n\n * Kept header_is_last_match assignment for Retry-After to prevent incorrect\n   handling of HTTP header continuation lines\n\nThe implementation includes:\n\n 1. A bug fix in strbuf_reencode() that corrects the allocation size passed\n    to strbuf_attach(), ensuring proper memory management.\n\n 2. A refactoring in remote-curl.c that introduces a\n    show_http_message_fatal() helper to reduce code duplication when\n    handling fatal HTTP errors.\n\n 3. The main feature: HTTP 429 retry logic with support for the Retry-After\n    header (both delay-seconds and HTTP-date formats), configurable via\n    http.maxRetries, http.retryAfter, and http.maxRetryTime options.\n\nVaidas Pilkauskas (3):\n  strbuf: fix incorrect alloc size in strbuf_reencode()\n  remote-curl: introduce show_http_message_fatal() helper\n  http: add support for HTTP 429 rate limit retries\n\n Documentation/config/http.adoc |  23 +++\n git-curl-compat.h              |   8 +\n http.c                         | 190 +++++++++++++++++++++--\n http.h                         |   2 +\n remote-curl.c                  |  49 +++---\n strbuf.c                       |   2 +-\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n 11 files changed, 618 insertions(+), 30 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\n\nbase-commit: 852829b3dd2fe4e7c7fc4d8badde644cf1b66c74\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2008%2Fvaidas-shopify%2Fretry-after-v3\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2008/vaidas-shopify/retry-after-v3\nPull-Request: https://github.com/gitgitgadget/git/pull/2008\n\nRange-diff vs v2:\n\n -:  ---------- > 1:  821043c664 strbuf: fix incorrect alloc size in strbuf_reencode()\n -:  ---------- > 2:  3653067f0e remote-curl: introduce show_http_message_fatal() helper\n 1:  d80ce07703 ! 3:  3cece62a63 http: add support for HTTP 429 rate limit retries\n     @@ Documentation/config/http.adoc: http.keepAliveCount::\n       \n      +http.retryAfter::\n      +\tDefault wait time in seconds before retrying when a server returns\n     -+\tHTTP 429 (Too Many Requests) without a Retry-After header. If set\n     -+\tto -1 (the default), Git will fail immediately when encountering\n     -+\ta 429 response without a Retry-After header. When a Retry-After\n     -+\theader is present, its value takes precedence over this setting.\n     -+\tCan be overridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n     ++\tHTTP 429 (Too Many Requests) without a Retry-After header.\n     ++\tDefaults to 0 (retry immediately). When a Retry-After header is\n     ++\tpresent, its value takes precedence over this setting. Can be\n     ++\toverridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n      +\tSee also `http.maxRetries` and `http.maxRetryTime`.\n      +\n      +http.maxRetries::\n     @@ Documentation/config/http.adoc: http.keepAliveCount::\n       \tA boolean which disables using of EPSV ftp command by curl.\n       \tThis can be helpful with some \"poor\" ftp servers which don't\n      \n     - ## http-push.c ##\n     -@@ http-push.c: static int fetch_indices(void)\n     - \tcase HTTP_MISSING_TARGET:\n     - \t\tret = 0;\n     - \t\tbreak;\n     -+\tcase HTTP_RATE_LIMITED:\n     -+\t\terror(_(\"rate limited by '%s', please try again later\"), repo->url);\n     -+\t\tret = -1;\n     -+\t\tbreak;\n     - \tdefault:\n     - \t\tret = -1;\n     - \t}\n     -@@ http-push.c: static int remote_exists(const char *path)\n     - \tcase HTTP_MISSING_TARGET:\n     - \t\tret = 0;\n     - \t\tbreak;\n     -+\tcase HTTP_RATE_LIMITED:\n     -+\t\terror(_(\"rate limited by '%s', please try again later\"), url);\n     -+\t\tret = -1;\n     -+\t\tbreak;\n     - \tcase HTTP_ERROR:\n     - \t\terror(\"unable to access '%s': %s\", url, curl_errorstr);\n     - \t\t/* fallthrough */\n     -\n     - ## http-walker.c ##\n     -@@ http-walker.c: static int fetch_indices(struct walker *walker, struct alt_base *repo)\n     - \t\trepo->got_indices = 1;\n     - \t\tret = 0;\n     - \t\tbreak;\n     -+\tcase HTTP_RATE_LIMITED:\n     -+\t\terror(\"rate limited by '%s', please try again later\", repo->base);\n     -+\t\trepo->got_indices = 0;\n     -+\t\tret = -1;\n     -+\t\tbreak;\n     - \tdefault:\n     - \t\trepo->got_indices = 0;\n     - \t\tret = -1;\n     + ## git-curl-compat.h ##\n     +@@\n     + #define GIT_CURL_NEED_TRANSFER_ENCODING_HEADER\n     + #endif\n     + \n     ++/**\n     ++ * CURLINFO_RETRY_AFTER was added in 7.66.0, released in September 2019.\n     ++ * It allows curl to automatically parse Retry-After headers.\n     ++ */\n     ++#if LIBCURL_VERSION_NUM >= 0x074200\n     ++#define GIT_CURL_HAVE_CURLINFO_RETRY_AFTER 1\n     ++#endif\n     ++\n     + /**\n     +  * CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR were added in 7.85.0,\n     +  * released in August 2022.\n      \n       ## http.c ##\n      @@\n     @@ http.c\n       #include \"odb.h\"\n       #include \"tempfile.h\"\n      +#include \"date.h\"\n     ++#include \"trace2.h\"\n       \n       static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n       static int trace_curl_data = 1;\n     @@ http.c: static char *cached_accept_language;\n       \n       static int http_schannel_check_revoke = 1;\n      +\n     -+static long http_retry_after = -1;\n     ++static long http_retry_after = 0;\n      +static long http_max_retries = 0;\n      +static long http_max_retry_time = 300;\n      +\n     @@ http.c: static inline int is_hdr_continuation(const char *ptr, const size_t size\n       }\n       \n      -static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n     -+static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p)\n     ++static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n       {\n       \tsize_t size = eltsize * nmemb;\n       \tstruct strvec *values = &http_auth.wwwauth_headers;\n     - \tstruct strbuf buf = STRBUF_INIT;\n     - \tconst char *val;\n     - \tsize_t val_len;\n     -+\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n     - \n     - \t/*\n     - \t * Header lines may not come NULL-terminated from libcurl so we must\n      @@ http.c: static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n       \t\tgoto exit;\n       \t}\n       \n     -+\t/* Parse Retry-After header for rate limiting */\n     ++#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n     ++\t/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */\n      +\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n     ++\t\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n     ++\n      +\t\tstrbuf_add(&buf, val, val_len);\n      +\t\tstrbuf_trim(&buf);\n      +\n     @@ http.c: static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, vo\n      +\t\t\terrno = 0;\n      +\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n      +\n     -+\t\t\t/* Check if it's a valid integer (delay-seconds format) */\n     -+\t\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n     -+\t\t\t    errno != ERANGE && retry_after > 0) {\n     -+\t\t\t\tslot->results->retry_after = retry_after;\n     -+\t\t\t} else {\n     ++\t\t/* Check if it's a valid integer (delay-seconds format) */\n     ++\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n     ++\t\t    errno != ERANGE && retry_after >= 0) {\n     ++\t\t\tslot->results->retry_after = retry_after;\n     ++\t\t} else {\n      +\t\t\t\t/* Try parsing as HTTP-date format */\n      +\t\t\t\ttimestamp_t timestamp;\n      +\t\t\t\tint offset;\n     @@ http.c: static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, vo\n      +\t\t\t}\n      +\t\t}\n      +\n     -+\t\thttp_auth.header_is_last_match = 1;\n      +\t\tgoto exit;\n      +\t}\n     ++#endif\n      +\n       \t/*\n       \t * This line could be a continuation of the previously matched header\n       \t * field. If this is the case then we should append this value to the\n     +@@ http.c: static void finish_active_slot(struct active_request_slot *slot)\n     + \n     + \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n     + \t\t\t&slot->results->http_connectcode);\n     ++\n     ++#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n     ++\t\tif (slot->results->http_code == 429) {\n     ++\t\t\tcurl_off_t retry_after;\n     ++\t\t\tCURLcode res = curl_easy_getinfo(slot->curl,\n     ++\t\t\t\t\t\t\t  CURLINFO_RETRY_AFTER,\n     ++\t\t\t\t\t\t\t  &retry_after);\n     ++\t\t\tif (res == 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      @@ http.c: static int http_options(const char *var, const char *value,\n       \t\treturn 0;\n       \t}\n     @@ http.c: void http_init(struct remote *remote, const char *url, int proactive_aut\n       \tcurl_default = get_curl_handle();\n       }\n       \n     -@@ http.c: struct active_request_slot *get_active_slot(void)\n     - \tslot->finished = NULL;\n     - \tslot->callback_data = NULL;\n     - \tslot->callback_func = NULL;\n     -+\tslot->retry_delay_seconds = -1;\n     -+\tmemset(&slot->retry_delay_start, 0, sizeof(slot->retry_delay_start));\n     - \n     - \tif (curl_cookie_file && !strcmp(curl_cookie_file, \"-\")) {\n     - \t\twarning(_(\"refusing to read cookies from http.cookiefile '-'\"));\n     -@@ http.c: void run_active_slot(struct active_request_slot *slot)\n     - \tfd_set excfds;\n     - \tint max_fd;\n     - \tstruct timeval select_timeout;\n     -+\tlong curl_timeout;\n     -+\tstruct timeval start_time = {0}, current_time, elapsed_time = {0};\n     -+\tlong remaining_seconds;\n     - \tint finished = 0;\n     -+\tint slot_not_started = (slot->finished == NULL);\n     -+\tint waiting_for_delay = (slot->retry_delay_seconds > 0);\n     -+\n     -+\tif (waiting_for_delay) {\n     -+\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), slot->retry_delay_seconds);\n     -+\t\tstart_time = slot->retry_delay_start;\n     -+\t}\n     - \n     - \tslot->finished = &finished;\n     --\twhile (!finished) {\n     -+\twhile (waiting_for_delay || !finished) {\n     -+\t\tif (waiting_for_delay) {\n     -+\t\t\tgettimeofday(&current_time, NULL);\n     -+\t\t\telapsed_time.tv_sec = current_time.tv_sec - start_time.tv_sec;\n     -+\t\t\telapsed_time.tv_usec = current_time.tv_usec - start_time.tv_usec;\n     -+\t\t\tif (elapsed_time.tv_usec < 0) {\n     -+\t\t\t\telapsed_time.tv_sec--;\n     -+\t\t\t\telapsed_time.tv_usec += 1000000;\n     -+\t\t\t}\n     -+\n     -+\t\t\tif (elapsed_time.tv_sec >= slot->retry_delay_seconds) {\n     -+\t\t\t\tslot->retry_delay_seconds = -1;\n     -+\t\t\t\twaiting_for_delay = 0;\n     -+\n     -+\t\t\t\tif (slot_not_started)\n     -+\t\t\t\t\treturn;\n     -+\t\t\t}\n     -+\t\t}\n     -+\n     - \t\tstep_active_slots();\n     - \n     --\t\tif (slot->in_use) {\n     --\t\t\tlong curl_timeout;\n     --\t\t\tcurl_multi_timeout(curlm, &curl_timeout);\n     --\t\t\tif (curl_timeout == 0) {\n     -+\t\tif (!waiting_for_delay && !slot->in_use)\n     -+\t\t\tcontinue;\n     -+\n     -+\t\tcurl_multi_timeout(curlm, &curl_timeout);\n     -+\t\tif (curl_timeout == 0) {\n     -+\t\t\tif (!waiting_for_delay)\n     - \t\t\t\tcontinue;\n     --\t\t\t} else if (curl_timeout == -1) {\n     --\t\t\t\tselect_timeout.tv_sec  = 0;\n     --\t\t\t\tselect_timeout.tv_usec = 50000;\n     -+\t\t\tselect_timeout.tv_sec = 0;\n     -+\t\t\tselect_timeout.tv_usec = 50000; /* 50ms */\n     -+\t\t} else if (curl_timeout == -1) {\n     -+\t\t\tselect_timeout.tv_sec = 0;\n     -+\t\t\tselect_timeout.tv_usec = 50000;\n     -+\t\t} else {\n     -+\t\t\tlong curl_timeout_sec = curl_timeout / 1000;\n     -+\t\t\tlong curl_timeout_usec = (curl_timeout % 1000) * 1000;\n     -+\n     -+\t\t\tif (waiting_for_delay) {\n     -+\t\t\t\tremaining_seconds = slot->retry_delay_seconds - elapsed_time.tv_sec;\n     -+\t\t\t\tif (curl_timeout_sec < remaining_seconds) {\n     -+\t\t\t\t\tselect_timeout.tv_sec = curl_timeout_sec;\n     -+\t\t\t\t\tselect_timeout.tv_usec = curl_timeout_usec;\n     -+\t\t\t\t} else {\n     -+\t\t\t\t\tselect_timeout.tv_sec = remaining_seconds;\n     -+\t\t\t\t\tselect_timeout.tv_usec = 0;\n     -+\t\t\t\t}\n     - \t\t\t} else {\n     --\t\t\t\tselect_timeout.tv_sec  =  curl_timeout / 1000;\n     --\t\t\t\tselect_timeout.tv_usec = (curl_timeout % 1000) * 1000;\n     -+\t\t\t\tselect_timeout.tv_sec = curl_timeout_sec;\n     -+\t\t\t\tselect_timeout.tv_usec = curl_timeout_usec;\n     - \t\t\t}\n     -+\t\t}\n     - \n     --\t\t\tmax_fd = -1;\n     --\t\t\tFD_ZERO(&readfds);\n     --\t\t\tFD_ZERO(&writefds);\n     --\t\t\tFD_ZERO(&excfds);\n     --\t\t\tcurl_multi_fdset(curlm, &readfds, &writefds, &excfds, &max_fd);\n     -+\t\tmax_fd = -1;\n     -+\t\tFD_ZERO(&readfds);\n     -+\t\tFD_ZERO(&writefds);\n     -+\t\tFD_ZERO(&excfds);\n     -+\t\tcurl_multi_fdset(curlm, &readfds, &writefds, &excfds, &max_fd);\n     - \n     --\t\t\t/*\n     --\t\t\t * It can happen that curl_multi_timeout returns a pathologically\n     --\t\t\t * long timeout when curl_multi_fdset returns no file descriptors\n     --\t\t\t * to read.  See commit message for more details.\n     --\t\t\t */\n     --\t\t\tif (max_fd < 0 &&\n     --\t\t\t    (select_timeout.tv_sec > 0 ||\n     --\t\t\t     select_timeout.tv_usec > 50000)) {\n     --\t\t\t\tselect_timeout.tv_sec  = 0;\n     --\t\t\t\tselect_timeout.tv_usec = 50000;\n     --\t\t\t}\n     -+\t\t/*\n     -+\t\t * It can happen that curl_multi_timeout returns a pathologically\n     -+\t\t * long timeout when curl_multi_fdset returns no file descriptors\n     -+\t\t * to read.  See commit message for more details.\n     -+\t\t */\n     -+\t\tif (max_fd < 0 &&\n     -+\t\t    (select_timeout.tv_sec > 0 ||\n     -+\t\t     select_timeout.tv_usec > 50000)) {\n     -+\t\t\tselect_timeout.tv_sec = 0;\n     -+\t\t\tselect_timeout.tv_usec = 50000;\n     -+\t\t}\n     - \n     --\t\t\tselect(max_fd+1, &readfds, &writefds, &excfds, &select_timeout);\n     -+\t\t/*\n     -+\t\t * If curl_multi_fdset returns no file descriptors but we have\n     -+\t\t * a timeout, still use select() to wait for the timeout period.\n     -+\t\t */\n     -+\t\tif (max_fd < 0) {\n     -+\t\t\t/* No file descriptors, just wait for timeout */\n     -+\t\t\tselect(0, NULL, NULL, NULL, &select_timeout);\n     -+\t\t} else {\n     -+\t\t\tselect(max_fd + 1, &readfds, &writefds, &excfds, &select_timeout);\n     - \t\t}\n     - \t}\n     - \n      @@ http.c: 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\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n     ++\t\t\tresults->retry_after);\n      +\t\treturn HTTP_RATE_LIMITED;\n       \t} else {\n       \t\tif (results->http_connectcode == 407)\n     @@ http.c: int run_one_slot(struct active_request_slot *slot,\n       \tslot->results = results;\n      +\t/* Initialize retry_after to -1 (not set) */\n      +\tresults->retry_after = -1;\n     -+\n     -+\t/* If there's a retry delay, wait for it before starting the slot */\n     -+\tif (slot->retry_delay_seconds > 0) {\n     -+\t\trun_active_slot(slot);\n     -+\t}\n      +\n       \tif (!start_active_slot(slot)) {\n       \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n       \t\t\t  \"failed to start HTTP request\");\n      @@ http.c: static void http_opt_request_remainder(CURL *curl, off_t pos)\n     - #define HTTP_REQUEST_STRBUF\t0\n     - #define HTTP_REQUEST_FILE\t1\n       \n     -+static void sleep_for_retry(struct active_request_slot *slot, long retry_after);\n     -+\n       static int http_request(const char *url,\n       \t\t\tvoid *result, int target,\n      -\t\t\tconst struct http_get_options *options)\n      +\t\t\tconst struct http_get_options *options,\n     -+\t\t\tlong *retry_after_out,\n     -+\t\t\tlong retry_delay)\n     ++\t\t\tlong *retry_after_out)\n       {\n       \tstruct active_request_slot *slot;\n       \tstruct slot_results results;\n     -@@ http.c: static int http_request(const char *url,\n     - \tint ret;\n     - \n     - \tslot = get_active_slot();\n     -+\t/* Mark slot for delay if retry delay is provided */\n     -+\tif (retry_delay > 0) {\n     -+\t\tsleep_for_retry(slot, retry_delay);\n     -+\t}\n     - \tcurl_easy_setopt(slot->curl, CURLOPT_HTTPGET, 1L);\n     - \n     - \tif (!result) {\n      @@ http.c: static int http_request(const char *url,\n       \t\t\t\t\t fwrite_buffer);\n       \t}\n     @@ http.c: static int update_url_from_redirect(struct strbuf *base,\n       \n      -static int http_request_reauth(const char *url,\n      +/*\n     -+ * Mark slot to be delayed for retry. The actual delay will be handled\n     -+ * in run_active_slot when the slot is executed.\n     -+ */\n     -+static void sleep_for_retry(struct active_request_slot *slot, long retry_after)\n     -+{\n     -+\tif (retry_after > 0 && slot) {\n     -+\t\twarning(_(\"rate limited, waiting %ld seconds before retry\"), retry_after);\n     -+\t\tslot->retry_delay_seconds = retry_after;\n     -+\t\tgettimeofday(&slot->retry_delay_start, NULL);\n     -+\t}\n     -+}\n     -+\n     -+/*\n      + * Handle rate limiting retry logic for HTTP 429 responses.\n     -+ * Uses slot-specific retry_after value to support concurrent slots.\n      + * Returns a negative value if retries are exhausted or configuration is invalid,\n      + * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n      + */\n      +static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n      +{\n      +\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n     ++\n     ++\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n     ++\t\tretry_attempt);\n     ++\n      +\tif (*rate_limit_retries <= 0) {\n      +\t\t/* Retries are disabled or exhausted */\n      +\t\tif (http_max_retries > 0) {\n      +\t\t\terror(_(\"too many rate limit retries, giving up\"));\n     ++\t\t\ttrace2_data_string(\"http\", the_repository,\n     ++\t\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n      +\t\t}\n      +\t\treturn -1;\n      +\t}\n      +\n     -+\t/* Decrement retries counter */\n      +\t(*rate_limit_retries)--;\n      +\n      +\t/* Use the slot-specific retry_after value or configured default */\n      +\tif (slot_retry_after >= 0) {\n      +\t\t/* Check if retry delay exceeds maximum allowed */\n      +\t\tif (slot_retry_after > http_max_retry_time) {\n     -+\t\t\terror(_(\"rate limited (HTTP 429) requested %ld second delay, \"\n     -+\t\t\t\t\"exceeds http.maxRetryTime of %ld seconds\"),\n     ++\t\t\terror(_(\"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"),\n      +\t\t\t      slot_retry_after, http_max_retry_time);\n     ++\t\t\ttrace2_data_string(\"http\", the_repository,\n     ++\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n     ++\t\t\ttrace2_data_intmax(\"http\", the_repository,\n     ++\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n      +\t\t\treturn -1;\n      +\t\t}\n      +\t\treturn slot_retry_after;\n      +\t} else {\n     -+\t\t/* No Retry-After header provided */\n     -+\t\tif (http_retry_after < 0) {\n     -+\t\t\t/* Not configured - exit with error */\n     -+\t\t\terror(_(\"rate limited (HTTP 429) and no Retry-After header provided. \"\n     -+\t\t\t\t\"Configure http.retryAfter or set GIT_HTTP_RETRY_AFTER.\"));\n     -+\t\t\treturn -1;\n     -+\t\t}\n     -+\t\t/* Check if configured default exceeds maximum allowed */\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 (%ld seconds) exceeds \"\n     -+\t\t\t\t\"http.maxRetryTime (%ld seconds)\"),\n     ++\t\t\terror(_(\"configured http.retryAfter exceeds http.maxRetryTime (%ld > %ld seconds)\"),\n      +\t\t\t      http_retry_after, http_max_retry_time);\n     ++\t\t\ttrace2_data_string(\"http\", the_repository,\n     ++\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n      +\t\t\treturn -1;\n      +\t\t}\n     -+\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}\n      +}\n     @@ http.c: static int update_url_from_redirect(struct strbuf *base,\n       \t\tcredential_fill(the_repository, &http_auth, 1);\n       \n      -\tret = http_request(url, result, target, options);\n     -+\tret = http_request(url, result, target, options, &slot_retry_after, -1);\n     ++\tret = http_request(url, result, target, options, &slot_retry_after);\n       \n      -\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 == 0)\n     ++\tif (ret == HTTP_RATE_LIMITED && !http_max_retries)\n      +\t\treturn HTTP_ERROR;\n      +\n       \tif (options && options->effective_url && options->base_url) {\n     @@ http.c: static int http_request_reauth(const char *url,\n      +\t\t\tretry_delay = handle_rate_limit_retry(&rate_limit_retries, slot_retry_after);\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\twarning(_(\"rate limited, waiting %ld seconds before retry\"), 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\tslot_retry_after = -1; /* Reset after use */\n      +\t\t} else if (ret == HTTP_REAUTH) {\n      +\t\t\tcredential_fill(the_repository, &http_auth, 1);\n     @@ http.c: static int http_request_reauth(const char *url,\n      -\t\tcredential_fill(the_repository, &http_auth, 1);\n      -\n      -\t\tret = http_request(url, result, target, options);\n     -+\t\tret = http_request(url, result, target, options, &slot_retry_after, retry_delay);\n     ++\t\tret = http_request(url, result, target, options, &slot_retry_after);\n       \t}\n       \treturn ret;\n       }\n     @@ http.h: struct slot_results {\n       };\n       \n       struct active_request_slot {\n     -@@ http.h: struct active_request_slot {\n     - \tvoid *callback_data;\n     - \tvoid (*callback_func)(void *data);\n     - \tstruct active_request_slot *next;\n     -+\tlong retry_delay_seconds;\n     -+\tstruct timeval retry_delay_start;\n     - };\n     - \n     - struct buffer {\n      @@ http.h: struct http_get_options {\n       #define HTTP_REAUTH\t4\n       #define HTTP_NOAUTH\t5\n     @@ http.h: struct http_get_options {\n        * Requests a URL and stores the result in a strbuf.\n      \n       ## remote-curl.c ##\n     -@@ remote-curl.c: static void free_discovery(struct discovery *d)\n     - \t}\n     - }\n     - \n     --static int show_http_message(struct strbuf *type, struct strbuf *charset,\n     --\t\t\t     struct strbuf *msg)\n     -+static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n     -+\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n     - {\n     - \tconst char *p, *eol;\n     -+\tva_list ap;\n     -+\treport_fn die_message_routine = get_die_message_routine();\n     - \n     - \t/*\n     - \t * We only show text/plain parts, as other types are likely\n     - \t * to be ugly to look at on the user's terminal.\n     - \t */\n     - \tif (strcmp(type->buf, \"text/plain\"))\n     --\t\treturn -1;\n     -+\t\tgoto out;\n     - \tif (charset->len)\n     - \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n     - \n     - \tstrbuf_trim(msg);\n     - \tif (!msg->len)\n     --\t\treturn -1;\n     -+\t\tgoto out;\n     - \n     - \tp = msg->buf;\n     - \tdo {\n     -@@ remote-curl.c: static int show_http_message(struct strbuf *type, struct strbuf *charset,\n     - \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n     - \t\tp = eol + 1;\n     - \t} while(*eol);\n     --\treturn 0;\n     -+\n     -+out:\n     -+\tstrbuf_release(type);\n     -+\tstrbuf_release(charset);\n     -+\tstrbuf_release(msg);\n     -+\n     -+\tva_start(ap, fmt);\n     -+\tdie_message_routine(fmt, ap);\n     -+\tva_end(ap);\n     -+\texit(128);\n     - }\n     - \n     - static int get_protocol_http_header(enum protocol_version version,\n      @@ remote-curl.c: static struct discovery *discover_refs(const char *service, int for_push)\n     - \tcase HTTP_OK:\n     - \t\tbreak;\n     - \tcase HTTP_MISSING_TARGET:\n     --\t\tshow_http_message(&type, &charset, &buffer);\n     --\t\tdie(_(\"repository '%s' not found\"),\n     --\t\t    transport_anonymize_url(url.buf));\n     -+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     -+\t\t\t\t\t_(\"repository '%s' not found\"),\n     -+\t\t\t\t\ttransport_anonymize_url(url.buf));\n     - \tcase HTTP_NOAUTH:\n     --\t\tshow_http_message(&type, &charset, &buffer);\n     --\t\tdie(_(\"Authentication failed for '%s'\"),\n     --\t\t    transport_anonymize_url(url.buf));\n     -+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     -+\t\t\t\t\t_(\"Authentication failed for '%s'\"),\n     -+\t\t\t\t\ttransport_anonymize_url(url.buf));\n     - \tcase HTTP_NOMATCHPUBLICKEY:\n     --\t\tshow_http_message(&type, &charset, &buffer);\n     --\t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     --\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n     -+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     -+\t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     -+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n     + \t\tshow_http_message_fatal(&type, &charset, &buffer,\n     + \t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     + \t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n      +\tcase HTTP_RATE_LIMITED:\n      +\t\tshow_http_message_fatal(&type, &charset, &buffer,\n      +\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n      +\t\t\t\t\ttransport_anonymize_url(url.buf));\n       \tdefault:\n     --\t\tshow_http_message(&type, &charset, &buffer);\n     --\t\tdie(_(\"unable to access '%s': %s\"),\n     --\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n     -+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     -+\t\t\t\t\t_(\"unable to access '%s': %s\"),\n     -+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n     - \t}\n     - \n     - \tif (options.verbosity && !starts_with(refs_url.buf, url.buf)) {\n     -\n     - ## strbuf.c ##\n     -@@ strbuf.c: int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n     - \tif (!out)\n     - \t\treturn -1;\n     - \n     --\tstrbuf_attach(sb, out, len, len);\n     -+\tstrbuf_attach(sb, out, len, len + 1);\n     - \treturn 0;\n     - }\n     - \n     + \t\tshow_http_message_fatal(&type, &charset, &buffer,\n     + \t\t\t\t\t_(\"unable to access '%s': %s\"),\n      \n       ## t/lib-httpd.sh ##\n      @@ t/lib-httpd.sh: prepare_httpd() {\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Time the operation - it should take at least 2 seconds due to retry delay\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Verify it took at least 2 seconds (allowing some tolerance)\n     -+\ttest \"$duration\" -ge 1 &&\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -ge 1 &&\n      +\ttest_grep \"refs/heads/\" output\n      +'\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.maxRetryTime 3 &&\n      +\n      +\t# Should fail immediately without waiting\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n     -+\ttest \"$duration\" -lt 2 &&\n     -+\ttest_grep \"exceeds http.maxRetryTime\" err\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -lt 2 &&\n     ++\ttest_grep \"greater than http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.maxRetryTime 5 &&\n      +\n      +\t# Should fail immediately with configuration error\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should fail quickly\n     -+\ttest \"$duration\" -lt 2 &&\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -lt 2 &&\n      +\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n      +\t# Test HTTP-date format (RFC 2822) in Retry-After header\n     -+\t# Generate a date 2 seconds in the future\n     -+\tfuture_date=$(TZ=GMT date -d \"+2 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n     -+\t\t      TZ=GMT date -v+2S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n     -+\t\t      echo \"skip\") &&\n     -+\n     -+\tif test \"$future_date\" = \"skip\"\n     -+\tthen\n     -+\t\tskip_all=\"date command does not support required format\" &&\n     -+\t\ttest_done\n     -+\tfi &&\n     -+\n     -+\t# URL-encode the date (replace spaces with %20)\n     ++\traw=$(test-tool date timestamp now) &&\n     ++\tnow=\"${raw#* -> }\" &&\n     ++\tfuture_time=$((now + 2)) &&\n     ++\traw=$(test-tool date show:rfc2822 $future_time) &&\n     ++\tfuture_date=\"${raw#* -> }\" &&\n      +\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n      +\n      +\t# Enable retries\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Git should parse the HTTP-date and retry after the delay\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should take at least 1 second (allowing tolerance for processing time)\n     -+\ttest \"$duration\" -ge 1 &&\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -ge 1 &&\n      +\ttest_grep \"refs/heads/\" output\n      +'\n      +\n      +test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n     -+\t# Generate a date 200 seconds in the future\n     -+\tfuture_date=$(TZ=GMT date -d \"+200 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n     -+\t\t      TZ=GMT date -v+200S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n     -+\t\t      echo \"skip\") &&\n     -+\n     -+\tif test \"$future_date\" = \"skip\"\n     -+\tthen\n     -+\t\tskip_all=\"date command does not support required format\" &&\n     -+\t\ttest_done\n     -+\tfi &&\n     -+\n     -+\t# URL-encode the date (replace spaces with %20)\n     ++\traw=$(test-tool date timestamp now) &&\n     ++\tnow=\"${raw#* -> }\" &&\n     ++\tfuture_time=$((now + 200)) &&\n     ++\traw=$(test-tool date show:rfc2822 $future_time) &&\n     ++\tfuture_date=\"${raw#* -> }\" &&\n      +\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n      +\n      +\t# Configure max retry time much less than the 200 second delay\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.maxRetryTime 10 &&\n      +\n      +\t# Should fail immediately without waiting 200 seconds\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should fail quickly (not wait 200 seconds)\n     -+\ttest \"$duration\" -lt 2 &&\n     -+\ttest_grep \"exceeds http.maxRetryTime\" err\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -lt 2 &&\n     ++\ttest_grep \"http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n     -+\tpast_date=$(TZ=GMT date -d \"-10 seconds\" \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n     -+\t\t    TZ=GMT date -v-10S \"+%a, %d %b %Y %H:%M:%S GMT\" 2>/dev/null || \\\n     -+\t\t    echo \"skip\") &&\n     -+\n     -+\tif test \"$past_date\" = \"skip\"\n     -+\tthen\n     -+\t\tskip_all=\"date command does not support required format\" &&\n     -+\t\ttest_done\n     -+\tfi &&\n     -+\n     -+\t# URL-encode the date (replace spaces with %20)\n     ++\traw=$(test-tool date timestamp now) &&\n     ++\tnow=\"${raw#* -> }\" &&\n     ++\tpast_time=$((now - 10)) &&\n     ++\traw=$(test-tool date show:rfc2822 $past_time) &&\n     ++\tpast_date=\"${raw#* -> }\" &&\n      +\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n      +\n      +\t# Enable retries\n      +\ttest_config http.maxRetries 3 &&\n      +\n      +\t# Git should retry immediately without waiting\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should complete quickly (less than 2 seconds)\n     -+\ttest \"$duration\" -lt 2 &&\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -lt 2 &&\n      +\ttest_grep \"refs/heads/\" output\n      +'\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.retryAfter 1 &&\n      +\n      +\t# Should use configured default (1 second) since header is invalid\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should take at least 1 second (the configured default)\n     -+\ttest \"$duration\" -ge 1 &&\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -ge 1 &&\n      +\ttest_grep \"refs/heads/\" output &&\n      +\ttest_grep \"waiting.*retry\" err\n      +'\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.retryAfter 10 &&\n      +\n      +\t# Override with environment variable to 1 second\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should use env var (1 second), not config (10 seconds)\n     -+\ttest \"$duration\" -ge 1 &&\n     -+\ttest \"$duration\" -lt 5 &&\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -ge 1 &&\n     ++\ttest \"$duration_int\" -lt 5 &&\n      +\ttest_grep \"refs/heads/\" output &&\n      +\ttest_grep \"waiting.*retry\" err\n      +'\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_config http.maxRetryTime 100 &&\n      +\n      +\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n     -+\tstart=$(date +%s) &&\n     ++\tstart=$(test-tool date getnanos) &&\n      +\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n      +\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n     -+\tend=$(date +%s) &&\n     -+\tduration=$((end - start)) &&\n     ++\tduration=$(test-tool date getnanos $start) &&\n      +\n      +\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n     -+\ttest \"$duration\" -lt 5 &&\n     -+\ttest_grep \"exceeds http.maxRetryTime\" err\n     ++\tduration_int=${duration%.*} &&\n     ++\ttest \"$duration_int\" -lt 5 &&\n     ++\ttest_grep \"greater than http.maxRetryTime\" err\n      +'\n      +\n      +test_expect_success 'verify normal repository access still works' '\n 2:  ad4495fc94 < -:  ---------- http: add trace2 logging for retry operations\n\n-- \ngitgitgadget\n"},{"id":"536169","messageId":"821043c664e41d8e395e944df3ada8f697a69d0b.1771326521.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v3.git.1771326521.gitgitgadget@gmail.com","subject":"[PATCH v3 1/3] strbuf: fix incorrect alloc size in strbuf_reencode()","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-17T11:08:38Z","receivedAt":"2026-02-17T11:08:45Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nThe strbuf_reencode() function incorrectly passes the string length\nas the allocation size to strbuf_attach(), when it should pass\nlength + 1 to account for the null terminator.\n\nThe reencode_string_len() function allocates len + 1 bytes (including\nthe null terminator) and returns the string length (excluding the null\nterminator) via the len parameter. However, strbuf_reencode() then\ncalls strbuf_attach() with this length value as both the len and alloc\nparameters:\n\n    strbuf_attach(sb, out, len, len);\n\nThis is incorrect because strbuf_attach()'s alloc parameter should\nreflect the actual allocated buffer size, which includes space for the\nnull terminator. This could lead to incorrect memory management in code\nthat relies on sb->alloc being accurate.\n\nFix by passing len + 1 as the alloc parameter:\n\n    strbuf_attach(sb, out, len, len + 1);\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n strbuf.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/strbuf.c b/strbuf.c\nindex 3939863cf3..3e04addc22 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n \tif (!out)\n \t\treturn -1;\n \n-\tstrbuf_attach(sb, out, len, len);\n+\tstrbuf_attach(sb, out, len, len + 1);\n \treturn 0;\n }\n \n-- \ngitgitgadget\n\n"},{"id":"536171","messageId":"3653067f0e84c22a432c7ee85caca129f5970f41.1771326521.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v3.git.1771326521.gitgitgadget@gmail.com","subject":"[PATCH v3 2/3] remote-curl: introduce show_http_message_fatal() helper","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-17T11:08:39Z","receivedAt":"2026-02-17T11:08:47Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nSeveral code paths in remote-curl.c follow the same pattern of calling\nshow_http_message() to display server error messages followed by die()\nto terminate with an error. This duplication makes the code more verbose\nand harder to maintain.\n\nIntroduce a new show_http_message_fatal() helper function that combines\nthese two operations. This function:\n\n1. Displays any HTTP error message from the server via show_http_message()\n2. Calls die() with the provided error message\n3. Returns NORETURN to help the compiler with control flow analysis\n\nRefactor existing call sites in remote-curl.c to use this new helper,\nreducing code duplication and improving readability. This pattern will\nalso be used by upcoming HTTP 429 rate limiting support.\n\nSuggested-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n remote-curl.c | 45 ++++++++++++++++++++++++++++-----------------\n 1 file changed, 28 insertions(+), 17 deletions(-)\n\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 92e40bb682..21c96f2ca9 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -367,23 +367,25 @@ static void free_discovery(struct discovery *d)\n \t}\n }\n \n-static int show_http_message(struct strbuf *type, struct strbuf *charset,\n-\t\t\t     struct strbuf *msg)\n+static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n+\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n {\n \tconst char *p, *eol;\n+\tva_list ap;\n+\treport_fn die_message_routine = get_die_message_routine();\n \n \t/*\n \t * We only show text/plain parts, as other types are likely\n \t * to be ugly to look at on the user's terminal.\n \t */\n \tif (strcmp(type->buf, \"text/plain\"))\n-\t\treturn -1;\n+\t\tgoto out;\n \tif (charset->len)\n \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n \n \tstrbuf_trim(msg);\n \tif (!msg->len)\n-\t\treturn -1;\n+\t\tgoto out;\n \n \tp = msg->buf;\n \tdo {\n@@ -391,7 +393,16 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n \t\tp = eol + 1;\n \t} while(*eol);\n-\treturn 0;\n+\n+out:\n+\tstrbuf_release(type);\n+\tstrbuf_release(charset);\n+\tstrbuf_release(msg);\n+\n+\tva_start(ap, fmt);\n+\tdie_message_routine(fmt, ap);\n+\tva_end(ap);\n+\texit(128);\n }\n \n static int get_protocol_http_header(enum protocol_version version,\n@@ -518,21 +529,21 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \tcase HTTP_OK:\n \t\tbreak;\n \tcase HTTP_MISSING_TARGET:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"repository '%s' not found\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"repository '%s' not found\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOAUTH:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"Authentication failed for '%s'\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"Authentication failed for '%s'\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOMATCHPUBLICKEY:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \tdefault:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s': %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s': %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \t}\n \n \tif (options.verbosity && !starts_with(refs_url.buf, url.buf)) {\n-- \ngitgitgadget\n\n"},{"id":"536172","messageId":"3cece62a63ebc8c1236089b3e43b807d809ed7e8.1771326521.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v3.git.1771326521.gitgitgadget@gmail.com","subject":"[PATCH v3 3/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-17T11:08:40Z","receivedAt":"2026-02-17T11:08:49Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd retry logic for HTTP 429 (Too Many Requests) responses to handle\nserver-side rate limiting gracefully. When Git's HTTP client receives\na 429 response, it can now automatically retry the request after an\nappropriate delay, respecting the server's rate limits.\n\nThe implementation supports the RFC-compliant Retry-After header in\nboth delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\npast date is provided, Git retries immediately without waiting.\n\nRetry behavior is controlled by three new configuration options\n(http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\ndocumented in git-config(1).\n\nThe retry logic implements a fail-fast approach: if any delay\n(whether from server header or configuration) exceeds maxRetryTime,\nGit fails immediately with a clear error message rather than capping\nthe delay. This provides better visibility into rate limiting issues.\n\nThe implementation includes extensive test coverage for basic retry\nbehavior, Retry-After header formats (integer and HTTP-date),\nconfiguration combinations, maxRetryTime limits, invalid header\nhandling, environment variable overrides, and edge cases.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n Documentation/config/http.adoc |  23 +++\n git-curl-compat.h              |   8 +\n http.c                         | 190 +++++++++++++++++++++--\n http.h                         |   2 +\n remote-curl.c                  |   4 +\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n 10 files changed, 589 insertions(+), 12 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 9da5c298cc..7d9a90dcba 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -315,6 +315,29 @@ http.keepAliveCount::\n \tunset, curl's default value is used. Can be overridden by the\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+\tHTTP 429 (Too Many Requests) without a Retry-After header.\n+\tDefaults to 0 (retry immediately). When a Retry-After header is\n+\tpresent, its value takes precedence over this setting. Can be\n+\toverridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n+\tSee also `http.maxRetries` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n+\tSee also `http.retryAfter` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n+\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\n \tThis can be helpful with some \"poor\" ftp servers which don't\ndiff --git a/git-curl-compat.h b/git-curl-compat.h\nindex 659e5a3875..dccdd4d6e5 100644\n--- a/git-curl-compat.h\n+++ b/git-curl-compat.h\n@@ -37,6 +37,14 @@\n #define GIT_CURL_NEED_TRANSFER_ENCODING_HEADER\n #endif\n \n+/**\n+ * CURLINFO_RETRY_AFTER was added in 7.66.0, released in September 2019.\n+ * It allows curl to automatically parse Retry-After headers.\n+ */\n+#if LIBCURL_VERSION_NUM >= 0x074200\n+#define GIT_CURL_HAVE_CURLINFO_RETRY_AFTER 1\n+#endif\n+\n /**\n  * CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR were added in 7.85.0,\n  * released in August 2022.\ndiff --git a/http.c b/http.c\nindex 7815f144de..11ea9f38f7 100644\n--- a/http.c\n+++ b/http.c\n@@ -22,6 +22,8 @@\n #include \"object-file.h\"\n #include \"odb.h\"\n #include \"tempfile.h\"\n+#include \"date.h\"\n+#include \"trace2.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -149,6 +151,11 @@ static char *cached_accept_language;\n static char *http_ssl_backend;\n \n static int http_schannel_check_revoke = 1;\n+\n+static long http_retry_after = 0;\n+static long http_max_retries = 0;\n+static long http_max_retry_time = 300;\n+\n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n  * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n@@ -209,7 +216,7 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)\n \treturn size && (*ptr == ' ' || *ptr == '\\t');\n }\n \n-static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n+static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n {\n \tsize_t size = eltsize * nmemb;\n \tstruct strvec *values = &http_auth.wwwauth_headers;\n@@ -257,6 +264,50 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n \t\tgoto exit;\n \t}\n \n+#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */\n+\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n+\t\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n+\n+\t\tstrbuf_add(&buf, val, val_len);\n+\t\tstrbuf_trim(&buf);\n+\n+\t\tif (slot && slot->results) {\n+\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n+\t\t\tchar *endptr;\n+\t\t\tlong retry_after;\n+\n+\t\t\terrno = 0;\n+\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n+\n+\t\t/* Check if it's a valid integer (delay-seconds format) */\n+\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n+\t\t    errno != ERANGE && retry_after >= 0) {\n+\t\t\tslot->results->retry_after = retry_after;\n+\t\t} else {\n+\t\t\t\t/* Try parsing as HTTP-date format */\n+\t\t\t\ttimestamp_t timestamp;\n+\t\t\t\tint offset;\n+\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n+\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n+\t\t\t\t\ttimestamp_t now = time(NULL);\n+\t\t\t\t\tif (timestamp > now) {\n+\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n+\t\t\t\t\t} else {\n+\t\t\t\t\t\t/* Past date means retry immediately */\n+\t\t\t\t\t\tslot->results->retry_after = 0;\n+\t\t\t\t\t}\n+\t\t\t\t} else {\n+\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n+\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n+\t\t\t\t}\n+\t\t\t}\n+\t\t}\n+\n+\t\tgoto exit;\n+\t}\n+#endif\n+\n \t/*\n \t * This line could be a continuation of the previously matched header\n \t * field. If this is the case then we should append this value to the\n@@ -342,6 +393,17 @@ static void finish_active_slot(struct active_request_slot *slot)\n \n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n \t\t\t&slot->results->http_connectcode);\n+\n+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t\tif (slot->results->http_code == 429) {\n+\t\t\tcurl_off_t retry_after;\n+\t\t\tCURLcode res = curl_easy_getinfo(slot->curl,\n+\t\t\t\t\t\t\t  CURLINFO_RETRY_AFTER,\n+\t\t\t\t\t\t\t  &retry_after);\n+\t\t\tif (res == 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@@ -575,6 +637,21 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retryafter\", var)) {\n+\t\thttp_retry_after = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretries\", var)) {\n+\t\thttp_max_retries = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretrytime\", var)) {\n+\t\thttp_max_retry_time = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1422,6 +1499,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n \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+\n \tcurl_default = get_curl_handle();\n }\n \n@@ -1871,6 +1952,10 @@ 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\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n+\t\t\tresults->retry_after);\n+\t\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n \t\t\tcredential_reject(the_repository, &proxy_auth);\n@@ -1886,6 +1971,9 @@ int run_one_slot(struct active_request_slot *slot,\n \t\t struct slot_results *results)\n {\n \tslot->results = results;\n+\t/* Initialize retry_after to -1 (not set) */\n+\tresults->retry_after = -1;\n+\n \tif (!start_active_slot(slot)) {\n \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n \t\t\t  \"failed to start HTTP request\");\n@@ -2119,7 +2207,8 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n \n static int http_request(const char *url,\n \t\t\tvoid *result, int target,\n-\t\t\tconst struct http_get_options *options)\n+\t\t\tconst struct http_get_options *options,\n+\t\t\tlong *retry_after_out)\n {\n \tstruct active_request_slot *slot;\n \tstruct slot_results results;\n@@ -2148,7 +2237,8 @@ static int http_request(const char *url,\n \t\t\t\t\t fwrite_buffer);\n \t}\n \n-\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n \n \taccept_language = http_get_accept_language_header();\n \n@@ -2183,6 +2273,10 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n+\t/* Store retry_after from slot results if output parameter provided */\n+\tif (retry_after_out)\n+\t\t*retry_after_out = results.retry_after;\n+\n \tif (options && options->content_type) {\n \t\tstruct strbuf raw = STRBUF_INIT;\n \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n@@ -2253,21 +2347,79 @@ static int update_url_from_redirect(struct strbuf *base,\n \treturn 1;\n }\n \n-static int http_request_reauth(const char *url,\n+/*\n+ * Handle rate limiting retry logic for HTTP 429 responses.\n+ * Returns a negative value if retries are exhausted or configuration is invalid,\n+ * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n+ */\n+static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n+{\n+\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n+\n+\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n+\t\tretry_attempt);\n+\n+\tif (*rate_limit_retries <= 0) {\n+\t\t/* Retries are disabled or exhausted */\n+\t\tif (http_max_retries > 0) {\n+\t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n+\t\t}\n+\t\treturn -1;\n+\t}\n+\n+\t(*rate_limit_retries)--;\n+\n+\t/* Use the slot-specific retry_after value or configured default */\n+\tif (slot_retry_after >= 0) {\n+\t\t/* Check if retry delay exceeds maximum allowed */\n+\t\tif (slot_retry_after > http_max_retry_time) {\n+\t\t\terror(_(\"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"),\n+\t\t\t      slot_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n+\t\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n+\t\t\treturn -1;\n+\t\t}\n+\t\treturn slot_retry_after;\n+\t} else {\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+\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n+\t\t\treturn -1;\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}\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 {\n \tint i = 3;\n \tint ret;\n+\tint rate_limit_retries = http_max_retries;\n+\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n \n \tif (always_auth_proactively())\n \t\tcredential_fill(the_repository, &http_auth, 1);\n \n-\tret = http_request(url, result, target, options);\n+\tret = http_request(url, result, target, options, &slot_retry_after);\n \n-\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 && 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@@ -2276,7 +2428,8 @@ static int http_request_reauth(const char *url,\n \t\t}\n \t}\n \n-\twhile (ret == HTTP_REAUTH && --i) {\n+\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\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 \t\t * should clear it out before making our next request.\n@@ -2301,10 +2454,23 @@ static int http_request_reauth(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(&rate_limit_retries, slot_retry_after);\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\twarning(_(\"rate limited, waiting %ld seconds before retry\"), 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\tslot_retry_after = -1; /* Reset after use */\n+\t\t} else if (ret == HTTP_REAUTH) {\n+\t\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\t}\n \n-\t\tcredential_fill(the_repository, &http_auth, 1);\n-\n-\t\tret = http_request(url, result, target, options);\n+\t\tret = http_request(url, result, target, options, &slot_retry_after);\n \t}\n \treturn ret;\n }\n@@ -2313,7 +2479,7 @@ int http_get_strbuf(const char *url,\n \t\t    struct strbuf *result,\n \t\t    struct http_get_options *options)\n {\n-\treturn http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);\n+\treturn http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);\n }\n \n /*\n@@ -2337,7 +2503,7 @@ int http_get_file(const char *url, const char *filename,\n \t\tgoto cleanup;\n \t}\n \n-\tret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);\n+\tret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);\n \tfclose(result);\n \n \tif (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))\ndiff --git a/http.h b/http.h\nindex f9d4593404..eb40456450 100644\n--- a/http.h\n+++ b/http.h\n@@ -20,6 +20,7 @@ struct slot_results {\n \tlong http_code;\n \tlong auth_avail;\n \tlong http_connectcode;\n+\tlong retry_after;\n };\n \n struct active_request_slot {\n@@ -167,6 +168,7 @@ struct http_get_options {\n #define HTTP_REAUTH\t4\n #define HTTP_NOAUTH\t5\n #define HTTP_NOMATCHPUBLICKEY\t6\n+#define HTTP_RATE_LIMITED\t7\n \n /*\n  * Requests a URL and stores the result in a strbuf.\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 21c96f2ca9..b80d2adb95 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -540,6 +540,10 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \t\tshow_http_message_fatal(&type, &charset, &buffer,\n \t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n \t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n+\tcase HTTP_RATE_LIMITED:\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tdefault:\n \t\tshow_http_message_fatal(&type, &charset, &buffer,\n \t\t\t\t\t_(\"unable to access '%s': %s\"),\ndiff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\nindex 5091db949b..8a43261ffc 100644\n--- a/t/lib-httpd.sh\n+++ b/t/lib-httpd.sh\n@@ -167,6 +167,7 @@ prepare_httpd() {\n \tinstall_script error.sh\n \tinstall_script apply-one-time-script.sh\n \tinstall_script nph-custom-auth.sh\n+\tinstall_script http-429.sh\n \n \tln -s \"$LIB_HTTPD_MODULE_PATH\" \"$HTTPD_ROOT_PATH/modules\"\n \ndiff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf\nindex e631ab0eb5..6bdef603cd 100644\n--- a/t/lib-httpd/apache.conf\n+++ b/t/lib-httpd/apache.conf\n@@ -139,6 +139,10 @@ SetEnv PERL_PATH ${PERL_PATH}\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n </LocationMatch>\n+<LocationMatch /http_429/>\n+\tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n+\tSetEnv GIT_HTTP_EXPORT_ALL\n+</LocationMatch>\n <LocationMatch /smart_v0/>\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n@@ -160,6 +164,7 @@ ScriptAlias /broken_smart/ broken-smart-http.sh/\n ScriptAlias /error_smart/ error-smart-http.sh/\n ScriptAlias /error/ error.sh/\n ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1\n+ScriptAliasMatch /http_429/(.*) http-429.sh/$1\n ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Directory ${GIT_EXEC_PATH}>\n \tOptions FollowSymlinks\n@@ -185,6 +190,9 @@ ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Files apply-one-time-script.sh>\n \tOptions ExecCGI\n </Files>\n+<Files http-429.sh>\n+\tOptions ExecCGI\n+</Files>\n <Files ${GIT_EXEC_PATH}/git-http-backend>\n \tOptions ExecCGI\n </Files>\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nnew file mode 100644\nindex 0000000000..c97b16145b\n--- /dev/null\n+++ b/t/lib-httpd/http-429.sh\n@@ -0,0 +1,98 @@\n+#!/bin/sh\n+\n+# Script to return HTTP 429 Too Many Requests responses for testing retry logic.\n+# Usage: /http_429/<test-context>/<retry-after-value>/<repo-path>\n+#\n+# The test-context is a unique identifier for each test to isolate state files.\n+# The retry-after-value can be:\n+#   - A number (e.g., \"1\", \"2\", \"100\") - sets Retry-After header to that many seconds\n+#   - \"none\" - no Retry-After header\n+#   - \"invalid\" - invalid Retry-After format\n+#   - \"permanent\" - always return 429 (never succeed)\n+#   - An HTTP-date string (RFC 2822 format) - sets Retry-After to that date\n+#\n+# On first call, returns 429. On subsequent calls (after retry), forwards to git-http-backend\n+# unless retry-after-value is \"permanent\".\n+\n+# Extract test context, retry-after value and repo path from PATH_INFO\n+# PATH_INFO format: /<test-context>/<retry-after-value>/<repo-path>\n+path_info=\"${PATH_INFO#/}\"  # Remove leading slash\n+test_context=\"${path_info%%/*}\"  # Get first component (test context)\n+remaining=\"${path_info#*/}\"  # Get rest\n+retry_after=\"${remaining%%/*}\"  # Get second component (retry-after value)\n+repo_path=\"${remaining#*/}\"  # Get rest (repo path)\n+\n+# Extract repository name from repo_path (e.g., \"repo.git\" from \"repo.git/info/refs\")\n+# The repo name is the first component before any \"/\"\n+repo_name=\"${repo_path%%/*}\"\n+\n+# Use current directory (HTTPD_ROOT_PATH) for state file\n+# Create a safe filename from test_context, retry_after and repo_name\n+# This ensures all requests for the same test context share the same state file\n+safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n+state_file=\"http-429-state-${safe_name}\"\n+\n+# Check if this is the first call (no state file exists)\n+if test -f \"$state_file\"\n+then\n+\t# Already returned 429 once, 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\n+\tPATH_INFO=\"/$repo_path\"\n+\texport PATH_INFO\n+\t# GIT_PROJECT_ROOT points to the document root where repositories are stored\n+\t# The script runs from HTTPD_ROOT_PATH, and www/ is the document root\n+\tif test -z \"$GIT_PROJECT_ROOT\"\n+\tthen\n+\t\t# Construct path: current directory (HTTPD_ROOT_PATH) + /www\n+\t\tGIT_PROJECT_ROOT=\"$(pwd)/www\"\n+\t\texport GIT_PROJECT_ROOT\n+\tfi\n+\texec \"$GIT_EXEC_PATH/git-http-backend\"\n+fi\n+\n+# Mark that we've returned 429\n+touch \"$state_file\"\n+\n+# Output HTTP 429 response\n+printf \"Status: 429 Too Many Requests\\r\\n\"\n+\n+# Set Retry-After header based on retry_after value\n+case \"$retry_after\" in\n+\tnone)\n+\t\t# No Retry-After header\n+\t\t;;\n+\tinvalid)\n+\t\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n+\t\t;;\n+\tpermanent)\n+\t\t# Always return 429, don't set state file for success\n+\t\trm -f \"$state_file\"\n+\t\tprintf \"Retry-After: 1\\r\\n\"\n+\t\tprintf \"Content-Type: text/plain\\r\\n\"\n+\t\tprintf \"\\r\\n\"\n+\t\tprintf \"Permanently rate limited\\n\"\n+\t\texit 0\n+\t\t;;\n+\t*)\n+\t\t# Check if it's a number\n+\t\tcase \"$retry_after\" in\n+\t\t\t[0-9]*)\n+\t\t\t\t# Numeric value\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_after\"\n+\t\t\t\t;;\n+\t\t\t*)\n+\t\t\t\t# Assume it's an HTTP-date format (passed as-is, URL decoded)\n+\t\t\t\t# Apache may URL-encode the path, so decode common URL-encoded characters\n+\t\t\t\t# %20 = space, %2C = comma, %3A = colon\n+\t\t\t\tretry_value=$(echo \"$retry_after\" | sed -e 's/%20/ /g' -e 's/%2C/,/g' -e 's/%3A/:/g')\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_value\"\n+\t\t\t\t;;\n+\t\tesac\n+\t\t;;\n+esac\n+\n+printf \"Content-Type: text/plain\\r\\n\"\n+printf \"\\r\\n\"\n+printf \"Rate limited\\n\"\ndiff --git a/t/meson.build b/t/meson.build\nindex a04a7a86cf..1e8f270408 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -703,6 +703,7 @@ integration_tests = [\n   't5581-http-curl-verbose.sh',\n   't5582-fetch-negative-refspec.sh',\n   't5583-push-branches.sh',\n+  't5584-http-429-retry.sh',\n   't5600-clone-fail-cleanup.sh',\n   't5601-clone.sh',\n   't5602-clone-remote-exec.sh',\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nnew file mode 100755\nindex 0000000000..f3a9439f51\n--- /dev/null\n+++ b/t/t5584-http-429-retry.sh\n@@ -0,0 +1,266 @@\n+#!/bin/sh\n+\n+test_description='test HTTP 429 Too Many Requests retry logic'\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/lib-httpd.sh\n+\n+start_httpd\n+\n+test_expect_success 'setup test repository' '\n+\ttest_commit initial &&\n+\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n+\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n+'\n+\n+# This test suite uses a special HTTP 429 endpoint at /http_429/ that simulates\n+# rate limiting. The endpoint format is:\n+#   /http_429/<test-context>/<retry-after-value>/<repo-path>\n+# The http-429.sh script (in t/lib-httpd) returns a 429 response with the\n+# specified Retry-After header on the first request for each test context,\n+# then forwards subsequent requests to git-http-backend. Each test context\n+# is isolated, allowing multiple tests to run independently.\n+\n+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n+\t# Set maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should fail immediately without any retry attempt\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retries-disabled/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message in stderr)\n+\ttest_grep ! -i \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 permanent should fail after max retries' '\n+\t# Enable retries with a limit\n+\ttest_config http.maxRetries 2 &&\n+\n+\t# Git should retry but eventually fail when 429 persists\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/permanent-fail/permanent/repo.git\" 2>err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry after receiving 429 and eventually succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-succeeds/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\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+\ttest_config http.retryAfter 1 &&\n+\n+\t# Git should retry using configured default and succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/no-retry-after-header/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 retry delays are respected' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Time the operation - it should take at least 2 seconds due to retry delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Verify it took at least 2 seconds (allowing some tolerance)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n+\t# Configure max retry time to 3 seconds (much less than requested 100)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 3 &&\n+\n+\t# Should fail immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n+\t# Test misconfiguration: retryAfter > maxRetryTime\n+\t# Configure retryAfter larger than maxRetryTime\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 100 &&\n+\ttest_config http.maxRetryTime 5 &&\n+\n+\t# Should fail immediately with configuration error\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n+\t# Test HTTP-date format (RFC 2822) in Retry-After header\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 2)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should parse the HTTP-date and retry after the delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (allowing tolerance for processing time)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 200)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Configure max retry time much less than the 200 second delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 10 &&\n+\n+\t# Should fail immediately without waiting 200 seconds\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 200 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tpast_time=$((now - 10)) &&\n+\traw=$(test-tool date show:rfc2822 $past_time) &&\n+\tpast_date=\"${raw#* -> }\" &&\n+\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should complete quickly (less than 2 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n+\t# Configure default retry-after\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should use configured default (1 second) since header is invalid\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (the configured default)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 will not be retried without config' '\n+\t# Default config means http.maxRetries=0 (retries disabled)\n+\t# When 429 is received, it should fail immediately without retry\n+\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n+\n+\t# Should fail immediately without retry\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/no-retry-without-config/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message)\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\n+\t# Should get 429 error\n+\ttest_grep \"429\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n+\t# Configure retryAfter to 10 seconds\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 10 &&\n+\n+\t# Override with environment variable to 1 second\n+\tstart=$(test-tool date getnanos) &&\n+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should use env var (1 second), not config (10 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n+\t# Configure maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Override with environment variable to enable retries\n+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/http_429/env-max-retries-override/1/repo.git\" >output 2>err &&\n+\n+\t# Should retry (env var enables it despite config saying disabled)\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n+\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 100 &&\n+\n+\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'verify normal repository access still works' '\n+\tgit ls-remote \"$HTTPD_URL/smart/repo.git\" >output &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"536226","messageId":"xmqqseaz9jrd.fsf@gitster.g","threadId":"64535","inReplyTo":"821043c664e41d8e395e944df3ada8f697a69d0b.1771326521.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v3 1/3] strbuf: fix incorrect alloc size in strbuf_reencode()","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-17T20:51:02Z","receivedAt":"2026-02-17T20:51:04Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Vaidas Pilkauskas via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n>\n> The strbuf_reencode() function incorrectly passes the string length\n> as the allocation size to strbuf_attach(), when it should pass\n> length + 1 to account for the null terminator.\n>\n> The reencode_string_len() function allocates len + 1 bytes (including\n> the null terminator) and returns the string length (excluding the null\n> terminator) via the len parameter. However, strbuf_reencode() then\n> calls strbuf_attach() with this length value as both the len and alloc\n> parameters:\n>\n>     strbuf_attach(sb, out, len, len);\n>\n> This is incorrect because strbuf_attach()'s alloc parameter should\n> reflect the actual allocated buffer size, which includes space for the\n> null terminator. This could lead to incorrect memory management in code\n> that relies on sb->alloc being accurate.\n\nI do agree that setting the correct number to .alloc member is a\ngood thing to do, but I am afraid that the above characterization of\na potential problem is incorrect.\n\nIf we were to extend the resulting strbuf further (by e.g.,\nappending to it), we might end up reallocating the buffer a bit\nprematurely by one byte before it actually fills up, but the\nreallocation would be done by giving the piece of memory pointed at\nby \"out\" here to realloc(3), so the wrong value of \"alloc\" would not\nlead to incorrect memory management at all.\n\nUpon further inspection, we see something else interesting.  The\nstrbuf_attach() function, immediately after initializing sb with the\nnew values of buf/len/alloc, calls strbuf_grow(sb, 0) and triggers\nthe ALLOC_GROW() growth thanks to this under specification.  By the\ntime the control returns to the caller, the sb->alloc would be\n(((len)+16)*3/2), not (len+1), and it records the actual allocation\nsize.  So there is no \"could lead to incorrect memory management\" at\nall, but this incorrect number forces us to always reallocate\nimmediately after the strbuf_attach() call, which is a waste when we\nare not going to further extend the strbuf returned by this function.\n\nAnd that is a very good reason to make this fix worth doing.\n\n\n> Fix by passing len + 1 as the alloc parameter:\n>\n>     strbuf_attach(sb, out, len, len + 1);\n\nI wonder how widespread this off-by-one error is.  Shouldn't\nstrbuf_attach() be doing some sanity checking of its parameters?\n\n        void strbuf_attach(struct strbuf *sb, void *buf, size_t len, size_t alloc)\n        {\n\n                strbuf_release(sb);\n                sb->buf   = buf;\n                sb->len   = len;\n                sb->alloc = alloc;\n                strbuf_grow(sb, 0);\n                sb->buf[sb->len] = '\\0';\n        }\n\nGiven the above code, it is clear that alloc must be at least as big\nas (len + 1), and the strbuf_grow(sb, 0) in between is papering over\nproblems (at least it is doing so here for the caller you corrected).\n\nPerhaps we want to replace the call to strbuf_grow(sb, 0) with\nsomething like\n\n\t\tif (alloc <= len)\n\t\t\tBUG(\"alloc must be larger than len\");\n\ninstead?  The log message of 917c9a71 (New strbuf APIs: splice and\nattach., 2007-09-15) is worth reading, but it is an iffy logic that\ndepends too much (at least for my taste) on what strbuf_grow(sb, 0)\nactually does ;-).\n\n\n\n\n> Signed-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n> ---\n>  strbuf.c | 2 +-\n>  1 file changed, 1 insertion(+), 1 deletion(-)\n>\n> diff --git a/strbuf.c b/strbuf.c\n> index 3939863cf3..3e04addc22 100644\n> --- a/strbuf.c\n> +++ b/strbuf.c\n> @@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n>  \tif (!out)\n>  \t\treturn -1;\n>  \n> -\tstrbuf_attach(sb, out, len, len);\n> +\tstrbuf_attach(sb, out, len, len + 1);\n>  \treturn 0;\n>  }\n"},{"id":"536281","messageId":"CAGjQmDODbvzRMO+V4MC_acCAbJh0=A-6ZWswbQCe+tK7ejRGoA@mail.gmail.com","threadId":"64535","inReplyTo":"xmqqseaz9jrd.fsf@gitster.g","subject":"Re: [PATCH v3 1/3] strbuf: fix incorrect alloc size in strbuf_reencode()","fromName":"Vaidas Pilkauskas","fromEmail":"vaidas.pilkauskas@shopify.com","sentAt":"2026-02-18T13:43:22Z","receivedAt":"2026-02-18T13:43:35Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"On Tue, Feb 17, 2026 at 10:51 PM Junio C Hamano <gitster@pobox.com> wrote:\n> > From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n> >\n> > The strbuf_reencode() function incorrectly passes the string length\n> > as the allocation size to strbuf_attach(), when it should pass\n> > length + 1 to account for the null terminator.\n> >\n> > The reencode_string_len() function allocates len + 1 bytes (including\n> > the null terminator) and returns the string length (excluding the null\n> > terminator) via the len parameter. However, strbuf_reencode() then\n> > calls strbuf_attach() with this length value as both the len and alloc\n> > parameters:\n> >\n> >     strbuf_attach(sb, out, len, len);\n> >\n> > This is incorrect because strbuf_attach()'s alloc parameter should\n> > reflect the actual allocated buffer size, which includes space for the\n> > null terminator. This could lead to incorrect memory management in code\n> > that relies on sb->alloc being accurate.\n>\n> I do agree that setting the correct number to .alloc member is a\n> good thing to do, but I am afraid that the above characterization of\n> a potential problem is incorrect.\n>\n> If we were to extend the resulting strbuf further (by e.g.,\n> appending to it), we might end up reallocating the buffer a bit\n> prematurely by one byte before it actually fills up, but the\n> reallocation would be done by giving the piece of memory pointed at\n> by \"out\" here to realloc(3), so the wrong value of \"alloc\" would not\n> lead to incorrect memory management at all.\n>\n> Upon further inspection, we see something else interesting.  The\n> strbuf_attach() function, immediately after initializing sb with the\n> new values of buf/len/alloc, calls strbuf_grow(sb, 0) and triggers\n> the ALLOC_GROW() growth thanks to this under specification.  By the\n> time the control returns to the caller, the sb->alloc would be\n> (((len)+16)*3/2), not (len+1), and it records the actual allocation\n> size.  So there is no \"could lead to incorrect memory management\" at\n> all, but this incorrect number forces us to always reallocate\n> immediately after the strbuf_attach() call, which is a waste when we\n> are not going to further extend the strbuf returned by this function.\n>\n> And that is a very good reason to make this fix worth doing.\n\nI agree that this is incorrect characterization. What about something like this:\n\n    strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n\n    reencode_string_len() allocates len+1 bytes (including the NUL) and\n    returns the string length in len. strbuf_reencode() was calling\n    strbuf_attach(sb, out, len, len), so alloc was one byte too small.\n\n    strbuf_attach() then calls strbuf_grow(sb, 0). With alloc < len+1,\n    ALLOC_GROW always reallocates, so we reallocated immediately after\n    attach even when the strbuf was not extended further. Pass len+1 as\n    the alloc argument so the existing buffer is reused and the\n    reallocation is avoided.\n\n\n> > Fix by passing len + 1 as the alloc parameter:\n> >\n> >     strbuf_attach(sb, out, len, len + 1);\n>\n> I wonder how widespread this off-by-one error is.  Shouldn't\n> strbuf_attach() be doing some sanity checking of its parameters?\n>\n>         void strbuf_attach(struct strbuf *sb, void *buf, size_t len, size_t alloc)\n>         {\n>\n>                 strbuf_release(sb);\n>                 sb->buf   = buf;\n>                 sb->len   = len;\n>                 sb->alloc = alloc;\n>                 strbuf_grow(sb, 0);\n>                 sb->buf[sb->len] = '\\0';\n>         }\n>\n> Given the above code, it is clear that alloc must be at least as big\n> as (len + 1), and the strbuf_grow(sb, 0) in between is papering over\n> problems (at least it is doing so here for the caller you corrected).\n>\n> Perhaps we want to replace the call to strbuf_grow(sb, 0) with\n> something like\n>\n>                 if (alloc <= len)\n>                         BUG(\"alloc must be larger than len\");\n>\n> instead?  The log message of 917c9a71 (New strbuf APIs: splice and\n> attach., 2007-09-15) is worth reading, but it is an iffy logic that\n> depends too much (at least for my taste) on what strbuf_grow(sb, 0)\n> actually does ;-).\n\nI'll send patches, one to clean up call sites (there aren't too many - 7 places)\nand another to add BUG() check to enforce the contract.\n\nThanks, Junio, for the review!\n\n>\n> > Signed-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n> > ---\n> >  strbuf.c | 2 +-\n> >  1 file changed, 1 insertion(+), 1 deletion(-)\n> >\n> > diff --git a/strbuf.c b/strbuf.c\n> > index 3939863cf3..3e04addc22 100644\n> > --- a/strbuf.c\n> > +++ b/strbuf.c\n> > @@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n> >       if (!out)\n> >               return -1;\n> >\n> > -     strbuf_attach(sb, out, len, len);\n> > +     strbuf_attach(sb, out, len, len + 1);\n> >       return 0;\n> >  }\n"},{"id":"536283","messageId":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v3.git.1771326521.gitgitgadget@gmail.com","subject":"[PATCH v4 0/5] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-18T14:09:03Z","receivedAt":"2026-02-18T14:09:12Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"Changes since v3:\n\n * Clean up of all strbuf_attach() call sites\n\n * Add strbuf_attach() contract enforcement via BUG()\n\nChanges since v2:\n\n * New preparatory patch: Introduced show_http_message_fatal() helper\n   function to reduce code duplication in remote-curl.c (suggested by Taylor\n   Blau)\n\n * Removed specific HTTP_RATE_LIMITED error handling from http-push.c and\n   http-walker.c for the obsolete \"dumb\" protocol, allowing generic error\n   handling to take over (suggested by Jeff King)\n\n * Added support for CURLINFO_RETRY_AFTER on curl >= 7.66.0, falling back to\n   manual header parsing on older versions\n\n * Simplified retry/delay architecture: replaced complex non-blocking\n   \"delayed slot\" mechanism with simple blocking sleep() call in the retry\n   loop, removing ~66 lines of timing logic (suggested by Jeff King)\n\n * Fixed Retry-After: 0 handling to allow immediate retry as specified by\n   RFC 9110\n\n * Changed http.retryAfter default from -1 to 0, so Git will retry\n   immediately when encountering HTTP 429 without a Retry-After header,\n   rather than failing with a configuration error\n\n * Improved error messages: shortened to be more concise\n\n * Fixed coding style issues: removed unnecessary curly braces, changed x ==\n   0 to !x (per CodingGuidelines)\n\n * Improved test portability: replaced non-portable date(1) commands with\n   test-tool date, added nanosecond-precision timing with getnanos, replaced\n   cut(1) with POSIX shell parameter expansion\n\n * Split out strbuf.c bugfix into separate preparatory patch (the\n   strbuf_reencode alloc size fix is unrelated to HTTP 429 support)\n\n * Squashed separate trace2 logging patch into main HTTP 429 retry support\n   commit\n\n * Kept header_is_last_match assignment for Retry-After to prevent incorrect\n   handling of HTTP header continuation lines\n\nThe implementation includes:\n\n 1. A bug fix in strbuf_reencode() that corrects the allocation size passed\n    to strbuf_attach(), passing len+1 instead of len so that the existing\n    buffer is reused rather than immediately reallocated.\n\n 2. A cleanup of all strbuf_attach() call sites that were passing alloc ==\n    len, leaving no room for the NUL terminator. Sites with a\n    known-NUL-terminated buffer now pass len+1; sites where the source\n    buffer has no trailing NUL (ll_merge output) are converted to use\n    strbuf_add() instead.\n\n 3. A hardening of strbuf_attach() itself: the internal strbuf_grow() that\n    silently papered over incorrect alloc values is replaced with an\n    explicit BUG() check, enforcing the documented contract that alloc must\n    be greater than len.\n\n 4. A new show_http_message_fatal() helper in remote-curl.c that combines\n    the repeated pattern of show_http_message() followed by die() into a\n    single NORETURN function, reducing boilerplate at existing call sites\n    and providing a clean hook for the retry logic.\n\n 5. The main feature: HTTP 429 retry logic with support for the Retry-After\n    header (both delay-seconds and HTTP-date formats), configurable via\n    http.maxRetries, http.retryAfter, and http.maxRetryTime options. If any\n    computed delay exceeds maxRetryTime the request fails immediately with a\n    clear diagnostic rather than capping and retrying silently.\n\nVaidas Pilkauskas (5):\n  strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n  strbuf_attach: fix all call sites to pass correct alloc\n  strbuf: replace strbuf_grow() in strbuf_attach() with BUG() check\n  remote-curl: introduce show_http_message_fatal() helper\n  http: add support for HTTP 429 rate limit retries\n\n Documentation/config/http.adoc |  23 +++\n apply.c                        |   3 +-\n builtin/am.c                   |   2 +-\n builtin/fast-import.c          |   2 +-\n git-curl-compat.h              |   8 +\n http.c                         | 190 +++++++++++++++++++++--\n http.h                         |   2 +\n mailinfo.c                     |   2 +-\n refs/files-backend.c           |   2 +-\n remote-curl.c                  |  49 +++---\n rerere.c                       |   3 +-\n strbuf.c                       |   5 +-\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n trailer.c                      |   2 +-\n 18 files changed, 629 insertions(+), 38 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\n\nbase-commit: 73fd77805fc6406f31c36212846d9e2541d19321\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2008%2Fvaidas-shopify%2Fretry-after-v4\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2008/vaidas-shopify/retry-after-v4\nPull-Request: https://github.com/gitgitgadget/git/pull/2008\n\nRange-diff vs v3:\n\n 1:  821043c664 ! 1:  a3386f5b56 strbuf: fix incorrect alloc size in strbuf_reencode()\n     @@ Metadata\n      Author: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n      \n       ## Commit message ##\n     -    strbuf: fix incorrect alloc size in strbuf_reencode()\n     +    strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n      \n     -    The strbuf_reencode() function incorrectly passes the string length\n     -    as the allocation size to strbuf_attach(), when it should pass\n     -    length + 1 to account for the null terminator.\n     +    reencode_string_len() allocates len+1 bytes (including the NUL) and\n     +    returns the string length in len. strbuf_reencode() was calling\n     +    strbuf_attach(sb, out, len, len), so alloc was one byte too small.\n      \n     -    The reencode_string_len() function allocates len + 1 bytes (including\n     -    the null terminator) and returns the string length (excluding the null\n     -    terminator) via the len parameter. However, strbuf_reencode() then\n     -    calls strbuf_attach() with this length value as both the len and alloc\n     -    parameters:\n     -\n     -        strbuf_attach(sb, out, len, len);\n     -\n     -    This is incorrect because strbuf_attach()'s alloc parameter should\n     -    reflect the actual allocated buffer size, which includes space for the\n     -    null terminator. This could lead to incorrect memory management in code\n     -    that relies on sb->alloc being accurate.\n     -\n     -    Fix by passing len + 1 as the alloc parameter:\n     -\n     -        strbuf_attach(sb, out, len, len + 1);\n     +    strbuf_attach() then calls strbuf_grow(sb, 0). With alloc < len+1,\n     +    ALLOC_GROW always reallocates, so we reallocated immediately after\n     +    attach even when the strbuf was not extended further. Pass len+1 as\n     +    the alloc argument so the existing buffer is reused and the\n     +    reallocation is avoided.\n      \n          Signed-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n      \n -:  ---------- > 2:  f48b1f07c4 strbuf_attach: fix all call sites to pass correct alloc\n -:  ---------- > 3:  557fd77444 strbuf: replace strbuf_grow() in strbuf_attach() with BUG() check\n 2:  3653067f0e = 4:  3a39dc9e39 remote-curl: introduce show_http_message_fatal() helper\n 3:  3cece62a63 = 5:  5e0f4a56ef http: add support for HTTP 429 rate limit retries\n\n-- \ngitgitgadget\n"},{"id":"536284","messageId":"a3386f5b56e808eec57016e192d42e05543063a4.1771423748.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","subject":"[PATCH v4 1/5] strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-18T14:09:04Z","receivedAt":"2026-02-18T14:09:14Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nreencode_string_len() allocates len+1 bytes (including the NUL) and\nreturns the string length in len. strbuf_reencode() was calling\nstrbuf_attach(sb, out, len, len), so alloc was one byte too small.\n\nstrbuf_attach() then calls strbuf_grow(sb, 0). With alloc < len+1,\nALLOC_GROW always reallocates, so we reallocated immediately after\nattach even when the strbuf was not extended further. Pass len+1 as\nthe alloc argument so the existing buffer is reused and the\nreallocation is avoided.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n strbuf.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/strbuf.c b/strbuf.c\nindex 3939863cf3..3e04addc22 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n \tif (!out)\n \t\treturn -1;\n \n-\tstrbuf_attach(sb, out, len, len);\n+\tstrbuf_attach(sb, out, len, len + 1);\n \treturn 0;\n }\n \n-- \ngitgitgadget\n\n"},{"id":"536285","messageId":"f48b1f07c45f6237f91fa6f746c58b791edef5bd.1771423748.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","subject":"[PATCH v4 2/5] strbuf_attach: fix all call sites to pass correct alloc","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-18T14:09:05Z","receivedAt":"2026-02-18T14:09:16Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nstrbuf_attach(sb, buf, len, alloc) requires alloc > len (the buffer\nmust have at least len+1 bytes to hold the NUL). Several call sites\npassed alloc == len, relying on strbuf_grow(sb, 0) inside strbuf_attach\nto reallocate. Prepare for changing that by fixing call sites to pass\nthe correct alloc.\n\n- mailinfo, am, refs/files-backend, fast-import, trailer: pass len+1\n  when the buffer is a NUL-terminated string (or from strbuf_detach).\n- rerere, apply: ll_merge returns a buffer with exactly result.size\n  bytes (no extra NUL). Use strbuf_add() to copy and NUL-terminate\n  into the strbuf, then free the merge result, so alloc is correct.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n apply.c               | 3 ++-\n builtin/am.c          | 2 +-\n builtin/fast-import.c | 2 +-\n mailinfo.c            | 2 +-\n refs/files-backend.c  | 2 +-\n rerere.c              | 3 ++-\n trailer.c             | 2 +-\n 7 files changed, 9 insertions(+), 7 deletions(-)\n\ndiff --git a/apply.c b/apply.c\nindex e4c4bf7af9..d67d86bce4 100644\n--- a/apply.c\n+++ b/apply.c\n@@ -3589,7 +3589,8 @@ static int three_way_merge(struct apply_state *state,\n \t\treturn -1;\n \t}\n \timage_clear(image);\n-\tstrbuf_attach(&image->buf, result.ptr, result.size, result.size);\n+\tstrbuf_add(&image->buf, result.ptr, result.size);\n+\tfree(result.ptr);\n \n \treturn status;\n }\ndiff --git a/builtin/am.c b/builtin/am.c\nindex e0c767e223..c439f868dc 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -1188,7 +1188,7 @@ static void am_append_signoff(struct am_state *state)\n {\n \tstruct strbuf sb = STRBUF_INIT;\n \n-\tstrbuf_attach(&sb, state->msg, state->msg_len, state->msg_len);\n+\tstrbuf_attach(&sb, state->msg, state->msg_len, state->msg_len + 1);\n \tappend_signoff(&sb, 0, 0);\n \tstate->msg = strbuf_detach(&sb, &state->msg_len);\n }\ndiff --git a/builtin/fast-import.c b/builtin/fast-import.c\nindex b8a7757cfd..164d8a6198 100644\n--- a/builtin/fast-import.c\n+++ b/builtin/fast-import.c\n@@ -3246,7 +3246,7 @@ static void cat_blob(struct object_entry *oe, struct object_id *oid)\n \tcat_blob_write(\"\\n\", 1);\n \tif (oe && oe->pack_id == pack_id) {\n \t\tlast_blob.offset = oe->idx.offset;\n-\t\tstrbuf_attach(&last_blob.data, buf, size, size);\n+\t\tstrbuf_attach(&last_blob.data, buf, size, size + 1);\n \t\tlast_blob.depth = oe->depth;\n \t} else\n \t\tfree(buf);\ndiff --git a/mailinfo.c b/mailinfo.c\nindex a2f06dbd96..13949ff31e 100644\n--- a/mailinfo.c\n+++ b/mailinfo.c\n@@ -470,7 +470,7 @@ static int convert_to_utf8(struct mailinfo *mi,\n \t\treturn error(\"cannot convert from %s to %s\",\n \t\t\t     charset, mi->metainfo_charset);\n \t}\n-\tstrbuf_attach(line, out, out_len, out_len);\n+\tstrbuf_attach(line, out, out_len, out_len + 1);\n \treturn 0;\n }\n \ndiff --git a/refs/files-backend.c b/refs/files-backend.c\nindex b1b13b41f6..6baba11f96 100644\n--- a/refs/files-backend.c\n+++ b/refs/files-backend.c\n@@ -1806,7 +1806,7 @@ static int commit_ref(struct ref_lock *lock)\n \t\tsize_t len = strlen(path);\n \t\tstruct strbuf sb_path = STRBUF_INIT;\n \n-\t\tstrbuf_attach(&sb_path, path, len, len);\n+\t\tstrbuf_attach(&sb_path, path, len, len + 1);\n \n \t\t/*\n \t\t * If this fails, commit_lock_file() will also fail\ndiff --git a/rerere.c b/rerere.c\nindex 6ec55964e2..2f4809a310 100644\n--- a/rerere.c\n+++ b/rerere.c\n@@ -1031,7 +1031,8 @@ static int handle_cache(struct index_state *istate,\n \telse\n \t\tio.io.output = NULL;\n \tstrbuf_init(&io.input, 0);\n-\tstrbuf_attach(&io.input, result.ptr, result.size, result.size);\n+\tstrbuf_add(&io.input, result.ptr, result.size);\n+\tfree(result.ptr);\n \n \t/*\n \t * Grab the conflict ID and optionally write the original\ndiff --git a/trailer.c b/trailer.c\nindex 911a81ed99..3afe368db0 100644\n--- a/trailer.c\n+++ b/trailer.c\n@@ -1009,7 +1009,7 @@ static struct trailer_block *trailer_block_get(const struct process_trailer_opti\n \tfor (ptr = trailer_lines; *ptr; ptr++) {\n \t\tif (last && isspace((*ptr)->buf[0])) {\n \t\t\tstruct strbuf sb = STRBUF_INIT;\n-\t\t\tstrbuf_attach(&sb, *last, strlen(*last), strlen(*last));\n+\t\t\tstrbuf_attach(&sb, *last, strlen(*last), strlen(*last) + 1);\n \t\t\tstrbuf_addbuf(&sb, *ptr);\n \t\t\t*last = strbuf_detach(&sb, NULL);\n \t\t\tcontinue;\n-- \ngitgitgadget\n\n"},{"id":"536286","messageId":"557fd77444dcf68277fea7c1a57dae07b2ec993d.1771423748.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","subject":"[PATCH v4 3/5] strbuf: replace strbuf_grow() in strbuf_attach() with BUG() check","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-18T14:09:06Z","receivedAt":"2026-02-18T14:09:17Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nstrbuf_attach() documents that alloc must be larger than len, as the\nbuffer must have room for the NUL terminator. Replace the strbuf_grow(sb, 0)\ncall, which was silently reallocating when alloc <= len, with an explicit\nBUG() to enforce this contract and write the NUL terminator directly.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n strbuf.c | 3 ++-\n 1 file changed, 2 insertions(+), 1 deletion(-)\n\ndiff --git a/strbuf.c b/strbuf.c\nindex 3e04addc22..0abed40c91 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -95,11 +95,12 @@ char *strbuf_detach(struct strbuf *sb, size_t *sz)\n \n void strbuf_attach(struct strbuf *sb, void *buf, size_t len, size_t alloc)\n {\n+\tif (alloc <= len)\n+\t\tBUG(\"alloc must be larger than len\");\n \tstrbuf_release(sb);\n \tsb->buf   = buf;\n \tsb->len   = len;\n \tsb->alloc = alloc;\n-\tstrbuf_grow(sb, 0);\n \tsb->buf[sb->len] = '\\0';\n }\n \n-- \ngitgitgadget\n\n"},{"id":"536287","messageId":"3a39dc9e39d68b9543d97980b6c68d73d1a168df.1771423748.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","subject":"[PATCH v4 4/5] remote-curl: introduce show_http_message_fatal() helper","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-18T14:09:07Z","receivedAt":"2026-02-18T14:09:19Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nSeveral code paths in remote-curl.c follow the same pattern of calling\nshow_http_message() to display server error messages followed by die()\nto terminate with an error. This duplication makes the code more verbose\nand harder to maintain.\n\nIntroduce a new show_http_message_fatal() helper function that combines\nthese two operations. This function:\n\n1. Displays any HTTP error message from the server via show_http_message()\n2. Calls die() with the provided error message\n3. Returns NORETURN to help the compiler with control flow analysis\n\nRefactor existing call sites in remote-curl.c to use this new helper,\nreducing code duplication and improving readability. This pattern will\nalso be used by upcoming HTTP 429 rate limiting support.\n\nSuggested-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n remote-curl.c | 45 ++++++++++++++++++++++++++++-----------------\n 1 file changed, 28 insertions(+), 17 deletions(-)\n\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 92e40bb682..21c96f2ca9 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -367,23 +367,25 @@ static void free_discovery(struct discovery *d)\n \t}\n }\n \n-static int show_http_message(struct strbuf *type, struct strbuf *charset,\n-\t\t\t     struct strbuf *msg)\n+static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n+\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n {\n \tconst char *p, *eol;\n+\tva_list ap;\n+\treport_fn die_message_routine = get_die_message_routine();\n \n \t/*\n \t * We only show text/plain parts, as other types are likely\n \t * to be ugly to look at on the user's terminal.\n \t */\n \tif (strcmp(type->buf, \"text/plain\"))\n-\t\treturn -1;\n+\t\tgoto out;\n \tif (charset->len)\n \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n \n \tstrbuf_trim(msg);\n \tif (!msg->len)\n-\t\treturn -1;\n+\t\tgoto out;\n \n \tp = msg->buf;\n \tdo {\n@@ -391,7 +393,16 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n \t\tp = eol + 1;\n \t} while(*eol);\n-\treturn 0;\n+\n+out:\n+\tstrbuf_release(type);\n+\tstrbuf_release(charset);\n+\tstrbuf_release(msg);\n+\n+\tva_start(ap, fmt);\n+\tdie_message_routine(fmt, ap);\n+\tva_end(ap);\n+\texit(128);\n }\n \n static int get_protocol_http_header(enum protocol_version version,\n@@ -518,21 +529,21 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \tcase HTTP_OK:\n \t\tbreak;\n \tcase HTTP_MISSING_TARGET:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"repository '%s' not found\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"repository '%s' not found\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOAUTH:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"Authentication failed for '%s'\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"Authentication failed for '%s'\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOMATCHPUBLICKEY:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \tdefault:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s': %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s': %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \t}\n \n \tif (options.verbosity && !starts_with(refs_url.buf, url.buf)) {\n-- \ngitgitgadget\n\n"},{"id":"536288","messageId":"5e0f4a56efb45dfceddb74feadd11ca9e3453ebb.1771423748.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","subject":"[PATCH v4 5/5] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-18T14:09:08Z","receivedAt":"2026-02-18T14:09:21Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd retry logic for HTTP 429 (Too Many Requests) responses to handle\nserver-side rate limiting gracefully. When Git's HTTP client receives\na 429 response, it can now automatically retry the request after an\nappropriate delay, respecting the server's rate limits.\n\nThe implementation supports the RFC-compliant Retry-After header in\nboth delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\npast date is provided, Git retries immediately without waiting.\n\nRetry behavior is controlled by three new configuration options\n(http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\ndocumented in git-config(1).\n\nThe retry logic implements a fail-fast approach: if any delay\n(whether from server header or configuration) exceeds maxRetryTime,\nGit fails immediately with a clear error message rather than capping\nthe delay. This provides better visibility into rate limiting issues.\n\nThe implementation includes extensive test coverage for basic retry\nbehavior, Retry-After header formats (integer and HTTP-date),\nconfiguration combinations, maxRetryTime limits, invalid header\nhandling, environment variable overrides, and edge cases.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n Documentation/config/http.adoc |  23 +++\n git-curl-compat.h              |   8 +\n http.c                         | 190 +++++++++++++++++++++--\n http.h                         |   2 +\n remote-curl.c                  |   4 +\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n 10 files changed, 589 insertions(+), 12 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 9da5c298cc..7d9a90dcba 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -315,6 +315,29 @@ http.keepAliveCount::\n \tunset, curl's default value is used. Can be overridden by the\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+\tHTTP 429 (Too Many Requests) without a Retry-After header.\n+\tDefaults to 0 (retry immediately). When a Retry-After header is\n+\tpresent, its value takes precedence over this setting. Can be\n+\toverridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n+\tSee also `http.maxRetries` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n+\tSee also `http.retryAfter` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n+\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\n \tThis can be helpful with some \"poor\" ftp servers which don't\ndiff --git a/git-curl-compat.h b/git-curl-compat.h\nindex 659e5a3875..dccdd4d6e5 100644\n--- a/git-curl-compat.h\n+++ b/git-curl-compat.h\n@@ -37,6 +37,14 @@\n #define GIT_CURL_NEED_TRANSFER_ENCODING_HEADER\n #endif\n \n+/**\n+ * CURLINFO_RETRY_AFTER was added in 7.66.0, released in September 2019.\n+ * It allows curl to automatically parse Retry-After headers.\n+ */\n+#if LIBCURL_VERSION_NUM >= 0x074200\n+#define GIT_CURL_HAVE_CURLINFO_RETRY_AFTER 1\n+#endif\n+\n /**\n  * CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR were added in 7.85.0,\n  * released in August 2022.\ndiff --git a/http.c b/http.c\nindex 7815f144de..11ea9f38f7 100644\n--- a/http.c\n+++ b/http.c\n@@ -22,6 +22,8 @@\n #include \"object-file.h\"\n #include \"odb.h\"\n #include \"tempfile.h\"\n+#include \"date.h\"\n+#include \"trace2.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -149,6 +151,11 @@ static char *cached_accept_language;\n static char *http_ssl_backend;\n \n static int http_schannel_check_revoke = 1;\n+\n+static long http_retry_after = 0;\n+static long http_max_retries = 0;\n+static long http_max_retry_time = 300;\n+\n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n  * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n@@ -209,7 +216,7 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)\n \treturn size && (*ptr == ' ' || *ptr == '\\t');\n }\n \n-static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n+static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n {\n \tsize_t size = eltsize * nmemb;\n \tstruct strvec *values = &http_auth.wwwauth_headers;\n@@ -257,6 +264,50 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n \t\tgoto exit;\n \t}\n \n+#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */\n+\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n+\t\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n+\n+\t\tstrbuf_add(&buf, val, val_len);\n+\t\tstrbuf_trim(&buf);\n+\n+\t\tif (slot && slot->results) {\n+\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n+\t\t\tchar *endptr;\n+\t\t\tlong retry_after;\n+\n+\t\t\terrno = 0;\n+\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n+\n+\t\t/* Check if it's a valid integer (delay-seconds format) */\n+\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n+\t\t    errno != ERANGE && retry_after >= 0) {\n+\t\t\tslot->results->retry_after = retry_after;\n+\t\t} else {\n+\t\t\t\t/* Try parsing as HTTP-date format */\n+\t\t\t\ttimestamp_t timestamp;\n+\t\t\t\tint offset;\n+\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n+\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n+\t\t\t\t\ttimestamp_t now = time(NULL);\n+\t\t\t\t\tif (timestamp > now) {\n+\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n+\t\t\t\t\t} else {\n+\t\t\t\t\t\t/* Past date means retry immediately */\n+\t\t\t\t\t\tslot->results->retry_after = 0;\n+\t\t\t\t\t}\n+\t\t\t\t} else {\n+\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n+\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n+\t\t\t\t}\n+\t\t\t}\n+\t\t}\n+\n+\t\tgoto exit;\n+\t}\n+#endif\n+\n \t/*\n \t * This line could be a continuation of the previously matched header\n \t * field. If this is the case then we should append this value to the\n@@ -342,6 +393,17 @@ static void finish_active_slot(struct active_request_slot *slot)\n \n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n \t\t\t&slot->results->http_connectcode);\n+\n+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t\tif (slot->results->http_code == 429) {\n+\t\t\tcurl_off_t retry_after;\n+\t\t\tCURLcode res = curl_easy_getinfo(slot->curl,\n+\t\t\t\t\t\t\t  CURLINFO_RETRY_AFTER,\n+\t\t\t\t\t\t\t  &retry_after);\n+\t\t\tif (res == 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@@ -575,6 +637,21 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retryafter\", var)) {\n+\t\thttp_retry_after = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretries\", var)) {\n+\t\thttp_max_retries = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretrytime\", var)) {\n+\t\thttp_max_retry_time = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1422,6 +1499,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n \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+\n \tcurl_default = get_curl_handle();\n }\n \n@@ -1871,6 +1952,10 @@ 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\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n+\t\t\tresults->retry_after);\n+\t\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n \t\t\tcredential_reject(the_repository, &proxy_auth);\n@@ -1886,6 +1971,9 @@ int run_one_slot(struct active_request_slot *slot,\n \t\t struct slot_results *results)\n {\n \tslot->results = results;\n+\t/* Initialize retry_after to -1 (not set) */\n+\tresults->retry_after = -1;\n+\n \tif (!start_active_slot(slot)) {\n \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n \t\t\t  \"failed to start HTTP request\");\n@@ -2119,7 +2207,8 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n \n static int http_request(const char *url,\n \t\t\tvoid *result, int target,\n-\t\t\tconst struct http_get_options *options)\n+\t\t\tconst struct http_get_options *options,\n+\t\t\tlong *retry_after_out)\n {\n \tstruct active_request_slot *slot;\n \tstruct slot_results results;\n@@ -2148,7 +2237,8 @@ static int http_request(const char *url,\n \t\t\t\t\t fwrite_buffer);\n \t}\n \n-\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n \n \taccept_language = http_get_accept_language_header();\n \n@@ -2183,6 +2273,10 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n+\t/* Store retry_after from slot results if output parameter provided */\n+\tif (retry_after_out)\n+\t\t*retry_after_out = results.retry_after;\n+\n \tif (options && options->content_type) {\n \t\tstruct strbuf raw = STRBUF_INIT;\n \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n@@ -2253,21 +2347,79 @@ static int update_url_from_redirect(struct strbuf *base,\n \treturn 1;\n }\n \n-static int http_request_reauth(const char *url,\n+/*\n+ * Handle rate limiting retry logic for HTTP 429 responses.\n+ * Returns a negative value if retries are exhausted or configuration is invalid,\n+ * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n+ */\n+static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n+{\n+\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n+\n+\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n+\t\tretry_attempt);\n+\n+\tif (*rate_limit_retries <= 0) {\n+\t\t/* Retries are disabled or exhausted */\n+\t\tif (http_max_retries > 0) {\n+\t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n+\t\t}\n+\t\treturn -1;\n+\t}\n+\n+\t(*rate_limit_retries)--;\n+\n+\t/* Use the slot-specific retry_after value or configured default */\n+\tif (slot_retry_after >= 0) {\n+\t\t/* Check if retry delay exceeds maximum allowed */\n+\t\tif (slot_retry_after > http_max_retry_time) {\n+\t\t\terror(_(\"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"),\n+\t\t\t      slot_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n+\t\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n+\t\t\treturn -1;\n+\t\t}\n+\t\treturn slot_retry_after;\n+\t} else {\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+\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n+\t\t\treturn -1;\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}\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 {\n \tint i = 3;\n \tint ret;\n+\tint rate_limit_retries = http_max_retries;\n+\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n \n \tif (always_auth_proactively())\n \t\tcredential_fill(the_repository, &http_auth, 1);\n \n-\tret = http_request(url, result, target, options);\n+\tret = http_request(url, result, target, options, &slot_retry_after);\n \n-\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 && 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@@ -2276,7 +2428,8 @@ static int http_request_reauth(const char *url,\n \t\t}\n \t}\n \n-\twhile (ret == HTTP_REAUTH && --i) {\n+\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\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 \t\t * should clear it out before making our next request.\n@@ -2301,10 +2454,23 @@ static int http_request_reauth(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(&rate_limit_retries, slot_retry_after);\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\twarning(_(\"rate limited, waiting %ld seconds before retry\"), 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\tslot_retry_after = -1; /* Reset after use */\n+\t\t} else if (ret == HTTP_REAUTH) {\n+\t\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\t}\n \n-\t\tcredential_fill(the_repository, &http_auth, 1);\n-\n-\t\tret = http_request(url, result, target, options);\n+\t\tret = http_request(url, result, target, options, &slot_retry_after);\n \t}\n \treturn ret;\n }\n@@ -2313,7 +2479,7 @@ int http_get_strbuf(const char *url,\n \t\t    struct strbuf *result,\n \t\t    struct http_get_options *options)\n {\n-\treturn http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);\n+\treturn http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);\n }\n \n /*\n@@ -2337,7 +2503,7 @@ int http_get_file(const char *url, const char *filename,\n \t\tgoto cleanup;\n \t}\n \n-\tret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);\n+\tret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);\n \tfclose(result);\n \n \tif (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))\ndiff --git a/http.h b/http.h\nindex f9d4593404..eb40456450 100644\n--- a/http.h\n+++ b/http.h\n@@ -20,6 +20,7 @@ struct slot_results {\n \tlong http_code;\n \tlong auth_avail;\n \tlong http_connectcode;\n+\tlong retry_after;\n };\n \n struct active_request_slot {\n@@ -167,6 +168,7 @@ struct http_get_options {\n #define HTTP_REAUTH\t4\n #define HTTP_NOAUTH\t5\n #define HTTP_NOMATCHPUBLICKEY\t6\n+#define HTTP_RATE_LIMITED\t7\n \n /*\n  * Requests a URL and stores the result in a strbuf.\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 21c96f2ca9..b80d2adb95 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -540,6 +540,10 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \t\tshow_http_message_fatal(&type, &charset, &buffer,\n \t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n \t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n+\tcase HTTP_RATE_LIMITED:\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tdefault:\n \t\tshow_http_message_fatal(&type, &charset, &buffer,\n \t\t\t\t\t_(\"unable to access '%s': %s\"),\ndiff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\nindex 5091db949b..8a43261ffc 100644\n--- a/t/lib-httpd.sh\n+++ b/t/lib-httpd.sh\n@@ -167,6 +167,7 @@ prepare_httpd() {\n \tinstall_script error.sh\n \tinstall_script apply-one-time-script.sh\n \tinstall_script nph-custom-auth.sh\n+\tinstall_script http-429.sh\n \n \tln -s \"$LIB_HTTPD_MODULE_PATH\" \"$HTTPD_ROOT_PATH/modules\"\n \ndiff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf\nindex e631ab0eb5..6bdef603cd 100644\n--- a/t/lib-httpd/apache.conf\n+++ b/t/lib-httpd/apache.conf\n@@ -139,6 +139,10 @@ SetEnv PERL_PATH ${PERL_PATH}\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n </LocationMatch>\n+<LocationMatch /http_429/>\n+\tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n+\tSetEnv GIT_HTTP_EXPORT_ALL\n+</LocationMatch>\n <LocationMatch /smart_v0/>\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n@@ -160,6 +164,7 @@ ScriptAlias /broken_smart/ broken-smart-http.sh/\n ScriptAlias /error_smart/ error-smart-http.sh/\n ScriptAlias /error/ error.sh/\n ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1\n+ScriptAliasMatch /http_429/(.*) http-429.sh/$1\n ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Directory ${GIT_EXEC_PATH}>\n \tOptions FollowSymlinks\n@@ -185,6 +190,9 @@ ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Files apply-one-time-script.sh>\n \tOptions ExecCGI\n </Files>\n+<Files http-429.sh>\n+\tOptions ExecCGI\n+</Files>\n <Files ${GIT_EXEC_PATH}/git-http-backend>\n \tOptions ExecCGI\n </Files>\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nnew file mode 100644\nindex 0000000000..c97b16145b\n--- /dev/null\n+++ b/t/lib-httpd/http-429.sh\n@@ -0,0 +1,98 @@\n+#!/bin/sh\n+\n+# Script to return HTTP 429 Too Many Requests responses for testing retry logic.\n+# Usage: /http_429/<test-context>/<retry-after-value>/<repo-path>\n+#\n+# The test-context is a unique identifier for each test to isolate state files.\n+# The retry-after-value can be:\n+#   - A number (e.g., \"1\", \"2\", \"100\") - sets Retry-After header to that many seconds\n+#   - \"none\" - no Retry-After header\n+#   - \"invalid\" - invalid Retry-After format\n+#   - \"permanent\" - always return 429 (never succeed)\n+#   - An HTTP-date string (RFC 2822 format) - sets Retry-After to that date\n+#\n+# On first call, returns 429. On subsequent calls (after retry), forwards to git-http-backend\n+# unless retry-after-value is \"permanent\".\n+\n+# Extract test context, retry-after value and repo path from PATH_INFO\n+# PATH_INFO format: /<test-context>/<retry-after-value>/<repo-path>\n+path_info=\"${PATH_INFO#/}\"  # Remove leading slash\n+test_context=\"${path_info%%/*}\"  # Get first component (test context)\n+remaining=\"${path_info#*/}\"  # Get rest\n+retry_after=\"${remaining%%/*}\"  # Get second component (retry-after value)\n+repo_path=\"${remaining#*/}\"  # Get rest (repo path)\n+\n+# Extract repository name from repo_path (e.g., \"repo.git\" from \"repo.git/info/refs\")\n+# The repo name is the first component before any \"/\"\n+repo_name=\"${repo_path%%/*}\"\n+\n+# Use current directory (HTTPD_ROOT_PATH) for state file\n+# Create a safe filename from test_context, retry_after and repo_name\n+# This ensures all requests for the same test context share the same state file\n+safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n+state_file=\"http-429-state-${safe_name}\"\n+\n+# Check if this is the first call (no state file exists)\n+if test -f \"$state_file\"\n+then\n+\t# Already returned 429 once, 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\n+\tPATH_INFO=\"/$repo_path\"\n+\texport PATH_INFO\n+\t# GIT_PROJECT_ROOT points to the document root where repositories are stored\n+\t# The script runs from HTTPD_ROOT_PATH, and www/ is the document root\n+\tif test -z \"$GIT_PROJECT_ROOT\"\n+\tthen\n+\t\t# Construct path: current directory (HTTPD_ROOT_PATH) + /www\n+\t\tGIT_PROJECT_ROOT=\"$(pwd)/www\"\n+\t\texport GIT_PROJECT_ROOT\n+\tfi\n+\texec \"$GIT_EXEC_PATH/git-http-backend\"\n+fi\n+\n+# Mark that we've returned 429\n+touch \"$state_file\"\n+\n+# Output HTTP 429 response\n+printf \"Status: 429 Too Many Requests\\r\\n\"\n+\n+# Set Retry-After header based on retry_after value\n+case \"$retry_after\" in\n+\tnone)\n+\t\t# No Retry-After header\n+\t\t;;\n+\tinvalid)\n+\t\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n+\t\t;;\n+\tpermanent)\n+\t\t# Always return 429, don't set state file for success\n+\t\trm -f \"$state_file\"\n+\t\tprintf \"Retry-After: 1\\r\\n\"\n+\t\tprintf \"Content-Type: text/plain\\r\\n\"\n+\t\tprintf \"\\r\\n\"\n+\t\tprintf \"Permanently rate limited\\n\"\n+\t\texit 0\n+\t\t;;\n+\t*)\n+\t\t# Check if it's a number\n+\t\tcase \"$retry_after\" in\n+\t\t\t[0-9]*)\n+\t\t\t\t# Numeric value\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_after\"\n+\t\t\t\t;;\n+\t\t\t*)\n+\t\t\t\t# Assume it's an HTTP-date format (passed as-is, URL decoded)\n+\t\t\t\t# Apache may URL-encode the path, so decode common URL-encoded characters\n+\t\t\t\t# %20 = space, %2C = comma, %3A = colon\n+\t\t\t\tretry_value=$(echo \"$retry_after\" | sed -e 's/%20/ /g' -e 's/%2C/,/g' -e 's/%3A/:/g')\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_value\"\n+\t\t\t\t;;\n+\t\tesac\n+\t\t;;\n+esac\n+\n+printf \"Content-Type: text/plain\\r\\n\"\n+printf \"\\r\\n\"\n+printf \"Rate limited\\n\"\ndiff --git a/t/meson.build b/t/meson.build\nindex f80e366cff..44f72e0f07 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -704,6 +704,7 @@ integration_tests = [\n   't5581-http-curl-verbose.sh',\n   't5582-fetch-negative-refspec.sh',\n   't5583-push-branches.sh',\n+  't5584-http-429-retry.sh',\n   't5600-clone-fail-cleanup.sh',\n   't5601-clone.sh',\n   't5602-clone-remote-exec.sh',\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nnew file mode 100755\nindex 0000000000..f3a9439f51\n--- /dev/null\n+++ b/t/t5584-http-429-retry.sh\n@@ -0,0 +1,266 @@\n+#!/bin/sh\n+\n+test_description='test HTTP 429 Too Many Requests retry logic'\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/lib-httpd.sh\n+\n+start_httpd\n+\n+test_expect_success 'setup test repository' '\n+\ttest_commit initial &&\n+\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n+\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n+'\n+\n+# This test suite uses a special HTTP 429 endpoint at /http_429/ that simulates\n+# rate limiting. The endpoint format is:\n+#   /http_429/<test-context>/<retry-after-value>/<repo-path>\n+# The http-429.sh script (in t/lib-httpd) returns a 429 response with the\n+# specified Retry-After header on the first request for each test context,\n+# then forwards subsequent requests to git-http-backend. Each test context\n+# is isolated, allowing multiple tests to run independently.\n+\n+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n+\t# Set maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should fail immediately without any retry attempt\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retries-disabled/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message in stderr)\n+\ttest_grep ! -i \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 permanent should fail after max retries' '\n+\t# Enable retries with a limit\n+\ttest_config http.maxRetries 2 &&\n+\n+\t# Git should retry but eventually fail when 429 persists\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/permanent-fail/permanent/repo.git\" 2>err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry after receiving 429 and eventually succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-succeeds/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\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+\ttest_config http.retryAfter 1 &&\n+\n+\t# Git should retry using configured default and succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/no-retry-after-header/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 retry delays are respected' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Time the operation - it should take at least 2 seconds due to retry delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Verify it took at least 2 seconds (allowing some tolerance)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n+\t# Configure max retry time to 3 seconds (much less than requested 100)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 3 &&\n+\n+\t# Should fail immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n+\t# Test misconfiguration: retryAfter > maxRetryTime\n+\t# Configure retryAfter larger than maxRetryTime\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 100 &&\n+\ttest_config http.maxRetryTime 5 &&\n+\n+\t# Should fail immediately with configuration error\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n+\t# Test HTTP-date format (RFC 2822) in Retry-After header\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 2)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should parse the HTTP-date and retry after the delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (allowing tolerance for processing time)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 200)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Configure max retry time much less than the 200 second delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 10 &&\n+\n+\t# Should fail immediately without waiting 200 seconds\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 200 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tpast_time=$((now - 10)) &&\n+\traw=$(test-tool date show:rfc2822 $past_time) &&\n+\tpast_date=\"${raw#* -> }\" &&\n+\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should complete quickly (less than 2 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n+\t# Configure default retry-after\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should use configured default (1 second) since header is invalid\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (the configured default)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 will not be retried without config' '\n+\t# Default config means http.maxRetries=0 (retries disabled)\n+\t# When 429 is received, it should fail immediately without retry\n+\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n+\n+\t# Should fail immediately without retry\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/no-retry-without-config/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message)\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\n+\t# Should get 429 error\n+\ttest_grep \"429\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n+\t# Configure retryAfter to 10 seconds\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 10 &&\n+\n+\t# Override with environment variable to 1 second\n+\tstart=$(test-tool date getnanos) &&\n+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should use env var (1 second), not config (10 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n+\t# Configure maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Override with environment variable to enable retries\n+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/http_429/env-max-retries-override/1/repo.git\" >output 2>err &&\n+\n+\t# Should retry (env var enables it despite config saying disabled)\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n+\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 100 &&\n+\n+\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'verify normal repository access still works' '\n+\tgit ls-remote \"$HTTPD_URL/smart/repo.git\" >output &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"536559","messageId":"xmqqfr6vuisp.fsf@gitster.g","threadId":"64535","inReplyTo":"f48b1f07c45f6237f91fa6f746c58b791edef5bd.1771423748.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v4 2/5] strbuf_attach: fix all call sites to pass correct alloc","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-20T22:55:18Z","receivedAt":"2026-02-20T22:55:21Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Vaidas Pilkauskas via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> - mailinfo, am, refs/files-backend, fast-import, trailer: pass len+1\n>   when the buffer is a NUL-terminated string (or from strbuf_detach).\n\nThese are good.\n\n> - rerere, apply: ll_merge returns a buffer with exactly result.size\n>   bytes (no extra NUL). Use strbuf_add() to copy and NUL-terminate\n>   into the strbuf, then free the merge result, so alloc is correct.\n\nI am not sure about this, because this will result in unnecessary\nreallocation.\n\nFor example\n\n> -\tstrbuf_attach(&image->buf, result.ptr, result.size, result.size);\n\nThis would have resulted in realloc(result.ptr, result.size + X) to\npreserve the strbuf invariants that len + 1 <= alloc inside the\nstrbuf_attach().\n\nIt depends on what the system allocator does, but when X is a small\nnumber, often no new memory needs to be carved out when this\nrealloc() happens, and all that needs to happen is that the size of\nthe memory region recorded by the system allocator is adjusted, and\nthe program will keep using the same memory region plus X bytes out\nof the slop that has already been there when result.ptr was\nallocated.  We will call realloc(), and it may result in a true\nallocation and copy when X is larger than the existing slop, but it\nmay end up to be a cheap operation.\n\nBut if we rewrite it to do this ...\n\n> +\tstrbuf_add(&image->buf, result.ptr, result.size);\n> +\tfree(result.ptr);\n\n... we will allocate as much as result.size and copy the bytes.\nGuaranteed, regardless of how much slop the system allocator left\nafter result.ptr+result.size when it allocated result.ptr.\n\nOf course, we could rewrite the original to\n\n\tresult.ptr = realloc(result.ptr, result.size + 1);\n        strbuf_attach(&image->buf, result.ptr, result.size, result.size + 1);\n\nwhich would avoid the extra allocation and copy when there is even a\nsingle byte of slop after result.ptr+result.size, but at that point,\nfor the sake of simplicity, we may be better off with the original\nimplementation of strbuf_attach() that automatically does that for\nus.\n\nSo, I dunno.\n\nThanks.\n"},{"id":"536784","messageId":"CAGjQmDOfikPmyouaG9zzAYxsEZsW_0p5UU=k_0NkGvUcGa9-Zw@mail.gmail.com","threadId":"64535","inReplyTo":"xmqqfr6vuisp.fsf@gitster.g","subject":"Re: [PATCH v4 2/5] strbuf_attach: fix all call sites to pass correct alloc","fromName":"Vaidas Pilkauskas","fromEmail":"vaidas.pilkauskas@shopify.com","sentAt":"2026-02-23T12:49:22Z","receivedAt":"2026-02-23T12:49:36Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"On Sat, Feb 21, 2026 at 12:55 AM Junio C Hamano <gitster@pobox.com> wrote:\n> > - rerere, apply: ll_merge returns a buffer with exactly result.size\n> >   bytes (no extra NUL). Use strbuf_add() to copy and NUL-terminate\n> >   into the strbuf, then free the merge result, so alloc is correct.\n>\n> I am not sure about this, because this will result in unnecessary\n> reallocation.\n>\n> For example\n>\n> > -     strbuf_attach(&image->buf, result.ptr, result.size, result.size);\n>\n> This would have resulted in realloc(result.ptr, result.size + X) to\n> preserve the strbuf invariants that len + 1 <= alloc inside the\n> strbuf_attach().\n>\n> It depends on what the system allocator does, but when X is a small\n> number, often no new memory needs to be carved out when this\n> realloc() happens, and all that needs to happen is that the size of\n> the memory region recorded by the system allocator is adjusted, and\n> the program will keep using the same memory region plus X bytes out\n> of the slop that has already been there when result.ptr was\n> allocated.  We will call realloc(), and it may result in a true\n> allocation and copy when X is larger than the existing slop, but it\n> may end up to be a cheap operation.\n>\n> But if we rewrite it to do this ...\n>\n> > +     strbuf_add(&image->buf, result.ptr, result.size);\n> > +     free(result.ptr);\n>\n> ... we will allocate as much as result.size and copy the bytes.\n> Guaranteed, regardless of how much slop the system allocator left\n> after result.ptr+result.size when it allocated result.ptr.\n>\n> Of course, we could rewrite the original to\n>\n>         result.ptr = realloc(result.ptr, result.size + 1);\n>         strbuf_attach(&image->buf, result.ptr, result.size, result.size + 1);\n>\n> which would avoid the extra allocation and copy when there is even a\n> single byte of slop after result.ptr+result.size, but at that point,\n> for the sake of simplicity, we may be better off with the original\n> implementation of strbuf_attach() that automatically does that for\n> us.\n\nIt seems reasonable to stay with the original implementation. So I'll\nchange this patch to keep original calls to strbuf_attach() in rerere and\napply. And I'll remove patch which enforces strbuf_attach() contract with\nBUG().\n"},{"id":"536795","messageId":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v4.git.1771423748.gitgitgadget@gmail.com","subject":"[PATCH v5 0/4] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-23T14:20:01Z","receivedAt":"2026-02-23T14:20:07Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"Changes since v4:\n\n * fix only strbuf_attach() calls which don't need reallocation\n * remove patch, which enforces strbuf_attach() contract via BUG()\n\nChanges since v3:\n\n * Clean up of all strbuf_attach() call sites\n\n * Add strbuf_attach() contract enforcement via BUG()\n\nChanges since v2:\n\n * New preparatory patch: Introduced show_http_message_fatal() helper\n   function to reduce code duplication in remote-curl.c (suggested by Taylor\n   Blau)\n\n * Removed specific HTTP_RATE_LIMITED error handling from http-push.c and\n   http-walker.c for the obsolete \"dumb\" protocol, allowing generic error\n   handling to take over (suggested by Jeff King)\n\n * Added support for CURLINFO_RETRY_AFTER on curl >= 7.66.0, falling back to\n   manual header parsing on older versions\n\n * Simplified retry/delay architecture: replaced complex non-blocking\n   \"delayed slot\" mechanism with simple blocking sleep() call in the retry\n   loop, removing ~66 lines of timing logic (suggested by Jeff King)\n\n * Fixed Retry-After: 0 handling to allow immediate retry as specified by\n   RFC 9110\n\n * Changed http.retryAfter default from -1 to 0, so Git will retry\n   immediately when encountering HTTP 429 without a Retry-After header,\n   rather than failing with a configuration error\n\n * Improved error messages: shortened to be more concise\n\n * Fixed coding style issues: removed unnecessary curly braces, changed x ==\n   0 to !x (per CodingGuidelines)\n\n * Improved test portability: replaced non-portable date(1) commands with\n   test-tool date, added nanosecond-precision timing with getnanos, replaced\n   cut(1) with POSIX shell parameter expansion\n\n * Split out strbuf.c bugfix into separate preparatory patch (the\n   strbuf_reencode alloc size fix is unrelated to HTTP 429 support)\n\n * Squashed separate trace2 logging patch into main HTTP 429 retry support\n   commit\n\n * Kept header_is_last_match assignment for Retry-After to prevent incorrect\n   handling of HTTP header continuation lines\n\nThe implementation includes:\n\n 1. A bug fix in strbuf_reencode() that corrects the allocation size passed\n    to strbuf_attach(), passing len+1 instead of len so that the existing\n    buffer is reused rather than immediately reallocated.\n\n 2. A cleanup of strbuf_attach() call sites that were passing alloc == len,\n    leaving no room for the NUL terminator. Sites with a\n    known-NUL-terminated buffer now pass len+1; sites where the source\n    buffer has no trailing NUL (ll_merge output) are converted to use\n    strbuf_add() instead.\n\n 3. A new show_http_message_fatal() helper in remote-curl.c that combines\n    the repeated pattern of show_http_message() followed by die() into a\n    single NORETURN function, reducing boilerplate at existing call sites\n    and providing a clean hook for the retry logic.\n\n 4. The main feature: HTTP 429 retry logic with support for the Retry-After\n    header (both delay-seconds and HTTP-date formats), configurable via\n    http.maxRetries, http.retryAfter, and http.maxRetryTime options. If any\n    computed delay exceeds maxRetryTime the request fails immediately with a\n    clear diagnostic rather than capping and retrying silently.\n\nVaidas Pilkauskas (4):\n  strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n  strbuf_attach: fix call sites to pass correct alloc\n  remote-curl: introduce show_http_message_fatal() helper\n  http: add support for HTTP 429 rate limit retries\n\n Documentation/config/http.adoc |  23 +++\n builtin/am.c                   |   2 +-\n builtin/fast-import.c          |   2 +-\n git-curl-compat.h              |   8 +\n http.c                         | 190 +++++++++++++++++++++--\n http.h                         |   2 +\n mailinfo.c                     |   2 +-\n refs/files-backend.c           |   2 +-\n remote-curl.c                  |  49 +++---\n strbuf.c                       |   2 +-\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n trailer.c                      |   2 +-\n 16 files changed, 623 insertions(+), 35 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\n\nbase-commit: 7c02d39fc2ed2702223c7674f73150d9a7e61ba4\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2008%2Fvaidas-shopify%2Fretry-after-v5\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2008/vaidas-shopify/retry-after-v5\nPull-Request: https://github.com/gitgitgadget/git/pull/2008\n\nRange-diff vs v4:\n\n 1:  a3386f5b56 = 1:  7ec2d66447 strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n 2:  f48b1f07c4 ! 2:  3e0b78cfb6 strbuf_attach: fix all call sites to pass correct alloc\n     @@ Metadata\n      Author: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n      \n       ## Commit message ##\n     -    strbuf_attach: fix all call sites to pass correct alloc\n     +    strbuf_attach: fix call sites to pass correct alloc\n      \n          strbuf_attach(sb, buf, len, alloc) requires alloc > len (the buffer\n          must have at least len+1 bytes to hold the NUL). Several call sites\n          passed alloc == len, relying on strbuf_grow(sb, 0) inside strbuf_attach\n     -    to reallocate. Prepare for changing that by fixing call sites to pass\n     -    the correct alloc.\n     -\n     -    - mailinfo, am, refs/files-backend, fast-import, trailer: pass len+1\n     -      when the buffer is a NUL-terminated string (or from strbuf_detach).\n     -    - rerere, apply: ll_merge returns a buffer with exactly result.size\n     -      bytes (no extra NUL). Use strbuf_add() to copy and NUL-terminate\n     -      into the strbuf, then free the merge result, so alloc is correct.\n     +    to reallocate. Fix these in mailinfo, am, refs/files-backend,\n     +    fast-import, and trailer by passing len+1 when the buffer is a\n     +    NUL-terminated string (or from strbuf_detach).\n      \n          Signed-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n      \n     - ## apply.c ##\n     -@@ apply.c: static int three_way_merge(struct apply_state *state,\n     - \t\treturn -1;\n     - \t}\n     - \timage_clear(image);\n     --\tstrbuf_attach(&image->buf, result.ptr, result.size, result.size);\n     -+\tstrbuf_add(&image->buf, result.ptr, result.size);\n     -+\tfree(result.ptr);\n     - \n     - \treturn status;\n     - }\n     -\n       ## builtin/am.c ##\n      @@ builtin/am.c: static void am_append_signoff(struct am_state *state)\n       {\n     @@ refs/files-backend.c: static int commit_ref(struct ref_lock *lock)\n       \t\t/*\n       \t\t * If this fails, commit_lock_file() will also fail\n      \n     - ## rerere.c ##\n     -@@ rerere.c: static int handle_cache(struct index_state *istate,\n     - \telse\n     - \t\tio.io.output = NULL;\n     - \tstrbuf_init(&io.input, 0);\n     --\tstrbuf_attach(&io.input, result.ptr, result.size, result.size);\n     -+\tstrbuf_add(&io.input, result.ptr, result.size);\n     -+\tfree(result.ptr);\n     - \n     - \t/*\n     - \t * Grab the conflict ID and optionally write the original\n     -\n       ## trailer.c ##\n      @@ trailer.c: static struct trailer_block *trailer_block_get(const struct process_trailer_opti\n       \tfor (ptr = trailer_lines; *ptr; ptr++) {\n 3:  557fd77444 < -:  ---------- strbuf: replace strbuf_grow() in strbuf_attach() with BUG() check\n 4:  3a39dc9e39 = 3:  973703e9dd remote-curl: introduce show_http_message_fatal() helper\n 5:  5e0f4a56ef = 4:  bfee1f10c0 http: add support for HTTP 429 rate limit retries\n\n-- \ngitgitgadget\n"},{"id":"536796","messageId":"7ec2d6644785827c047a55a99b213468583c6c1a.1771856405.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","subject":"[PATCH v5 1/4] strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-23T14:20:02Z","receivedAt":"2026-02-23T14:20:08Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nreencode_string_len() allocates len+1 bytes (including the NUL) and\nreturns the string length in len. strbuf_reencode() was calling\nstrbuf_attach(sb, out, len, len), so alloc was one byte too small.\n\nstrbuf_attach() then calls strbuf_grow(sb, 0). With alloc < len+1,\nALLOC_GROW always reallocates, so we reallocated immediately after\nattach even when the strbuf was not extended further. Pass len+1 as\nthe alloc argument so the existing buffer is reused and the\nreallocation is avoided.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n strbuf.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/strbuf.c b/strbuf.c\nindex 3939863cf3..3e04addc22 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n \tif (!out)\n \t\treturn -1;\n \n-\tstrbuf_attach(sb, out, len, len);\n+\tstrbuf_attach(sb, out, len, len + 1);\n \treturn 0;\n }\n \n-- \ngitgitgadget\n\n"},{"id":"536797","messageId":"3e0b78cfb66d7b903363e8e5e18720ed4894fbf5.1771856405.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","subject":"[PATCH v5 2/4] strbuf_attach: fix call sites to pass correct alloc","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-23T14:20:03Z","receivedAt":"2026-02-23T14:20:09Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nstrbuf_attach(sb, buf, len, alloc) requires alloc > len (the buffer\nmust have at least len+1 bytes to hold the NUL). Several call sites\npassed alloc == len, relying on strbuf_grow(sb, 0) inside strbuf_attach\nto reallocate. Fix these in mailinfo, am, refs/files-backend,\nfast-import, and trailer by passing len+1 when the buffer is a\nNUL-terminated string (or from strbuf_detach).\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n builtin/am.c          | 2 +-\n builtin/fast-import.c | 2 +-\n mailinfo.c            | 2 +-\n refs/files-backend.c  | 2 +-\n trailer.c             | 2 +-\n 5 files changed, 5 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/am.c b/builtin/am.c\nindex e0c767e223..c439f868dc 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -1188,7 +1188,7 @@ static void am_append_signoff(struct am_state *state)\n {\n \tstruct strbuf sb = STRBUF_INIT;\n \n-\tstrbuf_attach(&sb, state->msg, state->msg_len, state->msg_len);\n+\tstrbuf_attach(&sb, state->msg, state->msg_len, state->msg_len + 1);\n \tappend_signoff(&sb, 0, 0);\n \tstate->msg = strbuf_detach(&sb, &state->msg_len);\n }\ndiff --git a/builtin/fast-import.c b/builtin/fast-import.c\nindex b8a7757cfd..164d8a6198 100644\n--- a/builtin/fast-import.c\n+++ b/builtin/fast-import.c\n@@ -3246,7 +3246,7 @@ static void cat_blob(struct object_entry *oe, struct object_id *oid)\n \tcat_blob_write(\"\\n\", 1);\n \tif (oe && oe->pack_id == pack_id) {\n \t\tlast_blob.offset = oe->idx.offset;\n-\t\tstrbuf_attach(&last_blob.data, buf, size, size);\n+\t\tstrbuf_attach(&last_blob.data, buf, size, size + 1);\n \t\tlast_blob.depth = oe->depth;\n \t} else\n \t\tfree(buf);\ndiff --git a/mailinfo.c b/mailinfo.c\nindex a2f06dbd96..13949ff31e 100644\n--- a/mailinfo.c\n+++ b/mailinfo.c\n@@ -470,7 +470,7 @@ static int convert_to_utf8(struct mailinfo *mi,\n \t\treturn error(\"cannot convert from %s to %s\",\n \t\t\t     charset, mi->metainfo_charset);\n \t}\n-\tstrbuf_attach(line, out, out_len, out_len);\n+\tstrbuf_attach(line, out, out_len, out_len + 1);\n \treturn 0;\n }\n \ndiff --git a/refs/files-backend.c b/refs/files-backend.c\nindex b1b13b41f6..6baba11f96 100644\n--- a/refs/files-backend.c\n+++ b/refs/files-backend.c\n@@ -1806,7 +1806,7 @@ static int commit_ref(struct ref_lock *lock)\n \t\tsize_t len = strlen(path);\n \t\tstruct strbuf sb_path = STRBUF_INIT;\n \n-\t\tstrbuf_attach(&sb_path, path, len, len);\n+\t\tstrbuf_attach(&sb_path, path, len, len + 1);\n \n \t\t/*\n \t\t * If this fails, commit_lock_file() will also fail\ndiff --git a/trailer.c b/trailer.c\nindex 911a81ed99..3afe368db0 100644\n--- a/trailer.c\n+++ b/trailer.c\n@@ -1009,7 +1009,7 @@ static struct trailer_block *trailer_block_get(const struct process_trailer_opti\n \tfor (ptr = trailer_lines; *ptr; ptr++) {\n \t\tif (last && isspace((*ptr)->buf[0])) {\n \t\t\tstruct strbuf sb = STRBUF_INIT;\n-\t\t\tstrbuf_attach(&sb, *last, strlen(*last), strlen(*last));\n+\t\t\tstrbuf_attach(&sb, *last, strlen(*last), strlen(*last) + 1);\n \t\t\tstrbuf_addbuf(&sb, *ptr);\n \t\t\t*last = strbuf_detach(&sb, NULL);\n \t\t\tcontinue;\n-- \ngitgitgadget\n\n"},{"id":"536798","messageId":"973703e9ddedb67daa946208ef7edb9eb50425a7.1771856405.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","subject":"[PATCH v5 3/4] remote-curl: introduce show_http_message_fatal() helper","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-23T14:20:04Z","receivedAt":"2026-02-23T14:20:12Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nSeveral code paths in remote-curl.c follow the same pattern of calling\nshow_http_message() to display server error messages followed by die()\nto terminate with an error. This duplication makes the code more verbose\nand harder to maintain.\n\nIntroduce a new show_http_message_fatal() helper function that combines\nthese two operations. This function:\n\n1. Displays any HTTP error message from the server via show_http_message()\n2. Calls die() with the provided error message\n3. Returns NORETURN to help the compiler with control flow analysis\n\nRefactor existing call sites in remote-curl.c to use this new helper,\nreducing code duplication and improving readability. This pattern will\nalso be used by upcoming HTTP 429 rate limiting support.\n\nSuggested-by: Taylor Blau <me@ttaylorr.com>\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n remote-curl.c | 45 ++++++++++++++++++++++++++++-----------------\n 1 file changed, 28 insertions(+), 17 deletions(-)\n\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 92e40bb682..21c96f2ca9 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -367,23 +367,25 @@ static void free_discovery(struct discovery *d)\n \t}\n }\n \n-static int show_http_message(struct strbuf *type, struct strbuf *charset,\n-\t\t\t     struct strbuf *msg)\n+static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n+\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n {\n \tconst char *p, *eol;\n+\tva_list ap;\n+\treport_fn die_message_routine = get_die_message_routine();\n \n \t/*\n \t * We only show text/plain parts, as other types are likely\n \t * to be ugly to look at on the user's terminal.\n \t */\n \tif (strcmp(type->buf, \"text/plain\"))\n-\t\treturn -1;\n+\t\tgoto out;\n \tif (charset->len)\n \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n \n \tstrbuf_trim(msg);\n \tif (!msg->len)\n-\t\treturn -1;\n+\t\tgoto out;\n \n \tp = msg->buf;\n \tdo {\n@@ -391,7 +393,16 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n \t\tp = eol + 1;\n \t} while(*eol);\n-\treturn 0;\n+\n+out:\n+\tstrbuf_release(type);\n+\tstrbuf_release(charset);\n+\tstrbuf_release(msg);\n+\n+\tva_start(ap, fmt);\n+\tdie_message_routine(fmt, ap);\n+\tva_end(ap);\n+\texit(128);\n }\n \n static int get_protocol_http_header(enum protocol_version version,\n@@ -518,21 +529,21 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \tcase HTTP_OK:\n \t\tbreak;\n \tcase HTTP_MISSING_TARGET:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"repository '%s' not found\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"repository '%s' not found\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOAUTH:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"Authentication failed for '%s'\"),\n-\t\t    transport_anonymize_url(url.buf));\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"Authentication failed for '%s'\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tcase HTTP_NOMATCHPUBLICKEY:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \tdefault:\n-\t\tshow_http_message(&type, &charset, &buffer);\n-\t\tdie(_(\"unable to access '%s': %s\"),\n-\t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"unable to access '%s': %s\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n \t}\n \n \tif (options.verbosity && !starts_with(refs_url.buf, url.buf)) {\n-- \ngitgitgadget\n\n"},{"id":"536799","messageId":"bfee1f10c0dc3fe3bd4bb5ed87bf380cc6798968.1771856405.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","subject":"[PATCH v5 4/4] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-02-23T14:20:05Z","receivedAt":"2026-02-23T14:20:12Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd retry logic for HTTP 429 (Too Many Requests) responses to handle\nserver-side rate limiting gracefully. When Git's HTTP client receives\na 429 response, it can now automatically retry the request after an\nappropriate delay, respecting the server's rate limits.\n\nThe implementation supports the RFC-compliant Retry-After header in\nboth delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\npast date is provided, Git retries immediately without waiting.\n\nRetry behavior is controlled by three new configuration options\n(http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\ndocumented in git-config(1).\n\nThe retry logic implements a fail-fast approach: if any delay\n(whether from server header or configuration) exceeds maxRetryTime,\nGit fails immediately with a clear error message rather than capping\nthe delay. This provides better visibility into rate limiting issues.\n\nThe implementation includes extensive test coverage for basic retry\nbehavior, Retry-After header formats (integer and HTTP-date),\nconfiguration combinations, maxRetryTime limits, invalid header\nhandling, environment variable overrides, and edge cases.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n Documentation/config/http.adoc |  23 +++\n git-curl-compat.h              |   8 +\n http.c                         | 190 +++++++++++++++++++++--\n http.h                         |   2 +\n remote-curl.c                  |   4 +\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n 10 files changed, 589 insertions(+), 12 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 9da5c298cc..7d9a90dcba 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -315,6 +315,29 @@ http.keepAliveCount::\n \tunset, curl's default value is used. Can be overridden by the\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+\tHTTP 429 (Too Many Requests) without a Retry-After header.\n+\tDefaults to 0 (retry immediately). When a Retry-After header is\n+\tpresent, its value takes precedence over this setting. Can be\n+\toverridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\n+\tSee also `http.maxRetries` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n+\tSee also `http.retryAfter` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n+\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\n \tThis can be helpful with some \"poor\" ftp servers which don't\ndiff --git a/git-curl-compat.h b/git-curl-compat.h\nindex 659e5a3875..dccdd4d6e5 100644\n--- a/git-curl-compat.h\n+++ b/git-curl-compat.h\n@@ -37,6 +37,14 @@\n #define GIT_CURL_NEED_TRANSFER_ENCODING_HEADER\n #endif\n \n+/**\n+ * CURLINFO_RETRY_AFTER was added in 7.66.0, released in September 2019.\n+ * It allows curl to automatically parse Retry-After headers.\n+ */\n+#if LIBCURL_VERSION_NUM >= 0x074200\n+#define GIT_CURL_HAVE_CURLINFO_RETRY_AFTER 1\n+#endif\n+\n /**\n  * CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR were added in 7.85.0,\n  * released in August 2022.\ndiff --git a/http.c b/http.c\nindex 7815f144de..11ea9f38f7 100644\n--- a/http.c\n+++ b/http.c\n@@ -22,6 +22,8 @@\n #include \"object-file.h\"\n #include \"odb.h\"\n #include \"tempfile.h\"\n+#include \"date.h\"\n+#include \"trace2.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -149,6 +151,11 @@ static char *cached_accept_language;\n static char *http_ssl_backend;\n \n static int http_schannel_check_revoke = 1;\n+\n+static long http_retry_after = 0;\n+static long http_max_retries = 0;\n+static long http_max_retry_time = 300;\n+\n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n  * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n@@ -209,7 +216,7 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)\n \treturn size && (*ptr == ' ' || *ptr == '\\t');\n }\n \n-static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n+static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n {\n \tsize_t size = eltsize * nmemb;\n \tstruct strvec *values = &http_auth.wwwauth_headers;\n@@ -257,6 +264,50 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n \t\tgoto exit;\n \t}\n \n+#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */\n+\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n+\t\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n+\n+\t\tstrbuf_add(&buf, val, val_len);\n+\t\tstrbuf_trim(&buf);\n+\n+\t\tif (slot && slot->results) {\n+\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n+\t\t\tchar *endptr;\n+\t\t\tlong retry_after;\n+\n+\t\t\terrno = 0;\n+\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n+\n+\t\t/* Check if it's a valid integer (delay-seconds format) */\n+\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n+\t\t    errno != ERANGE && retry_after >= 0) {\n+\t\t\tslot->results->retry_after = retry_after;\n+\t\t} else {\n+\t\t\t\t/* Try parsing as HTTP-date format */\n+\t\t\t\ttimestamp_t timestamp;\n+\t\t\t\tint offset;\n+\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n+\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n+\t\t\t\t\ttimestamp_t now = time(NULL);\n+\t\t\t\t\tif (timestamp > now) {\n+\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n+\t\t\t\t\t} else {\n+\t\t\t\t\t\t/* Past date means retry immediately */\n+\t\t\t\t\t\tslot->results->retry_after = 0;\n+\t\t\t\t\t}\n+\t\t\t\t} else {\n+\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n+\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n+\t\t\t\t}\n+\t\t\t}\n+\t\t}\n+\n+\t\tgoto exit;\n+\t}\n+#endif\n+\n \t/*\n \t * This line could be a continuation of the previously matched header\n \t * field. If this is the case then we should append this value to the\n@@ -342,6 +393,17 @@ static void finish_active_slot(struct active_request_slot *slot)\n \n \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n \t\t\t&slot->results->http_connectcode);\n+\n+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n+\t\tif (slot->results->http_code == 429) {\n+\t\t\tcurl_off_t retry_after;\n+\t\t\tCURLcode res = curl_easy_getinfo(slot->curl,\n+\t\t\t\t\t\t\t  CURLINFO_RETRY_AFTER,\n+\t\t\t\t\t\t\t  &retry_after);\n+\t\t\tif (res == 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@@ -575,6 +637,21 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retryafter\", var)) {\n+\t\thttp_retry_after = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretries\", var)) {\n+\t\thttp_max_retries = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretrytime\", var)) {\n+\t\thttp_max_retry_time = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1422,6 +1499,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n \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+\n \tcurl_default = get_curl_handle();\n }\n \n@@ -1871,6 +1952,10 @@ 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\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n+\t\t\tresults->retry_after);\n+\t\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n \t\t\tcredential_reject(the_repository, &proxy_auth);\n@@ -1886,6 +1971,9 @@ int run_one_slot(struct active_request_slot *slot,\n \t\t struct slot_results *results)\n {\n \tslot->results = results;\n+\t/* Initialize retry_after to -1 (not set) */\n+\tresults->retry_after = -1;\n+\n \tif (!start_active_slot(slot)) {\n \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n \t\t\t  \"failed to start HTTP request\");\n@@ -2119,7 +2207,8 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n \n static int http_request(const char *url,\n \t\t\tvoid *result, int target,\n-\t\t\tconst struct http_get_options *options)\n+\t\t\tconst struct http_get_options *options,\n+\t\t\tlong *retry_after_out)\n {\n \tstruct active_request_slot *slot;\n \tstruct slot_results results;\n@@ -2148,7 +2237,8 @@ static int http_request(const char *url,\n \t\t\t\t\t fwrite_buffer);\n \t}\n \n-\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n \n \taccept_language = http_get_accept_language_header();\n \n@@ -2183,6 +2273,10 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n+\t/* Store retry_after from slot results if output parameter provided */\n+\tif (retry_after_out)\n+\t\t*retry_after_out = results.retry_after;\n+\n \tif (options && options->content_type) {\n \t\tstruct strbuf raw = STRBUF_INIT;\n \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n@@ -2253,21 +2347,79 @@ static int update_url_from_redirect(struct strbuf *base,\n \treturn 1;\n }\n \n-static int http_request_reauth(const char *url,\n+/*\n+ * Handle rate limiting retry logic for HTTP 429 responses.\n+ * Returns a negative value if retries are exhausted or configuration is invalid,\n+ * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n+ */\n+static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n+{\n+\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n+\n+\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n+\t\tretry_attempt);\n+\n+\tif (*rate_limit_retries <= 0) {\n+\t\t/* Retries are disabled or exhausted */\n+\t\tif (http_max_retries > 0) {\n+\t\t\terror(_(\"too many rate limit retries, giving up\"));\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n+\t\t}\n+\t\treturn -1;\n+\t}\n+\n+\t(*rate_limit_retries)--;\n+\n+\t/* Use the slot-specific retry_after value or configured default */\n+\tif (slot_retry_after >= 0) {\n+\t\t/* Check if retry delay exceeds maximum allowed */\n+\t\tif (slot_retry_after > http_max_retry_time) {\n+\t\t\terror(_(\"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"),\n+\t\t\t      slot_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n+\t\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n+\t\t\treturn -1;\n+\t\t}\n+\t\treturn slot_retry_after;\n+\t} else {\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+\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n+\t\t\treturn -1;\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}\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 {\n \tint i = 3;\n \tint ret;\n+\tint rate_limit_retries = http_max_retries;\n+\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n \n \tif (always_auth_proactively())\n \t\tcredential_fill(the_repository, &http_auth, 1);\n \n-\tret = http_request(url, result, target, options);\n+\tret = http_request(url, result, target, options, &slot_retry_after);\n \n-\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\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 && 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@@ -2276,7 +2428,8 @@ static int http_request_reauth(const char *url,\n \t\t}\n \t}\n \n-\twhile (ret == HTTP_REAUTH && --i) {\n+\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\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 \t\t * should clear it out before making our next request.\n@@ -2301,10 +2454,23 @@ static int http_request_reauth(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(&rate_limit_retries, slot_retry_after);\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\twarning(_(\"rate limited, waiting %ld seconds before retry\"), 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\tslot_retry_after = -1; /* Reset after use */\n+\t\t} else if (ret == HTTP_REAUTH) {\n+\t\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\t}\n \n-\t\tcredential_fill(the_repository, &http_auth, 1);\n-\n-\t\tret = http_request(url, result, target, options);\n+\t\tret = http_request(url, result, target, options, &slot_retry_after);\n \t}\n \treturn ret;\n }\n@@ -2313,7 +2479,7 @@ int http_get_strbuf(const char *url,\n \t\t    struct strbuf *result,\n \t\t    struct http_get_options *options)\n {\n-\treturn http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);\n+\treturn http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);\n }\n \n /*\n@@ -2337,7 +2503,7 @@ int http_get_file(const char *url, const char *filename,\n \t\tgoto cleanup;\n \t}\n \n-\tret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);\n+\tret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);\n \tfclose(result);\n \n \tif (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))\ndiff --git a/http.h b/http.h\nindex f9d4593404..eb40456450 100644\n--- a/http.h\n+++ b/http.h\n@@ -20,6 +20,7 @@ struct slot_results {\n \tlong http_code;\n \tlong auth_avail;\n \tlong http_connectcode;\n+\tlong retry_after;\n };\n \n struct active_request_slot {\n@@ -167,6 +168,7 @@ struct http_get_options {\n #define HTTP_REAUTH\t4\n #define HTTP_NOAUTH\t5\n #define HTTP_NOMATCHPUBLICKEY\t6\n+#define HTTP_RATE_LIMITED\t7\n \n /*\n  * Requests a URL and stores the result in a strbuf.\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 21c96f2ca9..b80d2adb95 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -540,6 +540,10 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \t\tshow_http_message_fatal(&type, &charset, &buffer,\n \t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n \t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n+\tcase HTTP_RATE_LIMITED:\n+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n+\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n+\t\t\t\t\ttransport_anonymize_url(url.buf));\n \tdefault:\n \t\tshow_http_message_fatal(&type, &charset, &buffer,\n \t\t\t\t\t_(\"unable to access '%s': %s\"),\ndiff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\nindex 5091db949b..8a43261ffc 100644\n--- a/t/lib-httpd.sh\n+++ b/t/lib-httpd.sh\n@@ -167,6 +167,7 @@ prepare_httpd() {\n \tinstall_script error.sh\n \tinstall_script apply-one-time-script.sh\n \tinstall_script nph-custom-auth.sh\n+\tinstall_script http-429.sh\n \n \tln -s \"$LIB_HTTPD_MODULE_PATH\" \"$HTTPD_ROOT_PATH/modules\"\n \ndiff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf\nindex e631ab0eb5..6bdef603cd 100644\n--- a/t/lib-httpd/apache.conf\n+++ b/t/lib-httpd/apache.conf\n@@ -139,6 +139,10 @@ SetEnv PERL_PATH ${PERL_PATH}\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n </LocationMatch>\n+<LocationMatch /http_429/>\n+\tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n+\tSetEnv GIT_HTTP_EXPORT_ALL\n+</LocationMatch>\n <LocationMatch /smart_v0/>\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n@@ -160,6 +164,7 @@ ScriptAlias /broken_smart/ broken-smart-http.sh/\n ScriptAlias /error_smart/ error-smart-http.sh/\n ScriptAlias /error/ error.sh/\n ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1\n+ScriptAliasMatch /http_429/(.*) http-429.sh/$1\n ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Directory ${GIT_EXEC_PATH}>\n \tOptions FollowSymlinks\n@@ -185,6 +190,9 @@ ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Files apply-one-time-script.sh>\n \tOptions ExecCGI\n </Files>\n+<Files http-429.sh>\n+\tOptions ExecCGI\n+</Files>\n <Files ${GIT_EXEC_PATH}/git-http-backend>\n \tOptions ExecCGI\n </Files>\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nnew file mode 100644\nindex 0000000000..c97b16145b\n--- /dev/null\n+++ b/t/lib-httpd/http-429.sh\n@@ -0,0 +1,98 @@\n+#!/bin/sh\n+\n+# Script to return HTTP 429 Too Many Requests responses for testing retry logic.\n+# Usage: /http_429/<test-context>/<retry-after-value>/<repo-path>\n+#\n+# The test-context is a unique identifier for each test to isolate state files.\n+# The retry-after-value can be:\n+#   - A number (e.g., \"1\", \"2\", \"100\") - sets Retry-After header to that many seconds\n+#   - \"none\" - no Retry-After header\n+#   - \"invalid\" - invalid Retry-After format\n+#   - \"permanent\" - always return 429 (never succeed)\n+#   - An HTTP-date string (RFC 2822 format) - sets Retry-After to that date\n+#\n+# On first call, returns 429. On subsequent calls (after retry), forwards to git-http-backend\n+# unless retry-after-value is \"permanent\".\n+\n+# Extract test context, retry-after value and repo path from PATH_INFO\n+# PATH_INFO format: /<test-context>/<retry-after-value>/<repo-path>\n+path_info=\"${PATH_INFO#/}\"  # Remove leading slash\n+test_context=\"${path_info%%/*}\"  # Get first component (test context)\n+remaining=\"${path_info#*/}\"  # Get rest\n+retry_after=\"${remaining%%/*}\"  # Get second component (retry-after value)\n+repo_path=\"${remaining#*/}\"  # Get rest (repo path)\n+\n+# Extract repository name from repo_path (e.g., \"repo.git\" from \"repo.git/info/refs\")\n+# The repo name is the first component before any \"/\"\n+repo_name=\"${repo_path%%/*}\"\n+\n+# Use current directory (HTTPD_ROOT_PATH) for state file\n+# Create a safe filename from test_context, retry_after and repo_name\n+# This ensures all requests for the same test context share the same state file\n+safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n+state_file=\"http-429-state-${safe_name}\"\n+\n+# Check if this is the first call (no state file exists)\n+if test -f \"$state_file\"\n+then\n+\t# Already returned 429 once, 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\n+\tPATH_INFO=\"/$repo_path\"\n+\texport PATH_INFO\n+\t# GIT_PROJECT_ROOT points to the document root where repositories are stored\n+\t# The script runs from HTTPD_ROOT_PATH, and www/ is the document root\n+\tif test -z \"$GIT_PROJECT_ROOT\"\n+\tthen\n+\t\t# Construct path: current directory (HTTPD_ROOT_PATH) + /www\n+\t\tGIT_PROJECT_ROOT=\"$(pwd)/www\"\n+\t\texport GIT_PROJECT_ROOT\n+\tfi\n+\texec \"$GIT_EXEC_PATH/git-http-backend\"\n+fi\n+\n+# Mark that we've returned 429\n+touch \"$state_file\"\n+\n+# Output HTTP 429 response\n+printf \"Status: 429 Too Many Requests\\r\\n\"\n+\n+# Set Retry-After header based on retry_after value\n+case \"$retry_after\" in\n+\tnone)\n+\t\t# No Retry-After header\n+\t\t;;\n+\tinvalid)\n+\t\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n+\t\t;;\n+\tpermanent)\n+\t\t# Always return 429, don't set state file for success\n+\t\trm -f \"$state_file\"\n+\t\tprintf \"Retry-After: 1\\r\\n\"\n+\t\tprintf \"Content-Type: text/plain\\r\\n\"\n+\t\tprintf \"\\r\\n\"\n+\t\tprintf \"Permanently rate limited\\n\"\n+\t\texit 0\n+\t\t;;\n+\t*)\n+\t\t# Check if it's a number\n+\t\tcase \"$retry_after\" in\n+\t\t\t[0-9]*)\n+\t\t\t\t# Numeric value\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_after\"\n+\t\t\t\t;;\n+\t\t\t*)\n+\t\t\t\t# Assume it's an HTTP-date format (passed as-is, URL decoded)\n+\t\t\t\t# Apache may URL-encode the path, so decode common URL-encoded characters\n+\t\t\t\t# %20 = space, %2C = comma, %3A = colon\n+\t\t\t\tretry_value=$(echo \"$retry_after\" | sed -e 's/%20/ /g' -e 's/%2C/,/g' -e 's/%3A/:/g')\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_value\"\n+\t\t\t\t;;\n+\t\tesac\n+\t\t;;\n+esac\n+\n+printf \"Content-Type: text/plain\\r\\n\"\n+printf \"\\r\\n\"\n+printf \"Rate limited\\n\"\ndiff --git a/t/meson.build b/t/meson.build\nindex f80e366cff..44f72e0f07 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -704,6 +704,7 @@ integration_tests = [\n   't5581-http-curl-verbose.sh',\n   't5582-fetch-negative-refspec.sh',\n   't5583-push-branches.sh',\n+  't5584-http-429-retry.sh',\n   't5600-clone-fail-cleanup.sh',\n   't5601-clone.sh',\n   't5602-clone-remote-exec.sh',\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nnew file mode 100755\nindex 0000000000..f3a9439f51\n--- /dev/null\n+++ b/t/t5584-http-429-retry.sh\n@@ -0,0 +1,266 @@\n+#!/bin/sh\n+\n+test_description='test HTTP 429 Too Many Requests retry logic'\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/lib-httpd.sh\n+\n+start_httpd\n+\n+test_expect_success 'setup test repository' '\n+\ttest_commit initial &&\n+\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n+\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n+'\n+\n+# This test suite uses a special HTTP 429 endpoint at /http_429/ that simulates\n+# rate limiting. The endpoint format is:\n+#   /http_429/<test-context>/<retry-after-value>/<repo-path>\n+# The http-429.sh script (in t/lib-httpd) returns a 429 response with the\n+# specified Retry-After header on the first request for each test context,\n+# then forwards subsequent requests to git-http-backend. Each test context\n+# is isolated, allowing multiple tests to run independently.\n+\n+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n+\t# Set maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should fail immediately without any retry attempt\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retries-disabled/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message in stderr)\n+\ttest_grep ! -i \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 permanent should fail after max retries' '\n+\t# Enable retries with a limit\n+\ttest_config http.maxRetries 2 &&\n+\n+\t# Git should retry but eventually fail when 429 persists\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/permanent-fail/permanent/repo.git\" 2>err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry after receiving 429 and eventually succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-succeeds/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\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+\ttest_config http.retryAfter 1 &&\n+\n+\t# Git should retry using configured default and succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/no-retry-after-header/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 retry delays are respected' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Time the operation - it should take at least 2 seconds due to retry delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Verify it took at least 2 seconds (allowing some tolerance)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n+\t# Configure max retry time to 3 seconds (much less than requested 100)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 3 &&\n+\n+\t# Should fail immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n+\t# Test misconfiguration: retryAfter > maxRetryTime\n+\t# Configure retryAfter larger than maxRetryTime\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 100 &&\n+\ttest_config http.maxRetryTime 5 &&\n+\n+\t# Should fail immediately with configuration error\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n+\t# Test HTTP-date format (RFC 2822) in Retry-After header\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 2)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should parse the HTTP-date and retry after the delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (allowing tolerance for processing time)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 200)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Configure max retry time much less than the 200 second delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 10 &&\n+\n+\t# Should fail immediately without waiting 200 seconds\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 200 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tpast_time=$((now - 10)) &&\n+\traw=$(test-tool date show:rfc2822 $past_time) &&\n+\tpast_date=\"${raw#* -> }\" &&\n+\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should complete quickly (less than 2 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 2 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n+\t# Configure default retry-after\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should use configured default (1 second) since header is invalid\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (the configured default)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 will not be retried without config' '\n+\t# Default config means http.maxRetries=0 (retries disabled)\n+\t# When 429 is received, it should fail immediately without retry\n+\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n+\n+\t# Should fail immediately without retry\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/no-retry-without-config/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message)\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\n+\t# Should get 429 error\n+\ttest_grep \"429\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n+\t# Configure retryAfter to 10 seconds\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 10 &&\n+\n+\t# Override with environment variable to 1 second\n+\tstart=$(test-tool date getnanos) &&\n+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should use env var (1 second), not config (10 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n+\t# Configure maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Override with environment variable to enable retries\n+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/http_429/env-max-retries-override/1/repo.git\" >output 2>err &&\n+\n+\t# Should retry (env var enables it despite config saying disabled)\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n+\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 100 &&\n+\n+\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'verify normal repository access still works' '\n+\tgit ls-remote \"$HTTPD_URL/smart/repo.git\" >output &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"536894","messageId":"xmqq5x7nknrd.fsf@gitster.g","threadId":"64535","inReplyTo":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","subject":"Re: [PATCH v5 0/4] http: add support for HTTP 429 rate limit retries","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-02-24T00:07:18Z","receivedAt":"2026-02-24T00:07:21Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"\"Vaidas Pilkauskas via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> Changes since v4:\n>\n>  * fix only strbuf_attach() calls which don't need reallocation\n>  * remove patch, which enforces strbuf_attach() contract via BUG()\n> ...\n> Vaidas Pilkauskas (4):\n>   strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n>   strbuf_attach: fix call sites to pass correct alloc\n>   remote-curl: introduce show_http_message_fatal() helper\n\nThese three patches looked quite reasonable to me.\n\n>   http: add support for HTTP 429 rate limit retries\n\nI'd feel comfortable to see somebody more familiar with the HTTP\ntransport code base to take a look at this step before we declare\nvictory.\n\nThanks.\n"},{"id":"538339","messageId":"xmqq4imo1sse.fsf@gitster.g","threadId":"64535","inReplyTo":"xmqq5x7nknrd.fsf@gitster.g","subject":"Re: [PATCH v5 0/4] http: add support for HTTP 429 rate limit retries","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-09T23:34:25Z","receivedAt":"2026-03-09T23:34:27Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> \"Vaidas Pilkauskas via GitGitGadget\" <gitgitgadget@gmail.com>\n> writes:\n>\n>> Changes since v4:\n>>\n>>  * fix only strbuf_attach() calls which don't need reallocation\n>>  * remove patch, which enforces strbuf_attach() contract via BUG()\n>> ...\n>> Vaidas Pilkauskas (4):\n>>   strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n>>   strbuf_attach: fix call sites to pass correct alloc\n>>   remote-curl: introduce show_http_message_fatal() helper\n>\n> These three patches looked quite reasonable to me.\n>\n>>   http: add support for HTTP 429 rate limit retries\n>\n> I'd feel comfortable to see somebody more familiar with the HTTP\n> transport code base to take a look at this step before we declare\n> victory.\n\nAny volunteers?\n\nThanks.\n"},{"id":"538484","messageId":"20260310174447.GA589835@coredump.intra.peff.net","threadId":"64535","inReplyTo":"973703e9ddedb67daa946208ef7edb9eb50425a7.1771856405.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v5 3/4] remote-curl: introduce show_http_message_fatal() helper","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-03-10T17:44:47Z","receivedAt":"2026-03-10T17:44:49Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Mon, Feb 23, 2026 at 02:20:04PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n\n> From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n> \n> Several code paths in remote-curl.c follow the same pattern of calling\n> show_http_message() to display server error messages followed by die()\n> to terminate with an error. This duplication makes the code more verbose\n> and harder to maintain.\n> \n> Introduce a new show_http_message_fatal() helper function that combines\n> these two operations. This function:\n> \n> 1. Displays any HTTP error message from the server via show_http_message()\n> 2. Calls die() with the provided error message\n> 3. Returns NORETURN to help the compiler with control flow analysis\n> \n> Refactor existing call sites in remote-curl.c to use this new helper,\n> reducing code duplication and improving readability. This pattern will\n> also be used by upcoming HTTP 429 rate limiting support.\n\nI'm unconvinced that this actually makes things simpler. It doesn't save\nany lines, the show_http_message() becomes less flexible, and we now\nencode die-logic like the numeric code for exit() in the curl code.\n\n> +static NORETURN void show_http_message_fatal(struct strbuf *type, struct strbuf *charset,\n> +\t\t\t\t    struct strbuf *msg, const char *fmt, ...)\n>  {\n>  \tconst char *p, *eol;\n> +\tva_list ap;\n> +\treport_fn die_message_routine = get_die_message_routine();\n\nIf we got the die_routine here and not the die_message_routine, we\nwouldn't have to exit() ourselves. But sadly there is not a\nget_die_routine() helper yet, so we'd have to add one.\n\n>  \t/*\n>  \t * We only show text/plain parts, as other types are likely\n>  \t * to be ugly to look at on the user's terminal.\n>  \t */\n>  \tif (strcmp(type->buf, \"text/plain\"))\n> -\t\treturn -1;\n> +\t\tgoto out;\n>  \tif (charset->len)\n>  \t\tstrbuf_reencode(msg, charset->buf, get_log_output_encoding());\n>  \n>  \tstrbuf_trim(msg);\n>  \tif (!msg->len)\n> -\t\treturn -1;\n> +\t\tgoto out;\n>  \n>  \tp = msg->buf;\n>  \tdo {\n\nOK, we jump to the end since now we have to make sure to hit the exit\nbits.\n\n> @@ -391,7 +393,16 @@ static int show_http_message(struct strbuf *type, struct strbuf *charset,\n>  \t\tfprintf(stderr, \"remote: %.*s\\n\", (int)(eol - p), p);\n>  \t\tp = eol + 1;\n>  \t} while(*eol);\n> -\treturn 0;\n> +\n> +out:\n> +\tstrbuf_release(type);\n> +\tstrbuf_release(charset);\n> +\tstrbuf_release(msg);\n\nThis cleanup seems pointless. These strbufs came from the caller, so\nthey are not our responsibility to release. But also, we're about to\ncall die(), so there is no need to clean them up.\n\n> +\tva_start(ap, fmt);\n> +\tdie_message_routine(fmt, ap);\n> +\tva_end(ap);\n> +\texit(128);\n\nThis part is the advertised change for the function, which looks correct.\n\n>  static int get_protocol_http_header(enum protocol_version version,\n> @@ -518,21 +529,21 @@ static struct discovery *discover_refs(const char *service, int for_push)\n>  \tcase HTTP_OK:\n>  \t\tbreak;\n>  \tcase HTTP_MISSING_TARGET:\n> -\t\tshow_http_message(&type, &charset, &buffer);\n> -\t\tdie(_(\"repository '%s' not found\"),\n> -\t\t    transport_anonymize_url(url.buf));\n> +\t\tshow_http_message_fatal(&type, &charset, &buffer,\n> +\t\t\t\t\t_(\"repository '%s' not found\"),\n> +\t\t\t\t\ttransport_anonymize_url(url.buf));\n\nSo this is what the refactoring bought us: we trade 3 lines for 3 lines.  ;)\n\nIf we could roll in policy decisions like anonymizing the URL, or\nshowing the curl_errorstr, I think it might be worth it. But we can't do\nthat easily because they are varargs for the %s format.\n\nIMHO we should just drop this patch, and patch 4 should do the usual\nshow_http_message() and die().\n\n-Peff\n"},{"id":"538507","messageId":"20260310190732.GB589835@coredump.intra.peff.net","threadId":"64535","inReplyTo":"bfee1f10c0dc3fe3bd4bb5ed87bf380cc6798968.1771856405.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v5 4/4] http: add support for HTTP 429 rate limit retries","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-03-10T19:07:32Z","receivedAt":"2026-03-10T19:07:40Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Mon, Feb 23, 2026 at 02:20:05PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n\n> @@ -257,6 +264,50 @@ static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n>  \t\tgoto exit;\n>  \t}\n>  \n> +#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n> +\t/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */\n> +\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n> +\t\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n> +\n> +\t\tstrbuf_add(&buf, val, val_len);\n> +\t\tstrbuf_trim(&buf);\n> +\n> +\t\tif (slot && slot->results) {\n> +\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n> +\t\t\tchar *endptr;\n> +\t\t\tlong retry_after;\n> +\n> +\t\t\terrno = 0;\n> +\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n> +\n> +\t\t/* Check if it's a valid integer (delay-seconds format) */\n> +\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n> +\t\t    errno != ERANGE && retry_after >= 0) {\n> +\t\t\tslot->results->retry_after = retry_after;\n> +\t\t} else {\n> +\t\t\t\t/* Try parsing as HTTP-date format */\n> +\t\t\t\ttimestamp_t timestamp;\n> +\t\t\t\tint offset;\n> +\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n> +\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n> +\t\t\t\t\ttimestamp_t now = time(NULL);\n> +\t\t\t\t\tif (timestamp > now) {\n> +\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n> +\t\t\t\t\t} else {\n> +\t\t\t\t\t\t/* Past date means retry immediately */\n> +\t\t\t\t\t\tslot->results->retry_after = 0;\n> +\t\t\t\t\t}\n> +\t\t\t\t} else {\n> +\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n> +\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n> +\t\t\t\t}\n> +\t\t\t}\n> +\t\t}\n> +\n> +\t\tgoto exit;\n> +\t}\n> +#endif\n> +\n>  \t/*\n>  \t * This line could be a continuation of the previously matched header\n>  \t * field. If this is the case then we should append this value to the\n\nI don't think this meshes well with the existing code for parsing\nwww-authenticate, especially with respect to header continuation:\n\n  1. If we see continuation like \"Retry-After:\\n  <date>\", we won't find\n     <date>. Instead, we'll just think it's blank (or worse, do a\n     partial parse if the line break happens at whitespace in the middle\n     of the date).\n\n  2. We don't reset http_auth.header_is_last_match, so\n     \"WWW-Authenticate: foo\\nRetry-After:\\n  <date>\" will attribute\n     <date> to the WWW-Authenticate header.\n\n  3. We don't clear the value like we do for www-authenticate headers,\n     as we do in the lower code:\n\n          /*\n           * If this is a HTTP status line and not a header field, this signals\n           * a different HTTP response. libcurl writes all the output of all\n           * response headers of all responses, including redirects.\n           * We only care about the last HTTP request response's headers so clear\n           * the existing array.\n           */\n          if (skip_iprefix_mem(ptr, size, \"http/\", &val, &val_len))\n                  strvec_clear(values);\n\n     It's probably unlikely to get a retry-after _and_ a redirect in\n     practice, though.\n\nI suspect these are all quite uncommon cases, but it worries me a little\nthat a malicious response could inject values into the wrong header.\nAnd worse, that these cases will go largely untested, as most developers\nhave recent enough versions of libcurl.\n\nCould we drop this hunk entirely, and just document that the Retry-After\nfeature only works if you have a recent version of libcurl? Curl 7.66.0\nis almost 7 years old now (and you'd still get manual-timed retries if\nyou configure them).\n\n> @@ -342,6 +393,17 @@ static void finish_active_slot(struct active_request_slot *slot)\n>  \n>  \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n>  \t\t\t&slot->results->http_connectcode);\n> +\n> +#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n> +\t\tif (slot->results->http_code == 429) {\n> +\t\t\tcurl_off_t retry_after;\n> +\t\t\tCURLcode res = curl_easy_getinfo(slot->curl,\n> +\t\t\t\t\t\t\t  CURLINFO_RETRY_AFTER,\n> +\t\t\t\t\t\t\t  &retry_after);\n> +\t\t\tif (res == CURLE_OK && retry_after > 0)\n> +\t\t\t\tslot->results->retry_after = (long)retry_after;\n> +\t\t}\n> +#endif\n\nThis hunk makes sense. It is a little funny that we cast to long after\ngetting a curl_off_t, but I suspect we have to cast somewhere unless we\nwant to consistently pass around a curl_off_t (including via\nhttp_request).\n\nThough since it seems like http_request() is the only code path that\nhandles retries, do we need to grab it here in finish_active_slot()?\nI.e., would it make more sense to do it in http_request() where we are\ncopying it out from the results field anyway?\n\nPushing it down to this level would make sense if we wanted to handle\nretries on other requests outside of http_request (like dumb-http walker\nrequest), but I don't think your patch does that.\n\n> @@ -1886,6 +1971,9 @@ int run_one_slot(struct active_request_slot *slot,\n>  \t\t struct slot_results *results)\n>  {\n>  \tslot->results = results;\n> +\t/* Initialize retry_after to -1 (not set) */\n> +\tresults->retry_after = -1;\n> +\n\nThis is an unusual spot for per-request setup. Usually this happens in\nget_active_slot(), which is called before making a request.\n\nI think you are putting it here because we will make several requests on\nthe same handle via http_request_recoverable(). But in that case, would\nsetting it there make more sense? In fact, we seem to _reset_ it there\nalready. \n\n>  static int http_request(const char *url,\n>  \t\t\tvoid *result, int target,\n> -\t\t\tconst struct http_get_options *options)\n> +\t\t\tconst struct http_get_options *options,\n> +\t\t\tlong *retry_after_out)\n>  {\n\nWhy add this as a new argument when we already have many other optional\nout-parameters in http_get_options? I.e., I'd have expected this:\n\ndiff --git a/http.h b/http.h\nindex eb40456450..4e36e41432 100644\n--- a/http.h\n+++ b/http.h\n@@ -155,12 +155,14 @@ struct http_get_options {\n \t/*\n \t * If not NULL, contains additional HTTP headers to be sent with the\n \t * request. The strings in the list must not be freed until after the\n \t * request has completed.\n \t */\n \tstruct string_list *extra_headers;\n+\n+\tlong *retry_after;\n };\n \n /* Return values for http_get_*() */\n #define HTTP_OK\t\t\t0\n #define HTTP_MISSING_TARGET\t1\n #define HTTP_ERROR\t\t2\n\n\nWe could possibly even just make it a regular \"long\", not a pointer. The\nreason the other fields in http_get_options are pointers is that we only\nwant to fill them in if the caller is interested, because:\n\n  1. There is a cost to getting/computing the information.\n\n  2. They allocate memory, so the caller has to know to clean them up.\n\nBut here we just have a plain integer, so we could just always assign\nit. It would mean dropping the \"const\" from the struct parameter, but I\nthink that's fine.\n\n\nLooking further in the patch, I guess one reason is that the retry stuff\nis handled internally by http_request(), so a caller never needs or\nwants to see it. I wonder if that is always true, though. For example,\nwhen we print the rate-limited message at the top-level of\nremote-curl.c, might it be useful for us to mention the retry-after field?\n\nIf you want retries to work for all calls, we might have to use a dummy\nhttp_get_options struct. But I think that would actually make the code\ncleaner in general (no more \"if (options && options->effective_url)\"; it\nwould just \"if (options->effective_url)\".\n\n> @@ -2148,7 +2237,8 @@ static int http_request(const char *url,\n>  \t\t\t\t\t fwrite_buffer);\n>  \t}\n>  \n> -\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n> +\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n> +\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n\nThis hunk goes away if we drop the fwrite_headers() bit.\n\n> +static long handle_rate_limit_retry(int *rate_limit_retries, long slot_retry_after)\n> [...]\n> +\t/* Use the slot-specific retry_after value or configured default */\n> +\tif (slot_retry_after >= 0) {\n> +\t\t/* Check if retry delay exceeds maximum allowed */\n> +\t\tif (slot_retry_after > http_max_retry_time) {\n> +\t\t\terror(_(\"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"),\n> +\t\t\t      slot_retry_after, http_max_retry_time);\n> +\t\t\ttrace2_data_string(\"http\", the_repository,\n> +\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n> +\t\t\ttrace2_data_intmax(\"http\", the_repository,\n> +\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n> +\t\t\treturn -1;\n> +\t\t}\n> +\t\treturn slot_retry_after;\n\nOK. I had imagine that the max-time would clamp how long we were willing\nto wait, but instead we just bail. I guess what you have is probably more\nfriendly to a server that says \"please try again after 600 seconds\" as\nopposed to trying again faster than they'd prefer.\n\n> +\t} else {\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> +\t\t\t      http_retry_after, http_max_retry_time);\n> +\t\t\ttrace2_data_string(\"http\", the_repository,\n> +\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n> +\t\t\treturn -1;\n> +\t\t}\n\nAnd this one is a misconfiguration on the part of the user, so it should\ncertainly be an error. ;)\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>  {\n>  \tint i = 3;\n>  \tint ret;\n> +\tint rate_limit_retries = http_max_retries;\n> +\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n\nHmm, what is \"i\" here? Previously we used it to avoid REAUTH looping too\nmany times:\n\n> -\twhile (ret == HTTP_REAUTH && --i) {\n> +\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\n\nBut now we are looping on both rate-limits and auth. If max_retries is\ngreater than 2, won't we bail even though there are retries left?\n\nAlso, what if we retry once due to RATE_LIMITED, but then need multiple\nattempts to do auth? It feels like we should have two separate counters:\n\n  while ((ret == HTTP_REAUTH && --i) ||\n         (ret == HTTP_RATE_LIMITED && --rate_limit_retries)\n\nSide note: I think I am reading the \"--i\" count right. It feels like it\nwould be simpler as \"i--\", but I guess it is counting the initial\nhttp_request() we made. I suspect this might make more sense as a\ndo-while loop, but it is hairy enough that trying to refactor it further\nmight be risky.\n\n> @@ -2301,10 +2454,23 @@ static int http_request_reauth(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(&rate_limit_retries, slot_retry_after);\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\twarning(_(\"rate limited, waiting %ld seconds before retry\"), 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\tslot_retry_after = -1; /* Reset after use */\n> +\t\t} else if (ret == HTTP_REAUTH) {\n> +\t\t\tcredential_fill(the_repository, &http_auth, 1);\n> +\t\t}\n\nOK. In the loop condition I suggested above, we decrement\nrate_limit_retries there. But obviously it is also happening here via\nhandle_rate_limit_retry(), so it has to be one or the other.\n\nI wondered about the case where we are out of retries, and whether we\nmight sleep before bailing. But that function checks that case and\nreturns -1, so we'd bail immediately. Good.\n\n> +test_expect_success 'HTTP 429 retry delays are respected' '\n> +\t# Enable retries\n> +\ttest_config http.maxRetries 3 &&\n> +\n> +\t# Time the operation - it should take at least 2 seconds due to retry delay\n> +\tstart=$(test-tool date getnanos) &&\n> +\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n> +\tduration=$(test-tool date getnanos $start) &&\n> +\n> +\t# Verify it took at least 2 seconds (allowing some tolerance)\n> +\tduration_int=${duration%.*} &&\n> +\ttest \"$duration_int\" -ge 1 &&\n> +\ttest_grep \"refs/heads/\" output\n> +'\n\nNice, the duration-checking here looks nice and simple. The 2-second\ncheck here should be non-racy, since we'll wait at least that long. We\nmay get a false-success on a heavily-loaded system, but that's OK.\n\n> +test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n> +\t# Configure max retry time to 3 seconds (much less than requested 100)\n> +\ttest_config http.maxRetries 3 &&\n> +\ttest_config http.maxRetryTime 3 &&\n> +\n> +\t# Should fail immediately without waiting\n> +\tstart=$(test-tool date getnanos) &&\n> +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n> +\tduration=$(test-tool date getnanos $start) &&\n> +\n> +\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n> +\tduration_int=${duration%.*} &&\n> +\ttest \"$duration_int\" -lt 2 &&\n> +\ttest_grep \"greater than http.maxRetryTime\" err\n> +'\n\nThis one is the opposite, though. On a heavily loaded system, we may get\na false failure if the test takes longer than 2s to run. Can we bump\nthis to something much less likely to trigger, like 99? Or even 100\nwould work, I'd think, since we know we'll wait at least 100 seconds if\nwe actually tried to use that retry-time.\n\n> +test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n> +\t# Test misconfiguration: retryAfter > maxRetryTime\n> +\t# Configure retryAfter larger than maxRetryTime\n> +\ttest_config http.maxRetries 3 &&\n> +\ttest_config http.retryAfter 100 &&\n> +\ttest_config http.maxRetryTime 5 &&\n> +\n> +\t# Should fail immediately with configuration error\n> +\tstart=$(test-tool date getnanos) &&\n> +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n> +\tduration=$(test-tool date getnanos $start) &&\n> +\n> +\t# Should fail quickly\n> +\tduration_int=${duration%.*} &&\n> +\ttest \"$duration_int\" -lt 2 &&\n> +\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n> +'\n\nSame here; the key thing is that we don't wait 100 seconds, but we might\naccidentally take 2 seconds on a slow system.\n\nI think there are a few more below, just looking for \"-lt\".\n\n-Peff\n"},{"id":"538508","messageId":"20260310191019.GA589481@coredump.intra.peff.net","threadId":"64535","inReplyTo":"xmqq4imo1sse.fsf@gitster.g","subject":"Re: [PATCH v5 0/4] http: add support for HTTP 429 rate limit retries","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-03-10T19:10:19Z","receivedAt":"2026-03-10T19:10:21Z","isPatch":true,"sender":{"key":"peff@peff.net","avatar":"https://avatars.githubusercontent.com/u/45925?v=4"},"body":"On Mon, Mar 09, 2026 at 04:34:25PM -0700, Junio C Hamano wrote:\n\n> Junio C Hamano <gitster@pobox.com> writes:\n> \n> > \"Vaidas Pilkauskas via GitGitGadget\" <gitgitgadget@gmail.com>\n> > writes:\n> >\n> >> Changes since v4:\n> >>\n> >>  * fix only strbuf_attach() calls which don't need reallocation\n> >>  * remove patch, which enforces strbuf_attach() contract via BUG()\n> >> ...\n> >> Vaidas Pilkauskas (4):\n> >>   strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n> >>   strbuf_attach: fix call sites to pass correct alloc\n> >>   remote-curl: introduce show_http_message_fatal() helper\n> >\n> > These three patches looked quite reasonable to me.\n> >\n> >>   http: add support for HTTP 429 rate limit retries\n> >\n> > I'd feel comfortable to see somebody more familiar with the HTTP\n> > transport code base to take a look at this step before we declare\n> > victory.\n> \n> Any volunteers?\n\nSorry, I'm way underwater on things I could/should be reviewing, and\nthis one was quite non-trivial. ;)\n\nI just left some comments. A lot of it was about how to more cleanly\nintegrate with the http code (which I admit is a mess, especially with\nrespect to which \"layer\" things should happen at). Some of that may be\ndebatable, though I hope we can make things a bit cleaner.\n\nBut I think there may be a logic error in how http_request_recoverable()\nloops, which certainly needs to be fixed.\n\n-Peff\n"},{"id":"538509","messageId":"xmqqsea7trv8.fsf@gitster.g","threadId":"64535","inReplyTo":"20260310191019.GA589481@coredump.intra.peff.net","subject":"Re: [PATCH v5 0/4] http: add support for HTTP 429 rate limit retries","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-10T19:19:07Z","receivedAt":"2026-03-10T19:19:09Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Jeff King <peff@peff.net> writes:\n\n> On Mon, Mar 09, 2026 at 04:34:25PM -0700, Junio C Hamano wrote:\n>\n>> Junio C Hamano <gitster@pobox.com> writes:\n>> \n>> > \"Vaidas Pilkauskas via GitGitGadget\" <gitgitgadget@gmail.com>\n>> > writes:\n>> >\n>> >> Changes since v4:\n>> >>\n>> >>  * fix only strbuf_attach() calls which don't need reallocation\n>> >>  * remove patch, which enforces strbuf_attach() contract via BUG()\n>> >> ...\n>> >> Vaidas Pilkauskas (4):\n>> >>   strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n>> >>   strbuf_attach: fix call sites to pass correct alloc\n>> >>   remote-curl: introduce show_http_message_fatal() helper\n>> >\n>> > These three patches looked quite reasonable to me.\n>> >\n>> >>   http: add support for HTTP 429 rate limit retries\n>> >\n>> > I'd feel comfortable to see somebody more familiar with the HTTP\n>> > transport code base to take a look at this step before we declare\n>> > victory.\n>> \n>> Any volunteers?\n>\n> Sorry, I'm way underwater on things I could/should be reviewing, and\n> this one was quite non-trivial. ;)\n>\n> I just left some comments. A lot of it was about how to more cleanly\n> integrate with the http code (which I admit is a mess, especially with\n> respect to which \"layer\" things should happen at). Some of that may be\n> debatable, though I hope we can make things a bit cleaner.\n>\n> But I think there may be a logic error in how http_request_recoverable()\n> loops, which certainly needs to be fixed.\n\nThanks.\n\n"},{"id":"539214","messageId":"pull.2008.v6.git.1773752435.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v5.git.1771856405.gitgitgadget@gmail.com","subject":"[PATCH v6 0/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-03-17T13:00:32Z","receivedAt":"2026-03-17T13:00:40Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"Changes since v5:\n\n * drop show_http_message_fatal() preparation patch\n * drop fwrite_headers, restore fwrite_wwwauth\n * move CURLINFO_RETRY_AFTER from finish_active_slot to http_request\n * move retry_after=-1 init from run_one_slot to http_request\n * replace retry_after_out param with http_get_options field\n * fix loop counter: separate REAUTH and RATE_LIMITED counters\n * fix racy -lt 2 timing bounds in tests\n\nChanges since v4:\n\n * fix only strbuf_attach() calls which don't need reallocation\n * remove patch, which enforces strbuf_attach() contract via BUG()\n\nChanges since v3:\n\n * Clean up of all strbuf_attach() call sites\n\n * Add strbuf_attach() contract enforcement via BUG()\n\nChanges since v2:\n\n * New preparatory patch: Introduced show_http_message_fatal() helper\n   function to reduce code duplication in remote-curl.c (suggested by Taylor\n   Blau)\n\n * Removed specific HTTP_RATE_LIMITED error handling from http-push.c and\n   http-walker.c for the obsolete \"dumb\" protocol, allowing generic error\n   handling to take over (suggested by Jeff King)\n\n * Added support for CURLINFO_RETRY_AFTER on curl >= 7.66.0, falling back to\n   manual header parsing on older versions\n\n * Simplified retry/delay architecture: replaced complex non-blocking\n   \"delayed slot\" mechanism with simple blocking sleep() call in the retry\n   loop, removing ~66 lines of timing logic (suggested by Jeff King)\n\n * Fixed Retry-After: 0 handling to allow immediate retry as specified by\n   RFC 9110\n\n * Changed http.retryAfter default from -1 to 0, so Git will retry\n   immediately when encountering HTTP 429 without a Retry-After header,\n   rather than failing with a configuration error\n\n * Improved error messages: shortened to be more concise\n\n * Fixed coding style issues: removed unnecessary curly braces, changed x ==\n   0 to !x (per CodingGuidelines)\n\n * Improved test portability: replaced non-portable date(1) commands with\n   test-tool date, added nanosecond-precision timing with getnanos, replaced\n   cut(1) with POSIX shell parameter expansion\n\n * Split out strbuf.c bugfix into separate preparatory patch (the\n   strbuf_reencode alloc size fix is unrelated to HTTP 429 support)\n\n * Squashed separate trace2 logging patch into main HTTP 429 retry support\n   commit\n\n * Kept header_is_last_match assignment for Retry-After to prevent incorrect\n   handling of HTTP header continuation lines\n\nThe implementation includes:\n\n 1. A bug fix in strbuf_reencode() that corrects the allocation size passed\n    to strbuf_attach(), passing len+1 instead of len so that the existing\n    buffer is reused rather than immediately reallocated.\n\n 2. A cleanup of strbuf_attach() call sites that were passing alloc == len,\n    leaving no room for the NUL terminator. Sites with a\n    known-NUL-terminated buffer now pass len+1; sites where the source\n    buffer has no trailing NUL (ll_merge output) are converted to use\n    strbuf_add() instead.\n\n 3. The main feature: HTTP 429 retry logic with support for the Retry-After\n    header (both delay-seconds and HTTP-date formats), configurable via\n    http.maxRetries, http.retryAfter, and http.maxRetryTime options. If any\n    computed delay exceeds maxRetryTime the request fails immediately with a\n    clear diagnostic rather than capping and retrying silently.\n\nVaidas Pilkauskas (3):\n  strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n  strbuf_attach: fix call sites to pass correct alloc\n  http: add support for HTTP 429 rate limit retries\n\n Documentation/config/http.adoc |  26 ++++\n builtin/am.c                   |   2 +-\n builtin/fast-import.c          |   2 +-\n git-curl-compat.h              |   8 +\n http.c                         | 144 +++++++++++++++---\n http.h                         |   9 ++\n mailinfo.c                     |   2 +-\n refs/files-backend.c           |   2 +-\n remote-curl.c                  |  11 ++\n strbuf.c                       |   2 +-\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n trailer.c                      |   2 +-\n 16 files changed, 557 insertions(+), 27 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\n\nbase-commit: ca1db8a0f7dc0dbea892e99f5b37c5fe5861be71\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-2008%2Fvaidas-shopify%2Fretry-after-v6\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2008/vaidas-shopify/retry-after-v6\nPull-Request: https://github.com/gitgitgadget/git/pull/2008\n\nRange-diff vs v5:\n\n 1:  7ec2d66447 = 1:  6e76be1d85 strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()\n 2:  3e0b78cfb6 = 2:  0dc214d3c2 strbuf_attach: fix call sites to pass correct alloc\n 3:  973703e9dd < -:  ---------- remote-curl: introduce show_http_message_fatal() helper\n 4:  bfee1f10c0 ! 3:  3418f4553d http: add support for HTTP 429 rate limit retries\n     @@ Documentation/config/http.adoc: http.keepAliveCount::\n      +\tDefault 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     -+\tpresent, its value takes precedence over this setting. Can be\n     -+\toverridden by the `GIT_HTTP_RETRY_AFTER` environment variable.\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     ++\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      +\n      +http.maxRetries::\n     @@ http.c: static inline int is_hdr_continuation(const char *ptr, const size_t size\n       }\n       \n      -static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n     -+static size_t fwrite_headers(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n     ++static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n       {\n       \tsize_t size = eltsize * nmemb;\n       \tstruct strvec *values = &http_auth.wwwauth_headers;\n     -@@ http.c: static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UN\n     - \t\tgoto exit;\n     - \t}\n     - \n     -+#ifndef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n     -+\t/* Parse Retry-After header for rate limiting (for curl < 7.66.0) */\n     -+\tif (skip_iprefix_mem(ptr, size, \"retry-after:\", &val, &val_len)) {\n     -+\t\tstruct active_request_slot *slot = (struct active_request_slot *)p;\n     -+\n     -+\t\tstrbuf_add(&buf, val, val_len);\n     -+\t\tstrbuf_trim(&buf);\n     -+\n     -+\t\tif (slot && slot->results) {\n     -+\t\t\t/* Parse the retry-after value (delay-seconds or HTTP-date) */\n     -+\t\t\tchar *endptr;\n     -+\t\t\tlong retry_after;\n     -+\n     -+\t\t\terrno = 0;\n     -+\t\t\tretry_after = strtol(buf.buf, &endptr, 10);\n     -+\n     -+\t\t/* Check if it's a valid integer (delay-seconds format) */\n     -+\t\tif (endptr != buf.buf && *endptr == '\\0' &&\n     -+\t\t    errno != ERANGE && retry_after >= 0) {\n     -+\t\t\tslot->results->retry_after = retry_after;\n     -+\t\t} else {\n     -+\t\t\t\t/* Try parsing as HTTP-date format */\n     -+\t\t\t\ttimestamp_t timestamp;\n     -+\t\t\t\tint offset;\n     -+\t\t\t\tif (!parse_date_basic(buf.buf, &timestamp, &offset)) {\n     -+\t\t\t\t\t/* Successfully parsed as date, calculate delay from now */\n     -+\t\t\t\t\ttimestamp_t now = time(NULL);\n     -+\t\t\t\t\tif (timestamp > now) {\n     -+\t\t\t\t\t\tslot->results->retry_after = (long)(timestamp - now);\n     -+\t\t\t\t\t} else {\n     -+\t\t\t\t\t\t/* Past date means retry immediately */\n     -+\t\t\t\t\t\tslot->results->retry_after = 0;\n     -+\t\t\t\t\t}\n     -+\t\t\t\t} else {\n     -+\t\t\t\t\t/* Failed to parse as either delay-seconds or HTTP-date */\n     -+\t\t\t\t\twarning(_(\"unable to parse Retry-After header value: '%s'\"), buf.buf);\n     -+\t\t\t\t}\n     -+\t\t\t}\n     -+\t\t}\n     -+\n     -+\t\tgoto exit;\n     -+\t}\n     -+#endif\n     -+\n     - \t/*\n     - \t * This line could be a continuation of the previously matched header\n     - \t * field. If this is the case then we should append this value to the\n     -@@ http.c: static void finish_active_slot(struct active_request_slot *slot)\n     - \n     - \t\tcurl_easy_getinfo(slot->curl, CURLINFO_HTTP_CONNECTCODE,\n     - \t\t\t&slot->results->http_connectcode);\n     -+\n     -+#ifdef GIT_CURL_HAVE_CURLINFO_RETRY_AFTER\n     -+\t\tif (slot->results->http_code == 429) {\n     -+\t\t\tcurl_off_t retry_after;\n     -+\t\t\tCURLcode res = curl_easy_getinfo(slot->curl,\n     -+\t\t\t\t\t\t\t  CURLINFO_RETRY_AFTER,\n     -+\t\t\t\t\t\t\t  &retry_after);\n     -+\t\t\tif (res == 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      @@ http.c: static int http_options(const char *var, const char *value,\n       \t\treturn 0;\n       \t}\n     @@ http.c: int run_one_slot(struct active_request_slot *slot,\n       \t\t struct slot_results *results)\n       {\n       \tslot->results = results;\n     -+\t/* Initialize retry_after to -1 (not set) */\n     -+\tresults->retry_after = -1;\n      +\n       \tif (!start_active_slot(slot)) {\n       \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n     @@ http.c: static void http_opt_request_remainder(CURL *curl, off_t pos)\n       static int http_request(const char *url,\n       \t\t\tvoid *result, int target,\n      -\t\t\tconst struct http_get_options *options)\n     -+\t\t\tconst struct http_get_options *options,\n     -+\t\t\tlong *retry_after_out)\n     ++\t\t\tstruct http_get_options *options)\n       {\n       \tstruct active_request_slot *slot;\n     - \tstruct slot_results results;\n     +-\tstruct slot_results results;\n     ++\tstruct slot_results results = { .retry_after = -1 };\n     + \tstruct curl_slist *headers = http_copy_default_headers();\n     + \tstruct strbuf buf = STRBUF_INIT;\n     + \tconst char *accept_language;\n      @@ http.c: static int http_request(const char *url,\n     - \t\t\t\t\t fwrite_buffer);\n     - \t}\n     + \t\theaders = curl_slist_append(headers, accept_language);\n       \n     --\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_wwwauth);\n     -+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERFUNCTION, fwrite_headers);\n     -+\tcurl_easy_setopt(slot->curl, CURLOPT_HEADERDATA, slot);\n     + \tstrbuf_addstr(&buf, \"Pragma:\");\n     +-\tif (options && options->no_cache)\n     ++\tif (options->no_cache)\n     + \t\tstrbuf_addstr(&buf, \" no-cache\");\n     +-\tif (options && options->initial_request &&\n     ++\tif (options->initial_request &&\n     + \t    http_follow_config == HTTP_FOLLOW_INITIAL)\n     + \t\tcurl_easy_setopt(slot->curl, CURLOPT_FOLLOWLOCATION, 1L);\n       \n     - \taccept_language = http_get_accept_language_header();\n     + \theaders = curl_slist_append(headers, buf.buf);\n       \n     + \t/* Add additional headers here */\n     +-\tif (options && options->extra_headers) {\n     ++\tif (options->extra_headers) {\n     + \t\tconst struct string_list_item *item;\n     +-\t\tif (options && options->extra_headers) {\n     +-\t\t\tfor_each_string_list_item(item, options->extra_headers) {\n     +-\t\t\t\theaders = curl_slist_append(headers, item->string);\n     +-\t\t\t}\n     +-\t\t}\n     ++\t\tfor_each_string_list_item(item, options->extra_headers)\n     ++\t\t\theaders = curl_slist_append(headers, item->string);\n     + \t}\n     + \n     + \theaders = http_append_auth_header(&http_auth, headers);\n      @@ http.c: static int http_request(const char *url,\n       \n       \tret = run_one_slot(slot, &results);\n       \n     -+\t/* Store retry_after from slot results if output parameter provided */\n     -+\tif (retry_after_out)\n     -+\t\t*retry_after_out = results.retry_after;\n     +-\tif (options && options->content_type) {\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 && options->content_type) {\n     ++\tif (options->content_type) {\n       \t\tstruct strbuf raw = STRBUF_INIT;\n       \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n     + \t\textract_content_type(&raw, options->content_type,\n     +@@ http.c: static int http_request(const char *url,\n     + \t\tstrbuf_release(&raw);\n     + \t}\n     + \n     +-\tif (options && options->effective_url)\n     ++\tif (options->effective_url)\n     + \t\tcurlinfo_strbuf(slot->curl, CURLINFO_EFFECTIVE_URL,\n     + \t\t\t\toptions->effective_url);\n     + \n      @@ http.c: static int update_url_from_redirect(struct strbuf *base,\n       \treturn 1;\n       }\n       \n      -static int http_request_reauth(const char *url,\n      +/*\n     -+ * Handle rate limiting retry logic for HTTP 429 responses.\n     -+ * Returns a negative value if retries are exhausted or configuration is invalid,\n     -+ * otherwise returns the delay value (>= 0) to indicate the retry should proceed.\n     ++ * Compute the retry delay for an HTTP 429 response.\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(int *rate_limit_retries, long slot_retry_after)\n     ++static long handle_rate_limit_retry(long slot_retry_after)\n      +{\n     -+\tint retry_attempt = http_max_retries - *rate_limit_retries + 1;\n     -+\n     -+\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-attempt\",\n     -+\t\tretry_attempt);\n     -+\n     -+\tif (*rate_limit_retries <= 0) {\n     -+\t\t/* Retries are disabled or exhausted */\n     -+\t\tif (http_max_retries > 0) {\n     -+\t\t\terror(_(\"too many rate limit retries, giving up\"));\n     -+\t\t\ttrace2_data_string(\"http\", the_repository,\n     -+\t\t\t\t\t   \"http/429-error\", \"retries-exhausted\");\n     -+\t\t}\n     -+\t\treturn -1;\n     -+\t}\n     -+\n     -+\t(*rate_limit_retries)--;\n     -+\n      +\t/* Use the slot-specific retry_after value or configured default */\n      +\tif (slot_retry_after >= 0) {\n      +\t\t/* Check if retry delay exceeds maximum allowed */\n     @@ http.c: static int update_url_from_redirect(struct strbuf *base,\n       \t\t\t       void *result, int target,\n       \t\t\t       struct http_get_options *options)\n       {\n     ++\tstatic struct http_get_options empty_opts;\n       \tint i = 3;\n       \tint ret;\n      +\tint rate_limit_retries = http_max_retries;\n     -+\tlong slot_retry_after = -1; /* Per-slot retry_after value */\n     ++\n     ++\tif (!options)\n     ++\t\toptions = &empty_opts;\n       \n       \tif (always_auth_proactively())\n       \t\tcredential_fill(the_repository, &http_auth, 1);\n       \n     --\tret = http_request(url, result, target, options);\n     -+\tret = http_request(url, result, target, options, &slot_retry_after);\n     + \tret = http_request(url, result, target, options);\n       \n      -\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\n      +\tif (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)\n       \t\treturn ret;\n       \n     +-\tif (options && options->effective_url && options->base_url) {\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 && options->effective_url && options->base_url) {\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     + \t\t\tcredential_from_url(&http_auth, options->base_url->buf);\n      @@ http.c: static int http_request_reauth(const char *url,\n       \t\t}\n       \t}\n       \n      -\twhile (ret == HTTP_REAUTH && --i) {\n     -+\twhile ((ret == HTTP_REAUTH || ret == HTTP_RATE_LIMITED) && --i) {\n     ++\twhile ((ret == HTTP_REAUTH && --i) ||\n     ++\t       (ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {\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     @@ http.c: static int http_request_reauth(const char *url,\n       \t\tdefault:\n       \t\t\tBUG(\"Unknown http_request target\");\n       \t\t}\n     +-\n     +-\t\tcredential_fill(the_repository, &http_auth, 1);\n      +\t\tif (ret == HTTP_RATE_LIMITED) {\n     -+\t\t\tretry_delay = handle_rate_limit_retry(&rate_limit_retries, slot_retry_after);\n     ++\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after);\n      +\t\t\tif (retry_delay < 0)\n      +\t\t\t\treturn HTTP_ERROR;\n      +\n     @@ http.c: static int http_request_reauth(const char *url,\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\tslot_retry_after = -1; /* Reset after use */\n      +\t\t} else if (ret == HTTP_REAUTH) {\n      +\t\t\tcredential_fill(the_repository, &http_auth, 1);\n      +\t\t}\n       \n     --\t\tcredential_fill(the_repository, &http_auth, 1);\n     --\n     --\t\tret = http_request(url, result, target, options);\n     -+\t\tret = http_request(url, result, target, options, &slot_retry_after);\n     + \t\tret = http_request(url, result, target, options);\n       \t}\n     ++\tif (ret == HTTP_RATE_LIMITED) {\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}\n       \treturn ret;\n       }\n     + \n      @@ http.c: int http_get_strbuf(const char *url,\n       \t\t    struct strbuf *result,\n       \t\t    struct http_get_options *options)\n     @@ http.h: struct slot_results {\n       };\n       \n       struct active_request_slot {\n     +@@ http.h: struct http_get_options {\n     + \t * request has completed.\n     + \t */\n     + \tstruct string_list *extra_headers;\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 */\n     ++\tlong retry_after;\n     + };\n     + \n     + /* Return values for http_get_*() */\n      @@ http.h: struct http_get_options {\n       #define HTTP_REAUTH\t4\n       #define HTTP_NOAUTH\t5\n     @@ http.h: struct http_get_options {\n      \n       ## remote-curl.c ##\n      @@ remote-curl.c: static struct discovery *discover_refs(const char *service, int for_push)\n     - \t\tshow_http_message_fatal(&type, &charset, &buffer,\n     - \t\t\t\t\t_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     - \t\t\t\t\ttransport_anonymize_url(url.buf), curl_errorstr);\n     + \t\tshow_http_message(&type, &charset, &buffer);\n     + \t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n     + \t\t    transport_anonymize_url(url.buf), curl_errorstr);\n      +\tcase HTTP_RATE_LIMITED:\n     -+\t\tshow_http_message_fatal(&type, &charset, &buffer,\n     -+\t\t\t\t\t_(\"rate limited by '%s', please try again later\"),\n     -+\t\t\t\t\ttransport_anonymize_url(url.buf));\n     ++\t\tif (http_options.retry_after > 0) {\n     ++\t\t\tshow_http_message(&type, &charset, &buffer);\n     ++\t\t\tdie(_(\"rate limited by '%s', please try again in %ld seconds\"),\n     ++\t\t\t\ttransport_anonymize_url(url.buf),\n     ++\t\t\t\thttp_options.retry_after);\n     ++\t\t} else {\n     ++\t\t\tshow_http_message(&type, &charset, &buffer);\n     ++\t\t\tdie(_(\"rate limited by '%s', please try again later\"),\n     ++\t\t\t\ttransport_anonymize_url(url.buf));\n     ++\t\t}\n       \tdefault:\n     - \t\tshow_http_message_fatal(&type, &charset, &buffer,\n     - \t\t\t\t\t_(\"unable to access '%s': %s\"),\n     + \t\tshow_http_message(&type, &charset, &buffer);\n     + \t\tdie(_(\"unable to access '%s': %s\"),\n      \n       ## t/lib-httpd.sh ##\n      @@ t/lib-httpd.sh: prepare_httpd() {\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n      +\tduration=$(test-tool date getnanos $start) &&\n      +\n     -+\t# Should fail quickly (less than 2 seconds, no 100 second wait)\n     ++\t# Should fail quickly (no 100 second wait)\n      +\tduration_int=${duration%.*} &&\n     -+\ttest \"$duration_int\" -lt 2 &&\n     ++\ttest \"$duration_int\" -lt 99 &&\n      +\ttest_grep \"greater than http.maxRetryTime\" err\n      +'\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n      +\tduration=$(test-tool date getnanos $start) &&\n      +\n     -+\t# Should fail quickly\n     ++\t# Should fail quickly (no 100 second wait)\n      +\tduration_int=${duration%.*} &&\n     -+\ttest \"$duration_int\" -lt 2 &&\n     ++\ttest \"$duration_int\" -lt 99 &&\n      +\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n      +'\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\n      +\t# Should fail quickly (not wait 200 seconds)\n      +\tduration_int=${duration%.*} &&\n     -+\ttest \"$duration_int\" -lt 2 &&\n     ++\ttest \"$duration_int\" -lt 199 &&\n      +\ttest_grep \"http.maxRetryTime\" err\n      +'\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n      +\tduration=$(test-tool date getnanos $start) &&\n      +\n     -+\t# Should complete quickly (less than 2 seconds)\n     ++\t# Should complete quickly (no wait for a past-date Retry-After)\n      +\tduration_int=${duration%.*} &&\n     -+\ttest \"$duration_int\" -lt 2 &&\n     ++\ttest \"$duration_int\" -lt 5 &&\n      +\ttest_grep \"refs/heads/\" output\n      +'\n      +\n     @@ t/t5584-http-429-retry.sh (new)\n      +\n      +\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n      +\tduration_int=${duration%.*} &&\n     -+\ttest \"$duration_int\" -lt 5 &&\n     ++\ttest \"$duration_int\" -lt 49 &&\n      +\ttest_grep \"greater than http.maxRetryTime\" err\n      +'\n      +\n\n-- \ngitgitgadget\n"},{"id":"539215","messageId":"0dc214d3c2f185398b2ddb1816ea87d0d53474c7.1773752435.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v6.git.1773752435.gitgitgadget@gmail.com","subject":"[PATCH v6 2/3] strbuf_attach: fix call sites to pass correct alloc","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-03-17T13:00:34Z","receivedAt":"2026-03-17T13:00:43Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nstrbuf_attach(sb, buf, len, alloc) requires alloc > len (the buffer\nmust have at least len+1 bytes to hold the NUL). Several call sites\npassed alloc == len, relying on strbuf_grow(sb, 0) inside strbuf_attach\nto reallocate. Fix these in mailinfo, am, refs/files-backend,\nfast-import, and trailer by passing len+1 when the buffer is a\nNUL-terminated string (or from strbuf_detach).\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n builtin/am.c          | 2 +-\n builtin/fast-import.c | 2 +-\n mailinfo.c            | 2 +-\n refs/files-backend.c  | 2 +-\n trailer.c             | 2 +-\n 5 files changed, 5 insertions(+), 5 deletions(-)\n\ndiff --git a/builtin/am.c b/builtin/am.c\nindex e0c767e223..c439f868dc 100644\n--- a/builtin/am.c\n+++ b/builtin/am.c\n@@ -1188,7 +1188,7 @@ static void am_append_signoff(struct am_state *state)\n {\n \tstruct strbuf sb = STRBUF_INIT;\n \n-\tstrbuf_attach(&sb, state->msg, state->msg_len, state->msg_len);\n+\tstrbuf_attach(&sb, state->msg, state->msg_len, state->msg_len + 1);\n \tappend_signoff(&sb, 0, 0);\n \tstate->msg = strbuf_detach(&sb, &state->msg_len);\n }\ndiff --git a/builtin/fast-import.c b/builtin/fast-import.c\nindex a41f95191e..6593a71379 100644\n--- a/builtin/fast-import.c\n+++ b/builtin/fast-import.c\n@@ -3250,7 +3250,7 @@ static void cat_blob(struct object_entry *oe, struct object_id *oid)\n \tcat_blob_write(\"\\n\", 1);\n \tif (oe && oe->pack_id == pack_id) {\n \t\tlast_blob.offset = oe->idx.offset;\n-\t\tstrbuf_attach(&last_blob.data, buf, size, size);\n+\t\tstrbuf_attach(&last_blob.data, buf, size, size + 1);\n \t\tlast_blob.depth = oe->depth;\n \t} else\n \t\tfree(buf);\ndiff --git a/mailinfo.c b/mailinfo.c\nindex a2f06dbd96..13949ff31e 100644\n--- a/mailinfo.c\n+++ b/mailinfo.c\n@@ -470,7 +470,7 @@ static int convert_to_utf8(struct mailinfo *mi,\n \t\treturn error(\"cannot convert from %s to %s\",\n \t\t\t     charset, mi->metainfo_charset);\n \t}\n-\tstrbuf_attach(line, out, out_len, out_len);\n+\tstrbuf_attach(line, out, out_len, out_len + 1);\n \treturn 0;\n }\n \ndiff --git a/refs/files-backend.c b/refs/files-backend.c\nindex 7ce0d57478..0537a72b2a 100644\n--- a/refs/files-backend.c\n+++ b/refs/files-backend.c\n@@ -1813,7 +1813,7 @@ static int commit_ref(struct ref_lock *lock)\n \t\tsize_t len = strlen(path);\n \t\tstruct strbuf sb_path = STRBUF_INIT;\n \n-\t\tstrbuf_attach(&sb_path, path, len, len);\n+\t\tstrbuf_attach(&sb_path, path, len, len + 1);\n \n \t\t/*\n \t\t * If this fails, commit_lock_file() will also fail\ndiff --git a/trailer.c b/trailer.c\nindex 911a81ed99..3afe368db0 100644\n--- a/trailer.c\n+++ b/trailer.c\n@@ -1009,7 +1009,7 @@ static struct trailer_block *trailer_block_get(const struct process_trailer_opti\n \tfor (ptr = trailer_lines; *ptr; ptr++) {\n \t\tif (last && isspace((*ptr)->buf[0])) {\n \t\t\tstruct strbuf sb = STRBUF_INIT;\n-\t\t\tstrbuf_attach(&sb, *last, strlen(*last), strlen(*last));\n+\t\t\tstrbuf_attach(&sb, *last, strlen(*last), strlen(*last) + 1);\n \t\t\tstrbuf_addbuf(&sb, *ptr);\n \t\t\t*last = strbuf_detach(&sb, NULL);\n \t\t\tcontinue;\n-- \ngitgitgadget\n\n"},{"id":"539216","messageId":"3418f4553d246c797697c80f439c77fee293f7e0.1773752435.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v6.git.1773752435.gitgitgadget@gmail.com","subject":"[PATCH v6 3/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-03-17T13:00:35Z","receivedAt":"2026-03-17T13:00:46Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nAdd retry logic for HTTP 429 (Too Many Requests) responses to handle\nserver-side rate limiting gracefully. When Git's HTTP client receives\na 429 response, it can now automatically retry the request after an\nappropriate delay, respecting the server's rate limits.\n\nThe implementation supports the RFC-compliant Retry-After header in\nboth delay-seconds (integer) and HTTP-date (RFC 2822) formats. If a\npast date is provided, Git retries immediately without waiting.\n\nRetry behavior is controlled by three new configuration options\n(http.maxRetries, http.retryAfter, and http.maxRetryTime) which are\ndocumented in git-config(1).\n\nThe retry logic implements a fail-fast approach: if any delay\n(whether from server header or configuration) exceeds maxRetryTime,\nGit fails immediately with a clear error message rather than capping\nthe delay. This provides better visibility into rate limiting issues.\n\nThe implementation includes extensive test coverage for basic retry\nbehavior, Retry-After header formats (integer and HTTP-date),\nconfiguration combinations, maxRetryTime limits, invalid header\nhandling, environment variable overrides, and edge cases.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n Documentation/config/http.adoc |  26 ++++\n git-curl-compat.h              |   8 +\n http.c                         | 144 +++++++++++++++---\n http.h                         |   9 ++\n remote-curl.c                  |  11 ++\n t/lib-httpd.sh                 |   1 +\n t/lib-httpd/apache.conf        |   8 +\n t/lib-httpd/http-429.sh        |  98 ++++++++++++\n t/meson.build                  |   1 +\n t/t5584-http-429-retry.sh      | 266 +++++++++++++++++++++++++++++++++\n 10 files changed, 551 insertions(+), 21 deletions(-)\n create mode 100644 t/lib-httpd/http-429.sh\n create mode 100755 t/t5584-http-429-retry.sh\n\ndiff --git a/Documentation/config/http.adoc b/Documentation/config/http.adoc\nindex 9da5c298cc..849c89f36c 100644\n--- a/Documentation/config/http.adoc\n+++ b/Documentation/config/http.adoc\n@@ -315,6 +315,32 @@ http.keepAliveCount::\n \tunset, curl's default value is used. Can be overridden by the\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+\tHTTP 429 (Too Many Requests) without a Retry-After header.\n+\tDefaults to 0 (retry immediately). 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+\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+\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRIES` environment variable.\n+\tSee also `http.retryAfter` and `http.maxRetryTime`.\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+\tCan be overridden by the `GIT_HTTP_MAX_RETRY_TIME` environment\n+\tvariable. See also `http.retryAfter` and `http.maxRetries`.\n+\n http.noEPSV::\n \tA boolean which disables using of EPSV ftp command by curl.\n \tThis can be helpful with some \"poor\" ftp servers which don't\ndiff --git a/git-curl-compat.h b/git-curl-compat.h\nindex 659e5a3875..dccdd4d6e5 100644\n--- a/git-curl-compat.h\n+++ b/git-curl-compat.h\n@@ -37,6 +37,14 @@\n #define GIT_CURL_NEED_TRANSFER_ENCODING_HEADER\n #endif\n \n+/**\n+ * CURLINFO_RETRY_AFTER was added in 7.66.0, released in September 2019.\n+ * It allows curl to automatically parse Retry-After headers.\n+ */\n+#if LIBCURL_VERSION_NUM >= 0x074200\n+#define GIT_CURL_HAVE_CURLINFO_RETRY_AFTER 1\n+#endif\n+\n /**\n  * CURLOPT_PROTOCOLS_STR and CURLOPT_REDIR_PROTOCOLS_STR were added in 7.85.0,\n  * released in August 2022.\ndiff --git a/http.c b/http.c\nindex 8ea1b9d1f6..d8d016891b 100644\n--- a/http.c\n+++ b/http.c\n@@ -22,6 +22,8 @@\n #include \"object-file.h\"\n #include \"odb.h\"\n #include \"tempfile.h\"\n+#include \"date.h\"\n+#include \"trace2.h\"\n \n static struct trace_key trace_curl = TRACE_KEY_INIT(CURL);\n static int trace_curl_data = 1;\n@@ -149,6 +151,11 @@ static char *cached_accept_language;\n static char *http_ssl_backend;\n \n static int http_schannel_check_revoke = 1;\n+\n+static long http_retry_after = 0;\n+static long http_max_retries = 0;\n+static long http_max_retry_time = 300;\n+\n /*\n  * With the backend being set to `schannel`, setting sslCAinfo would override\n  * the Certificate Store in cURL v7.60.0 and later, which is not what we want\n@@ -209,7 +216,7 @@ static inline int is_hdr_continuation(const char *ptr, const size_t size)\n \treturn size && (*ptr == ' ' || *ptr == '\\t');\n }\n \n-static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n+static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n {\n \tsize_t size = eltsize * nmemb;\n \tstruct strvec *values = &http_auth.wwwauth_headers;\n@@ -575,6 +582,21 @@ static int http_options(const char *var, const char *value,\n \t\treturn 0;\n \t}\n \n+\tif (!strcmp(\"http.retryafter\", var)) {\n+\t\thttp_retry_after = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretries\", var)) {\n+\t\thttp_max_retries = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n+\tif (!strcmp(\"http.maxretrytime\", var)) {\n+\t\thttp_max_retry_time = git_config_int(var, value, ctx->kvi);\n+\t\treturn 0;\n+\t}\n+\n \t/* Fall back on the default ones */\n \treturn git_default_config(var, value, ctx, data);\n }\n@@ -1422,6 +1444,10 @@ void http_init(struct remote *remote, const char *url, int proactive_auth)\n \tset_long_from_env(&curl_tcp_keepintvl, \"GIT_TCP_KEEPINTVL\");\n \tset_long_from_env(&curl_tcp_keepcnt, \"GIT_TCP_KEEPCNT\");\n \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+\n \tcurl_default = get_curl_handle();\n }\n \n@@ -1871,6 +1897,10 @@ 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\ttrace2_data_intmax(\"http\", the_repository, \"http/429-retry-after\",\n+\t\t\tresults->retry_after);\n+\t\treturn HTTP_RATE_LIMITED;\n \t} else {\n \t\tif (results->http_connectcode == 407)\n \t\t\tcredential_reject(the_repository, &proxy_auth);\n@@ -1886,6 +1916,7 @@ int run_one_slot(struct active_request_slot *slot,\n \t\t struct slot_results *results)\n {\n \tslot->results = results;\n+\n \tif (!start_active_slot(slot)) {\n \t\txsnprintf(curl_errorstr, sizeof(curl_errorstr),\n \t\t\t  \"failed to start HTTP request\");\n@@ -2119,10 +2150,10 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n \n static int http_request(const char *url,\n \t\t\tvoid *result, int target,\n-\t\t\tconst struct http_get_options *options)\n+\t\t\tstruct http_get_options *options)\n {\n \tstruct active_request_slot *slot;\n-\tstruct slot_results results;\n+\tstruct slot_results results = { .retry_after = -1 };\n \tstruct curl_slist *headers = http_copy_default_headers();\n \tstruct strbuf buf = STRBUF_INIT;\n \tconst char *accept_language;\n@@ -2156,22 +2187,19 @@ static int http_request(const char *url,\n \t\theaders = curl_slist_append(headers, accept_language);\n \n \tstrbuf_addstr(&buf, \"Pragma:\");\n-\tif (options && options->no_cache)\n+\tif (options->no_cache)\n \t\tstrbuf_addstr(&buf, \" no-cache\");\n-\tif (options && options->initial_request &&\n+\tif (options->initial_request &&\n \t    http_follow_config == HTTP_FOLLOW_INITIAL)\n \t\tcurl_easy_setopt(slot->curl, CURLOPT_FOLLOWLOCATION, 1L);\n \n \theaders = curl_slist_append(headers, buf.buf);\n \n \t/* Add additional headers here */\n-\tif (options && options->extra_headers) {\n+\tif (options->extra_headers) {\n \t\tconst struct string_list_item *item;\n-\t\tif (options && options->extra_headers) {\n-\t\t\tfor_each_string_list_item(item, options->extra_headers) {\n-\t\t\t\theaders = curl_slist_append(headers, item->string);\n-\t\t\t}\n-\t\t}\n+\t\tfor_each_string_list_item(item, options->extra_headers)\n+\t\t\theaders = curl_slist_append(headers, item->string);\n \t}\n \n \theaders = http_append_auth_header(&http_auth, headers);\n@@ -2183,7 +2211,18 @@ static int http_request(const char *url,\n \n \tret = run_one_slot(slot, &results);\n \n-\tif (options && options->content_type) {\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 \t\tstruct strbuf raw = STRBUF_INIT;\n \t\tcurlinfo_strbuf(slot->curl, CURLINFO_CONTENT_TYPE, &raw);\n \t\textract_content_type(&raw, options->content_type,\n@@ -2191,7 +2230,7 @@ static int http_request(const char *url,\n \t\tstrbuf_release(&raw);\n \t}\n \n-\tif (options && options->effective_url)\n+\tif (options->effective_url)\n \t\tcurlinfo_strbuf(slot->curl, CURLINFO_EFFECTIVE_URL,\n \t\t\t\toptions->effective_url);\n \n@@ -2253,22 +2292,66 @@ static int update_url_from_redirect(struct strbuf *base,\n \treturn 1;\n }\n \n-static int http_request_reauth(const char *url,\n+/*\n+ * Compute the retry delay for an HTTP 429 response.\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+{\n+\t/* Use the slot-specific retry_after value or configured default */\n+\tif (slot_retry_after >= 0) {\n+\t\t/* Check if retry delay exceeds maximum allowed */\n+\t\tif (slot_retry_after > http_max_retry_time) {\n+\t\t\terror(_(\"response requested a delay greater than http.maxRetryTime (%ld > %ld seconds)\"),\n+\t\t\t      slot_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t  \"http/429-error\", \"exceeds-max-retry-time\");\n+\t\t\ttrace2_data_intmax(\"http\", the_repository,\n+\t\t\t\t  \"http/429-requested-delay\", slot_retry_after);\n+\t\t\treturn -1;\n+\t\t}\n+\t\treturn slot_retry_after;\n+\t} else {\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+\t\t\t      http_retry_after, http_max_retry_time);\n+\t\t\ttrace2_data_string(\"http\", the_repository,\n+\t\t\t\t\t\"http/429-error\", \"config-exceeds-max-retry-time\");\n+\t\t\treturn -1;\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}\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 {\n+\tstatic struct http_get_options empty_opts;\n \tint i = 3;\n \tint ret;\n+\tint rate_limit_retries = http_max_retries;\n+\n+\tif (!options)\n+\t\toptions = &empty_opts;\n \n \tif (always_auth_proactively())\n \t\tcredential_fill(the_repository, &http_auth, 1);\n \n \tret = http_request(url, result, target, options);\n \n-\tif (ret != HTTP_OK && ret != HTTP_REAUTH)\n+\tif (ret != HTTP_OK && ret != HTTP_REAUTH && ret != HTTP_RATE_LIMITED)\n \t\treturn ret;\n \n-\tif (options && options->effective_url && options->base_url) {\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 \t\t\tcredential_from_url(&http_auth, options->base_url->buf);\n@@ -2276,7 +2359,9 @@ static int http_request_reauth(const char *url,\n \t\t}\n \t}\n \n-\twhile (ret == HTTP_REAUTH && --i) {\n+\twhile ((ret == HTTP_REAUTH && --i) ||\n+\t       (ret == HTTP_RATE_LIMITED && --rate_limit_retries)) {\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 \t\t * should clear it out before making our next request.\n@@ -2301,11 +2386,28 @@ static int http_request_reauth(const char *url,\n \t\tdefault:\n \t\t\tBUG(\"Unknown http_request target\");\n \t\t}\n-\n-\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\tif (ret == HTTP_RATE_LIMITED) {\n+\t\t\tretry_delay = handle_rate_limit_retry(options->retry_after);\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\twarning(_(\"rate limited, waiting %ld seconds before retry\"), 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\t\tcredential_fill(the_repository, &http_auth, 1);\n+\t\t}\n \n \t\tret = http_request(url, result, target, options);\n \t}\n+\tif (ret == HTTP_RATE_LIMITED) {\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}\n \treturn ret;\n }\n \n@@ -2313,7 +2415,7 @@ int http_get_strbuf(const char *url,\n \t\t    struct strbuf *result,\n \t\t    struct http_get_options *options)\n {\n-\treturn http_request_reauth(url, result, HTTP_REQUEST_STRBUF, options);\n+\treturn http_request_recoverable(url, result, HTTP_REQUEST_STRBUF, options);\n }\n \n /*\n@@ -2337,7 +2439,7 @@ int http_get_file(const char *url, const char *filename,\n \t\tgoto cleanup;\n \t}\n \n-\tret = http_request_reauth(url, result, HTTP_REQUEST_FILE, options);\n+\tret = http_request_recoverable(url, result, HTTP_REQUEST_FILE, options);\n \tfclose(result);\n \n \tif (ret == HTTP_OK && finalize_object_file(the_repository, tmpfile.buf, filename))\ndiff --git a/http.h b/http.h\nindex f9d4593404..f9ee888c3e 100644\n--- a/http.h\n+++ b/http.h\n@@ -20,6 +20,7 @@ struct slot_results {\n \tlong http_code;\n \tlong auth_avail;\n \tlong http_connectcode;\n+\tlong retry_after;\n };\n \n struct active_request_slot {\n@@ -157,6 +158,13 @@ struct http_get_options {\n \t * request has completed.\n \t */\n \tstruct string_list *extra_headers;\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 */\n+\tlong retry_after;\n };\n \n /* Return values for http_get_*() */\n@@ -167,6 +175,7 @@ struct http_get_options {\n #define HTTP_REAUTH\t4\n #define HTTP_NOAUTH\t5\n #define HTTP_NOMATCHPUBLICKEY\t6\n+#define HTTP_RATE_LIMITED\t7\n \n /*\n  * Requests a URL and stores the result in a strbuf.\ndiff --git a/remote-curl.c b/remote-curl.c\nindex 92e40bb682..57a3e9db62 100644\n--- a/remote-curl.c\n+++ b/remote-curl.c\n@@ -529,6 +529,17 @@ static struct discovery *discover_refs(const char *service, int for_push)\n \t\tshow_http_message(&type, &charset, &buffer);\n \t\tdie(_(\"unable to access '%s' with http.pinnedPubkey configuration: %s\"),\n \t\t    transport_anonymize_url(url.buf), curl_errorstr);\n+\tcase HTTP_RATE_LIMITED:\n+\t\tif (http_options.retry_after > 0) {\n+\t\t\tshow_http_message(&type, &charset, &buffer);\n+\t\t\tdie(_(\"rate limited by '%s', please try again in %ld seconds\"),\n+\t\t\t\ttransport_anonymize_url(url.buf),\n+\t\t\t\thttp_options.retry_after);\n+\t\t} else {\n+\t\t\tshow_http_message(&type, &charset, &buffer);\n+\t\t\tdie(_(\"rate limited by '%s', please try again later\"),\n+\t\t\t\ttransport_anonymize_url(url.buf));\n+\t\t}\n \tdefault:\n \t\tshow_http_message(&type, &charset, &buffer);\n \t\tdie(_(\"unable to access '%s': %s\"),\ndiff --git a/t/lib-httpd.sh b/t/lib-httpd.sh\nindex 5f42c311c2..4c76e813e3 100644\n--- a/t/lib-httpd.sh\n+++ b/t/lib-httpd.sh\n@@ -167,6 +167,7 @@ prepare_httpd() {\n \tinstall_script error.sh\n \tinstall_script apply-one-time-script.sh\n \tinstall_script nph-custom-auth.sh\n+\tinstall_script http-429.sh\n \n \tln -s \"$LIB_HTTPD_MODULE_PATH\" \"$HTTPD_ROOT_PATH/modules\"\n \ndiff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf\nindex 6b8c50a51a..40a690b0bb 100644\n--- a/t/lib-httpd/apache.conf\n+++ b/t/lib-httpd/apache.conf\n@@ -139,6 +139,10 @@ SetEnv PERL_PATH ${PERL_PATH}\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n </LocationMatch>\n+<LocationMatch /http_429/>\n+\tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n+\tSetEnv GIT_HTTP_EXPORT_ALL\n+</LocationMatch>\n <LocationMatch /smart_v0/>\n \tSetEnv GIT_EXEC_PATH ${GIT_EXEC_PATH}\n \tSetEnv GIT_HTTP_EXPORT_ALL\n@@ -160,6 +164,7 @@ ScriptAlias /broken_smart/ broken-smart-http.sh/\n ScriptAlias /error_smart/ error-smart-http.sh/\n ScriptAlias /error/ error.sh/\n ScriptAliasMatch /one_time_script/(.*) apply-one-time-script.sh/$1\n+ScriptAliasMatch /http_429/(.*) http-429.sh/$1\n ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Directory ${GIT_EXEC_PATH}>\n \tOptions FollowSymlinks\n@@ -185,6 +190,9 @@ ScriptAliasMatch /custom_auth/(.*) nph-custom-auth.sh/$1\n <Files apply-one-time-script.sh>\n \tOptions ExecCGI\n </Files>\n+<Files http-429.sh>\n+\tOptions ExecCGI\n+</Files>\n <Files ${GIT_EXEC_PATH}/git-http-backend>\n \tOptions ExecCGI\n </Files>\ndiff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh\nnew file mode 100644\nindex 0000000000..c97b16145b\n--- /dev/null\n+++ b/t/lib-httpd/http-429.sh\n@@ -0,0 +1,98 @@\n+#!/bin/sh\n+\n+# Script to return HTTP 429 Too Many Requests responses for testing retry logic.\n+# Usage: /http_429/<test-context>/<retry-after-value>/<repo-path>\n+#\n+# The test-context is a unique identifier for each test to isolate state files.\n+# The retry-after-value can be:\n+#   - A number (e.g., \"1\", \"2\", \"100\") - sets Retry-After header to that many seconds\n+#   - \"none\" - no Retry-After header\n+#   - \"invalid\" - invalid Retry-After format\n+#   - \"permanent\" - always return 429 (never succeed)\n+#   - An HTTP-date string (RFC 2822 format) - sets Retry-After to that date\n+#\n+# On first call, returns 429. On subsequent calls (after retry), forwards to git-http-backend\n+# unless retry-after-value is \"permanent\".\n+\n+# Extract test context, retry-after value and repo path from PATH_INFO\n+# PATH_INFO format: /<test-context>/<retry-after-value>/<repo-path>\n+path_info=\"${PATH_INFO#/}\"  # Remove leading slash\n+test_context=\"${path_info%%/*}\"  # Get first component (test context)\n+remaining=\"${path_info#*/}\"  # Get rest\n+retry_after=\"${remaining%%/*}\"  # Get second component (retry-after value)\n+repo_path=\"${remaining#*/}\"  # Get rest (repo path)\n+\n+# Extract repository name from repo_path (e.g., \"repo.git\" from \"repo.git/info/refs\")\n+# The repo name is the first component before any \"/\"\n+repo_name=\"${repo_path%%/*}\"\n+\n+# Use current directory (HTTPD_ROOT_PATH) for state file\n+# Create a safe filename from test_context, retry_after and repo_name\n+# This ensures all requests for the same test context share the same state file\n+safe_name=$(echo \"${test_context}-${retry_after}-${repo_name}\" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-')\n+state_file=\"http-429-state-${safe_name}\"\n+\n+# Check if this is the first call (no state file exists)\n+if test -f \"$state_file\"\n+then\n+\t# Already returned 429 once, 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\n+\tPATH_INFO=\"/$repo_path\"\n+\texport PATH_INFO\n+\t# GIT_PROJECT_ROOT points to the document root where repositories are stored\n+\t# The script runs from HTTPD_ROOT_PATH, and www/ is the document root\n+\tif test -z \"$GIT_PROJECT_ROOT\"\n+\tthen\n+\t\t# Construct path: current directory (HTTPD_ROOT_PATH) + /www\n+\t\tGIT_PROJECT_ROOT=\"$(pwd)/www\"\n+\t\texport GIT_PROJECT_ROOT\n+\tfi\n+\texec \"$GIT_EXEC_PATH/git-http-backend\"\n+fi\n+\n+# Mark that we've returned 429\n+touch \"$state_file\"\n+\n+# Output HTTP 429 response\n+printf \"Status: 429 Too Many Requests\\r\\n\"\n+\n+# Set Retry-After header based on retry_after value\n+case \"$retry_after\" in\n+\tnone)\n+\t\t# No Retry-After header\n+\t\t;;\n+\tinvalid)\n+\t\tprintf \"Retry-After: invalid-format-123abc\\r\\n\"\n+\t\t;;\n+\tpermanent)\n+\t\t# Always return 429, don't set state file for success\n+\t\trm -f \"$state_file\"\n+\t\tprintf \"Retry-After: 1\\r\\n\"\n+\t\tprintf \"Content-Type: text/plain\\r\\n\"\n+\t\tprintf \"\\r\\n\"\n+\t\tprintf \"Permanently rate limited\\n\"\n+\t\texit 0\n+\t\t;;\n+\t*)\n+\t\t# Check if it's a number\n+\t\tcase \"$retry_after\" in\n+\t\t\t[0-9]*)\n+\t\t\t\t# Numeric value\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_after\"\n+\t\t\t\t;;\n+\t\t\t*)\n+\t\t\t\t# Assume it's an HTTP-date format (passed as-is, URL decoded)\n+\t\t\t\t# Apache may URL-encode the path, so decode common URL-encoded characters\n+\t\t\t\t# %20 = space, %2C = comma, %3A = colon\n+\t\t\t\tretry_value=$(echo \"$retry_after\" | sed -e 's/%20/ /g' -e 's/%2C/,/g' -e 's/%3A/:/g')\n+\t\t\t\tprintf \"Retry-After: %s\\r\\n\" \"$retry_value\"\n+\t\t\t\t;;\n+\t\tesac\n+\t\t;;\n+esac\n+\n+printf \"Content-Type: text/plain\\r\\n\"\n+printf \"\\r\\n\"\n+printf \"Rate limited\\n\"\ndiff --git a/t/meson.build b/t/meson.build\nindex 9b2fa4dee8..cba123af81 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -709,6 +709,7 @@ integration_tests = [\n   't5581-http-curl-verbose.sh',\n   't5582-fetch-negative-refspec.sh',\n   't5583-push-branches.sh',\n+  't5584-http-429-retry.sh',\n   't5600-clone-fail-cleanup.sh',\n   't5601-clone.sh',\n   't5602-clone-remote-exec.sh',\ndiff --git a/t/t5584-http-429-retry.sh b/t/t5584-http-429-retry.sh\nnew file mode 100755\nindex 0000000000..a22007b2cf\n--- /dev/null\n+++ b/t/t5584-http-429-retry.sh\n@@ -0,0 +1,266 @@\n+#!/bin/sh\n+\n+test_description='test HTTP 429 Too Many Requests retry logic'\n+\n+. ./test-lib.sh\n+\n+. \"$TEST_DIRECTORY\"/lib-httpd.sh\n+\n+start_httpd\n+\n+test_expect_success 'setup test repository' '\n+\ttest_commit initial &&\n+\tgit clone --bare . \"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" &&\n+\tgit --git-dir=\"$HTTPD_DOCUMENT_ROOT_PATH/repo.git\" config http.receivepack true\n+'\n+\n+# This test suite uses a special HTTP 429 endpoint at /http_429/ that simulates\n+# rate limiting. The endpoint format is:\n+#   /http_429/<test-context>/<retry-after-value>/<repo-path>\n+# The http-429.sh script (in t/lib-httpd) returns a 429 response with the\n+# specified Retry-After header on the first request for each test context,\n+# then forwards subsequent requests to git-http-backend. Each test context\n+# is isolated, allowing multiple tests to run independently.\n+\n+test_expect_success 'HTTP 429 with retries disabled (maxRetries=0) fails immediately' '\n+\t# Set maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should fail immediately without any retry attempt\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retries-disabled/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message in stderr)\n+\ttest_grep ! -i \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 permanent should fail after max retries' '\n+\t# Enable retries with a limit\n+\ttest_config http.maxRetries 2 &&\n+\n+\t# Git should retry but eventually fail when 429 persists\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/permanent-fail/permanent/repo.git\" 2>err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After is retried and succeeds' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry after receiving 429 and eventually succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-succeeds/1/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\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+\ttest_config http.retryAfter 1 &&\n+\n+\t# Git should retry using configured default and succeed\n+\tgit ls-remote \"$HTTPD_URL/http_429/no-retry-after-header/none/repo.git\" >output 2>err &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 retry delays are respected' '\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Time the operation - it should take at least 2 seconds due to retry delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/retry-delays-respected/2/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Verify it took at least 2 seconds (allowing some tolerance)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 fails immediately if Retry-After exceeds http.maxRetryTime' '\n+\t# Configure max retry time to 3 seconds (much less than requested 100)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 3 &&\n+\n+\t# Should fail immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/retry-after-exceeds-max-time/100/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (no 100 second wait)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 99 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 fails if configured http.retryAfter exceeds http.maxRetryTime' '\n+\t# Test misconfiguration: retryAfter > maxRetryTime\n+\t# Configure retryAfter larger than maxRetryTime\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 100 &&\n+\ttest_config http.maxRetryTime 5 &&\n+\n+\t# Should fail immediately with configuration error\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/config-retry-after-exceeds-max-time/none/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (no 100 second wait)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 99 &&\n+\ttest_grep \"configured http.retryAfter.*exceeds.*http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with Retry-After HTTP-date format' '\n+\t# Test HTTP-date format (RFC 2822) in Retry-After header\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 2)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should parse the HTTP-date and retry after the delay\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/http-date-format/$future_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (allowing tolerance for processing time)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with HTTP-date exceeding maxRetryTime fails immediately' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tfuture_time=$((now + 200)) &&\n+\traw=$(test-tool date show:rfc2822 $future_time) &&\n+\tfuture_date=\"${raw#* -> }\" &&\n+\tfuture_date_encoded=$(echo \"$future_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Configure max retry time much less than the 200 second delay\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 10 &&\n+\n+\t# Should fail immediately without waiting 200 seconds\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/http-date-exceeds-max-time/$future_date_encoded/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 200 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 199 &&\n+\ttest_grep \"http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'HTTP 429 with past HTTP-date should not wait' '\n+\traw=$(test-tool date timestamp now) &&\n+\tnow=\"${raw#* -> }\" &&\n+\tpast_time=$((now - 10)) &&\n+\traw=$(test-tool date show:rfc2822 $past_time) &&\n+\tpast_date=\"${raw#* -> }\" &&\n+\tpast_date_encoded=$(echo \"$past_date\" | sed \"s/ /%20/g\") &&\n+\n+\t# Enable retries\n+\ttest_config http.maxRetries 3 &&\n+\n+\t# Git should retry immediately without waiting\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/past-http-date/$past_date_encoded/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should complete quickly (no wait for a past-date Retry-After)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_expect_success 'HTTP 429 with invalid Retry-After format uses configured default' '\n+\t# Configure default retry-after\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Should use configured default (1 second) since header is invalid\n+\tstart=$(test-tool date getnanos) &&\n+\tgit ls-remote \"$HTTPD_URL/http_429/invalid-retry-after-format/invalid/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should take at least 1 second (the configured default)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'HTTP 429 will not be retried without config' '\n+\t# Default config means http.maxRetries=0 (retries disabled)\n+\t# When 429 is received, it should fail immediately without retry\n+\t# Do NOT configure anything - use defaults (http.maxRetries defaults to 0)\n+\n+\t# Should fail immediately without retry\n+\ttest_must_fail git ls-remote \"$HTTPD_URL/http_429/no-retry-without-config/1/repo.git\" 2>err &&\n+\n+\t# Verify no retry happened (no \"waiting\" message)\n+\ttest_grep ! -i \"waiting.*retry\" err &&\n+\n+\t# Should get 429 error\n+\ttest_grep \"429\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_RETRY_AFTER overrides http.retryAfter config' '\n+\t# Configure retryAfter to 10 seconds\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.retryAfter 10 &&\n+\n+\t# Override with environment variable to 1 second\n+\tstart=$(test-tool date getnanos) &&\n+\tGIT_HTTP_RETRY_AFTER=1 git ls-remote \"$HTTPD_URL/http_429/env-retry-after-override/none/repo.git\" >output 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should use env var (1 second), not config (10 seconds)\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -ge 1 &&\n+\ttest \"$duration_int\" -lt 5 &&\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRIES overrides http.maxRetries config' '\n+\t# Configure maxRetries to 0 (disabled)\n+\ttest_config http.maxRetries 0 &&\n+\ttest_config http.retryAfter 1 &&\n+\n+\t# Override with environment variable to enable retries\n+\tGIT_HTTP_MAX_RETRIES=3 git ls-remote \"$HTTPD_URL/http_429/env-max-retries-override/1/repo.git\" >output 2>err &&\n+\n+\t# Should retry (env var enables it despite config saying disabled)\n+\ttest_grep \"refs/heads/\" output &&\n+\ttest_grep \"waiting.*retry\" err\n+'\n+\n+test_expect_success 'GIT_HTTP_MAX_RETRY_TIME overrides http.maxRetryTime config' '\n+\t# Configure maxRetryTime to 100 seconds (would accept 50 second delay)\n+\ttest_config http.maxRetries 3 &&\n+\ttest_config http.maxRetryTime 100 &&\n+\n+\t# Override with environment variable to 10 seconds (should reject 50 second delay)\n+\tstart=$(test-tool date getnanos) &&\n+\ttest_must_fail env GIT_HTTP_MAX_RETRY_TIME=10 \\\n+\t\tgit ls-remote \"$HTTPD_URL/http_429/env-max-retry-time-override/50/repo.git\" 2>err &&\n+\tduration=$(test-tool date getnanos $start) &&\n+\n+\t# Should fail quickly (not wait 50 seconds) because env var limits to 10\n+\tduration_int=${duration%.*} &&\n+\ttest \"$duration_int\" -lt 49 &&\n+\ttest_grep \"greater than http.maxRetryTime\" err\n+'\n+\n+test_expect_success 'verify normal repository access still works' '\n+\tgit ls-remote \"$HTTPD_URL/smart/repo.git\" >output &&\n+\ttest_grep \"refs/heads/\" output\n+'\n+\n+test_done\n-- \ngitgitgadget\n"},{"id":"539217","messageId":"6e76be1d85e3d3e9ec520112e433d4859ac4f16c.1773752435.git.gitgitgadget@gmail.com","threadId":"64535","inReplyTo":"pull.2008.v6.git.1773752435.gitgitgadget@gmail.com","subject":"[PATCH v6 1/3] strbuf: pass correct alloc to strbuf_attach() in strbuf_reencode()","fromName":"Vaidas Pilkauskas via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-03-17T13:00:33Z","receivedAt":"2026-03-17T13:00:56Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"From: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n\nreencode_string_len() allocates len+1 bytes (including the NUL) and\nreturns the string length in len. strbuf_reencode() was calling\nstrbuf_attach(sb, out, len, len), so alloc was one byte too small.\n\nstrbuf_attach() then calls strbuf_grow(sb, 0). With alloc < len+1,\nALLOC_GROW always reallocates, so we reallocated immediately after\nattach even when the strbuf was not extended further. Pass len+1 as\nthe alloc argument so the existing buffer is reused and the\nreallocation is avoided.\n\nSigned-off-by: Vaidas Pilkauskas <vaidas.pilkauskas@shopify.com>\n---\n strbuf.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/strbuf.c b/strbuf.c\nindex 3939863cf3..3e04addc22 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -168,7 +168,7 @@ int strbuf_reencode(struct strbuf *sb, const char *from, const char *to)\n \tif (!out)\n \t\treturn -1;\n \n-\tstrbuf_attach(sb, out, len, len);\n+\tstrbuf_attach(sb, out, len, len + 1);\n \treturn 0;\n }\n \n-- \ngitgitgadget\n\n"},{"id":"539585","messageId":"ab4Q2XMQIaOYDjPw@nand.local","threadId":"64535","inReplyTo":"3418f4553d246c797697c80f439c77fee293f7e0.1773752435.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v6 3/3] http: add support for HTTP 429 rate limit retries","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2026-03-21T03:30:33Z","receivedAt":"2026-03-21T03:30:39Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Tue, Mar 17, 2026 at 01:00:35PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n>  \treturn size && (*ptr == ' ' || *ptr == '\\t');\n>  }\n>\n> -static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p UNUSED)\n> +static size_t fwrite_wwwauth(char *ptr, size_t eltsize, size_t nmemb, void *p MAYBE_UNUSED)\n>  {\n>  \tsize_t size = eltsize * nmemb;\n>  \tstruct strvec *values = &http_auth.wwwauth_headers;\n> @@ -575,6 +582,21 @@ static int http_options(const char *var, const char *value,\n\nGood, this version drops the special case where we do not define\nGIT_CURL_HAVE_CURLINFO_RETRY_AFTER, which Peff suggested in his review\nof the earlier round.\n\nI agree with his suggestion that we can document that handling\nRetry-After requires a libcurl newer than 7.66.0, and that is well\ndocumented in the user-facing documentation and code comments where\nappropriate.\n\n> @@ -2119,10 +2150,10 @@ static void http_opt_request_remainder(CURL *curl, off_t pos)\n>\n>  static int http_request(const char *url,\n>  \t\t\tvoid *result, int target,\n> -\t\t\tconst struct http_get_options *options)\n> +\t\t\tstruct http_get_options *options)\n\nThe previous round had this as a const pointer, with a separate\nout-parameter via 'long *retry_after_out'. Review on the previous round\nsuggested making the retry_after part of the existing out-parameter. Of\ncourse, doing so requires that we make that parameter non-const, hence\nthe change here, which looks good to me.\n\n>  {\n>  \tstruct active_request_slot *slot;\n> -\tstruct slot_results results;\n> +\tstruct slot_results results = { .retry_after = -1 };\n\nThis also moved from run_one_slot(); this location makes more sense to\nme.\n\n> diff --git a/http.h b/http.h\n> index f9d4593404..f9ee888c3e 100644\n> --- a/http.h\n> +++ b/http.h\n> @@ -20,6 +20,7 @@ struct slot_results {\n>  \tlong http_code;\n>  \tlong auth_avail;\n>  \tlong http_connectcode;\n> +\tlong retry_after;\n>  };\n>\n>  struct active_request_slot {\n> @@ -157,6 +158,13 @@ struct http_get_options {\n>  \t * request has completed.\n>  \t */\n>  \tstruct string_list *extra_headers;\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 */\n> +\tlong retry_after;\n\nI think making this a pure long instead of a pointer as is the case with\nother members of this struct makes sense for the reasons that Peff\npointed out in the review of the previous round.\n\nThanks,\nTaylor\n"},{"id":"539586","messageId":"ab4RIZr7b49VKjR9@nand.local","threadId":"64535","inReplyTo":"pull.2008.v6.git.1773752435.gitgitgadget@gmail.com","subject":"Re: [PATCH v6 0/3] http: add support for HTTP 429 rate limit retries","fromName":"Taylor Blau","fromEmail":"me@ttaylorr.com","sentAt":"2026-03-21T03:31:45Z","receivedAt":"2026-03-21T03:31:47Z","isPatch":true,"sender":{"key":"me@ttaylorr.com","avatar":"https://avatars.githubusercontent.com/u/301000140?v=4"},"body":"On Tue, Mar 17, 2026 at 01:00:32PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n> Changes since v5:\n>\n>  * drop show_http_message_fatal() preparation patch\n>  * drop fwrite_headers, restore fwrite_wwwauth\n>  * move CURLINFO_RETRY_AFTER from finish_active_slot to http_request\n>  * move retry_after=-1 init from run_one_slot to http_request\n>  * replace retry_after_out param with http_get_options field\n>  * fix loop counter: separate REAUTH and RATE_LIMITED counters\n>  * fix racy -lt 2 timing bounds in tests\n\nThanks, this round looks good to me. The main things that I noted from\nthe review on v5 was to drop the old 3/4, and a handful of suggestions\non the final patch, all of which look to have been addressed.\n\nThe first two patches being unchanged, this round looks good to me.\nThanks for working on this, Vaidas!\n\nThanks,\nTaylor\n"},{"id":"539590","messageId":"xmqqbjghdbkq.fsf@gitster.g","threadId":"64535","inReplyTo":"ab4RIZr7b49VKjR9@nand.local","subject":"Re: [PATCH v6 0/3] http: add support for HTTP 429 rate limit retries","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-03-21T04:57:09Z","receivedAt":"2026-03-21T04:57:12Z","isPatch":true,"sender":{"key":"gitster@pobox.com","avatar":"https://avatars.githubusercontent.com/u/54884?v=4"},"body":"Taylor Blau <me@ttaylorr.com> writes:\n\n> On Tue, Mar 17, 2026 at 01:00:32PM +0000, Vaidas Pilkauskas via GitGitGadget wrote:\n>> Changes since v5:\n>>\n>>  * drop show_http_message_fatal() preparation patch\n>>  * drop fwrite_headers, restore fwrite_wwwauth\n>>  * move CURLINFO_RETRY_AFTER from finish_active_slot to http_request\n>>  * move retry_after=-1 init from run_one_slot to http_request\n>>  * replace retry_after_out param with http_get_options field\n>>  * fix loop counter: separate REAUTH and RATE_LIMITED counters\n>>  * fix racy -lt 2 timing bounds in tests\n>\n> Thanks, this round looks good to me. The main things that I noted from\n> the review on v5 was to drop the old 3/4, and a handful of suggestions\n> on the final patch, all of which look to have been addressed.\n>\n> The first two patches being unchanged, this round looks good to me.\n> Thanks for working on this, Vaidas!\n>\n> Thanks,\n> Taylor\n\nThanks, both.\nLet me mark the topic for 'next' then.\n"},{"id":"539701","messageId":"CAGjQmDPOo+c=i-oTOzCV=EO+B__3ySR4wd5EYZ1EAdL+jF5rLQ@mail.gmail.com","threadId":"64535","inReplyTo":"ab4RIZr7b49VKjR9@nand.local","subject":"Re: [PATCH v6 0/3] http: add support for HTTP 429 rate limit retries","fromName":"Vaidas Pilkauskas","fromEmail":"vaidas.pilkauskas@shopify.com","sentAt":"2026-03-23T06:58:27Z","receivedAt":"2026-03-23T06:58:40Z","isPatch":true,"sender":{"key":"vaidas.pilkauskas@shopify.com","avatar":"https://avatars.githubusercontent.com/u/169160138?v=4"},"body":"On Sat, Mar 21, 2026 at 5:31 AM Taylor Blau <me@ttaylorr.com> wrote:\n>\n> Thanks, this round looks good to me. The main things that I noted from\n> the review on v5 was to drop the old 3/4, and a handful of suggestions\n> on the final patch, all of which look to have been addressed.\n>\n> The first two patches being unchanged, this round looks good to me.\n> Thanks for working on this, Vaidas!\n>\n> Thanks,\n> Taylor\n\n\nTaylor, Peff and Junio, thank you very much for reviewing!\n"}]}