Cap how long the payment wait endpoints block and answer 408 - #4533
Open
TaprootFreak wants to merge 2 commits into
Open
Cap how long the payment wait endpoints block and answer 408#4533TaprootFreak wants to merge 2 commits into
TaprootFreak wants to merge 2 commits into
Conversation
Both wait routes passed 0 as the timeout to AsyncMap.wait(), which arms no timer, so a request could only ever be settled by the payment itself. For an invoice with an expiry years out that means the handler stays pending indefinitely, and the CDN in front of the API ends the request after 125 s with a gateway error the caller cannot interpret. Because such a request never completes, it also never writes an access-log line, so the latency data covers only the waits that finished. Cap a single call at 110 s and answer 408, leaving the caller a defined "nothing has happened yet" to re-issue. The cap sits below the CDN limit and above the longest wait seen in production (93 s), so it does not cut short a payment that would still resolve. The timeout is raced in the service rather than passed to AsyncMap.wait(): the map hands every caller of the same payment one shared subscriber, whose timer would be armed by whoever arrived first and would reject all of them together. Note the subscriber itself still lives until the payment resolves; it is bounded by the number of pending payments and a later caller is served from it, so releasing it on client disconnect would need reference counting in AsyncMap and is left out here.
Two follow-ups from review: The catch turned any rejection of the raced promise into a 408. That is harmless today because nothing ever rejects the wait, but it would silently swallow the reason if a later change starts rejecting it. Rejections now pass through and only a TimeoutError becomes a 408. Util.timeout left its timer armed for the full duration even when the wrapped promise won the race. With previous callers (10-20 s, low frequency) that went unnoticed; a 110 s timeout on a per-request path would accumulate them. The timer is now cleared once the race is decided. Also re-runs prettier on docs/payment-links.md, whose table padding the previous commit had left inconsistent.
Collaborator
Author
|
Two review passes were needed to reach zero findings. The first pass raised four points, all addressed in a95e58f:
The second pass confirmed zero findings in both lanes, including a check that the |
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.
Addresses the timeout half of #4502.
Why
Both long-polling routes —
GET /v1/lnurlp/wait/:idandGET /v1/paymentLink/payment/wait— passed0as the timeout toAsyncMap.wait().0is falsy, so no timer is armed and the request can only ever be settled by the payment itself. For an invoice whose expiry is years out (production currently holds pending payments expiring in 2029) the handler stays pending indefinitely.Two consequences, both measured against production:
HTTP 524after 125.1 s and 125.2 s in two runs. A gateway error page is not something a client can act on.What
A single call now blocks for at most
Config.payment.waitTimeout(110 s) and then answers 408, so the caller has a defined "nothing has happened yet" to re-issue. The value sits below the CDN limit and above the longest wait observed in production, so it does not cut short a payment that would still resolve. It is a constant rather than an environment variable because its upper bound is set by infrastructure outside this repo, not by the environment.The timeout is raced in the service rather than passed to
AsyncMap.wait(). The map hands every caller waiting on the same payment one shared subscriber, so a timer armed there would belong to whoever arrived first and would reject all of them together — a caller joining one second before that timer fires would be cut off after one second instead of getting its own window. One of the four added tests covers exactly that case.Only a timeout becomes a 408: a rejection of the wait itself passes through unchanged, so a future caller of
AsyncMap.rejectis not silently reported as an ordinary timeout.Shared code touched
Util.timeoutgained two things: it now rejects with aTimeoutError(so callers can tell the timeout apart from a rejection of the wrapped promise), and it clears the losing timer once the race is decided. Previously the timer stayed armed for its full duration even when the wrapped promise won. That went unnoticed with the existing callers (10-20 s, low frequency); at 110 s on a per-request path it would accumulate. All existing call sites were checked — none inspects the error message or type, so both changes are invisible to them.Not in scope
The subscriber itself still lives until the payment resolves. It is bounded by the number of pending payments (37 today), and a later caller is served from it, so this is benign. Releasing it on client disconnect would need reference counting in
AsyncMap— deliberately left out of this change.Client dependency
The timeout answer is JSON where clients previously saw a CDN error page. DFXswiss/services#1223 makes the widget tolerate it and must be deployed first; without it the widget would stop polling on the first 408 and never notice the completed payment.
Merchant integrations that long-poll are unaffected in kind — they already have to handle the 125 s gateway error — but the documented contract changes, so README and
docs/payment-links.mdnow spell out that a 408 means "ask again", never "the customer did not pay".