bugfix: balancer_by_lua* fix unbounded retries caused by a wrapped try count - #2524
Merged
zhuizhuhaomeng merged 3 commits intoSep 14, 2026
Merged
zhuizhuhaomeng merged 3 commits into
zhuizhuhaomeng merged 3 commits into
Conversation
set_more_tries() reduces the requested count with "count = max_tries -
total", where both operands are ngx_uint_t. Once total exceeds max_tries the
result is negative, and since count is an int it is then stored into
bp->more_tries, which is ngx_uint_t. peer.tries therefore receives a value
near 2^64 and decrements from there, so proxy_next_upstream_tries can never
terminate the request: it retries until the client disconnects.
Instrumented, with proxy_next_upstream_tries 3:
set_more_tries max_tries:3 total:1 granted:1
set_more_tries max_tries:3 total:3 granted:0
set_more_tries max_tries:3 total:4 granted:-1
set_more_tries max_tries:3 total:5 granted:-2
get_peer cached:0 tries:18446744073709551614
get_peer cached:0 tries:18446744073709551613
total passes max_tries because nginx re-increments peer.tries for errors on
cached connections (ngx_http_upstream_next), so a balancer that asks for one
extra try on every invocation, as ingress-nginx's does, grows the budget
faster than it depletes.
Reproduction, with no Lua application code required: an upstream whose
backend closes the connection without sending a response, warmed with 32
concurrent requests, then one further request. Before this change that
request retried until the client timed out; after it, 5 to 7 attempts and a
502 within milliseconds, over 6 trials. A self-contained nginx.conf is
attached to the pull request.
No test is included: reproducing this needs concurrent warm-up before a
single request, and Test::Nginx has no clean primitive for that. The
attached configuration reproduces it deterministically instead.
Observed in production on ingress-nginx v1.12.8, where one request produced
roughly 392,000 upstream retries and 6.5M log lines in seven minutes.
Signed-off-by: Teachh <hectoritiin@hotmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zhuizhuhaomeng
previously approved these changes
Sep 14, 2026
Warm four keepalive connections with concurrent subrequests and validate all warm-up responses. Bound the warm-up wait and match the complete balancer invocation sequence to detect wrapped retry budgets. Allow six attempts without the Nginx cached-error notification patch, and three with it. The previous guard rejected the bounded retry count before it could distinguish the fix from the original underflow. Verified that the old code fails and the fix passes without the Nginx notification patch. Repeated the fixed regression three times and passed all 11 assertions with the notification patch on both Lua variants.
zhuizhuhaomeng
approved these changes
Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
set_more_tries()can grant a wrapped try count, which makesproxy_next_upstream_triesunenforceable: a single downstream request retries until the client disconnects.I hereby granted the copyright of the changes in this pull request to the authors of this lua-nginx-module project.
The defect
ngx_http_lua_ffi_balancer_set_more_tries()reduces the requested count with:max_triesandtotalarengx_uint_t. Oncetotalexceedsmax_triesthe subtraction is negative;countis anintso it holds that negative value, and it is then stored intobp->more_tries, which isngx_uint_t.peer.triesconsequently receives a value near 2^64 and decrements from there, so the termination check can never pass.Instrumented, with
proxy_next_upstream_tries 3:totalpassesmax_triesbecause nginx re-incrementspeer.triesfor errors on cached connections (ngx_http_upstream_next(),if (u->peer.cached && ft_type == NGX_HTTP_UPSTREAM_FT_ERROR)). A balancer that asks for one extra try on every invocation — which is what ingress-nginx's balancer does — therefore grows the budget faster than it depletes.This contradicts the documented contract for
set_more_tries():Reproduction
A standalone configuration is also included at the end of this description, for reproducing it outside the test suite. It needs no Lua application code: nginx is its own backend and
return 444closes a connection without sending a response, which is what an upstream crashing mid-request looks like. Warm the upstream keepalive pool with concurrent requests, then send one request to the failing path.Concurrency during the warm-up is required — a single cold request stays within the limit, because nothing has yet inflated
peer.tries.Measured on nginx 1.31.4 + this module at
master, 3 trials per configuration, countingprematurely closedlines in the error log for a unique path:set_more_tries(1)per invocationkeepaliveproxy_passproxy_passCalling
set_more_tries(1)only on the first invocation of each request stays bounded: 5, 7, 5.Verification of the fix
Rebuilt with the clamp, same reproduction, 6 trials: 6, 5, 5 with keepalive and 5, 6, 5 without. Every request returned a 502 within milliseconds and the guard was never reached — 0 storms in 6 trials, against 5 of 6 before.
Test
A companion test is proposed for
openresty/lua-resty-coreast/balancer.tTEST 23, since this isngx.balancerbehaviour: openresty/lua-resty-core#PENDING.It is deterministic and needs no concurrency. The trick is distinct peer addresses:
127.0.0.1,127.0.0.2and127.0.0.3are separate keepalive pool entries, so three sequential warm-up requests leave one cached connection each. Retries then land on cached connections, which are not charged againstpeer.tries, lettingbp->total_triesoutgrowmax_tries. Withproxy_next_upstream_tries 2andset_more_tries(1)the fourth request answers 502 with the fix and 500 without it, because the loop trips the test's own invocation guard.Note that a grant larger than 1 is not sufficient on its own: the existing guard clamps
countso thattotal + count <= max_trieswhatever the grant, and while a negativecountdoes appear it is harmless in that case —−1becomes2^64−1in the unsignedmore_tries, sopeer.tries += more_triesmerely decrements by one. The failure needs|count| > peer.tries, which takes several consecutive cached-connection failures.Context
Found while investigating a production incident on ingress-nginx v1.12.8, where one HTTP request to a path with no matching route produced roughly 392,000 upstream retries and 6.5M log lines in seven minutes, saturating a shared Kafka logging topic. The client received no response at all rather than a 502.
Related history: #866 was superseded by #913, whose C change was merged as
da11870db65e— the guard this patch corrects. Its companion test, proposed in openresty/lua-resty-core#59, does not appear in any commit in that repository, so this path has been untested since. #1546 was rejected citing the contract quoted above.Reproduction configuration