Skip to content

Cap how long the payment wait endpoints block and answer 408 - #4533

Open
TaprootFreak wants to merge 2 commits into
developfrom
fix/payment-link-wait-timeout
Open

Cap how long the payment wait endpoints block and answer 408#4533
TaprootFreak wants to merge 2 commits into
developfrom
fix/payment-link-wait-timeout

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Addresses the timeout half of #4502.

Why

Both long-polling routes — GET /v1/lnurlp/wait/:id and GET /v1/paymentLink/payment/wait — passed 0 as the timeout to AsyncMap.wait(). 0 is 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:

  1. The caller gets an error instead of an answer. The CDN in front of the API terminates an origin request after 125 s — verified by calling the endpoint against a real pending payment: HTTP 524 after 125.1 s and 125.2 s in two runs. A gateway error page is not something a client can act on.
  2. The latency data is wrong. A request that never completes never writes an access-log line. Over the last 7 days the public wait route logged 870 requests, none of which completed; the authenticated route logged 21, all completed, the longest at 92.5 s. The finished waits are the only ones the data ever showed.

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.reject is not silently reported as an ordinary timeout.

Shared code touched

Util.timeout gained two things: it now rejects with a TimeoutError (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.md now spell out that a 408 means "ask again", never "the customer did not pay".

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.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Two review passes were needed to reach zero findings.

The first pass raised four points, all addressed in a95e58f:

  • the catch converted any rejection of the raced promise into a 408 — only a timeout does now, anything else passes through, with a test covering it
  • Util.timeout left its losing timer armed for the full duration even when the wrapped promise won; it is cleared once the race is decided
  • two markdown formatting regressions in docs/payment-links.md (table padding, a missing blank line before a list item)

The second pass confirmed zero findings in both lanes, including a check that the Util.timeout change is backward compatible for all existing call sites.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant