Skip to content

Keep polling the payment wait endpoint when it times out - #1223

Open
TaprootFreak wants to merge 4 commits into
developfrom
fix/payment-wait-timeout-tolerance
Open

Keep polling the payment wait endpoint when it times out#1223
TaprootFreak wants to merge 4 commits into
developfrom
fix/payment-wait-timeout-tolerance

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why

The API is about to cap how long GET /v1/lnurlp/wait/{id} blocks and answer HTTP 408 while the payment is still pending (DFXswiss/api#4502, implemented in DFXswiss/api#4533). Today that request is instead cut off by the CDN after 125 s with a gateway error page.

That difference matters here. fetchJson (src/util/utils.ts) returns the parsed body regardless of status, so today's non-JSON error page makes the promise reject and the poll interval keeps running — the widget recovers by accident. A JSON 408 would instead arrive in the success callback, which calls stopWaitPolling(). And usePolling.init() returned early when called again with the same URL, which is exactly what happens for a wait poll (the URL is derived from the payment and does not change while it is pending). The poll would stop for good and the completed payment would never be picked up.

What

payment-link.context.tsx — a 408 in the wait callback is treated as "nothing decided yet" and the poll continues.

polling.tsstop() now also releases url.current. It previously kept it, so a restart on the same URL was silently ignored: every stop on a still-pending payment was final. That was the trap the 408 guard had to work around; it is now gone, and a test covers the restart.

payment-link-pos.context.tsx — the POS terminal reaches the same endpoint through call(), which throws on any non-2xx, so its .catch chain already re-issued the wait. Three things were wrong with that retry once 408 becomes a regular occurrence:

  • it retried immediately, so an error returning at once would spin;
  • it retried a 401 as well, so a terminal whose session had gone re-asked every two seconds for as long as it stayed open;
  • the retry timers were untracked, so a pending retry fired for a terminal nobody was using anymore.

The retry now waits, skips the unauthorized case, and every pending timer is cleared on unmount. Because creating a payment starts a wait of its own, the timers are held in a set rather than a single slot — otherwise the older one is lost and fires unattended.

Tests

polling.test.ts covers the restart after stop; payment-link-pos-wait-retry.test.tsx covers both retry rules (ask again after a timeout, stop once the session is gone).

Deployment order

This has to go out before the API-side cap.

The endpoint is going to cap how long it blocks and answer 408 while the payment
is still pending. fetchJson does not distinguish success from failure, so that
answer reaches the success callback, which stops the poll — and init() ignores a
repeat call for the same URL, so it never resumes and the payment is never picked
up. Treat 408 as "nothing decided yet" and let the poll continue.
Two follow-ups from review:

The POS wait retries on error without any delay. Once the endpoint answers 408 that
path is taken regularly, and an error returning immediately would turn the retry
into a tight loop. It now waits before asking again.

usePolling.stop() left url.current set, so init() treated a restart on the same URL
as a duplicate and silently did nothing. A wait poll keeps its URL for as long as
the payment is pending, which made every stop on a pending payment final. Releasing
the URL in stop() removes the trap the 408 guard has to work around, and the added
test covers it.
Review follow-up on the retry delay.

The retry ran for every error, including the 401 that unauthorizedResponse rethrows
after clearing the session. Asking again without a valid key only repeats the same
401, so the terminal kept calling the endpoint every two seconds for as long as it
stayed open. The retry now skips that case.

The retry timer is also tracked and cleared on unmount, so a pending retry no longer
fires for a terminal nobody is using anymore.
Review follow-up on the retry timer.

Creating a payment starts a wait of its own, so more than one retry can be pending
at a time. A single ref kept only the newest timer id and lost the older one, which
then fired after unmount — the case the previous commit meant to rule out. The
timers are now held in a set and all of them are cleared on unmount.

Adds tests for both retry rules: ask again after a timeout, stop once the session
is gone.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Four review passes were needed to reach zero findings.

Beyond the original one-line guard, the passes surfaced three problems in the paths this change touches:

  • usePolling.stop() kept the polled URL, so a restart on the same URL was silently ignored — every stop on a still-pending payment was final
  • the POS wait retried immediately and also retried a 401, so a terminal whose session had gone kept calling the endpoint every two seconds
  • the retry timers were held in a single slot, so with two waits in flight the older timer was lost and fired after unmount

All are fixed, with tests for the restart and for both retry rules.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 30, 2026 23:31
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