fix(masspay): survive CoinPay's rate limit instead of writing off invoices - #513
Merged
Conversation
…oices Bulk pay reported 30 accepted invoices as un-payable with "CoinPay create failed 429: Too many requests". None of them were actually un-payable. CoinPayPortal limits every /api/ route to 60 requests per rolling minute per client IP. ugig.net calls it from one server IP, so that budget is shared by every payment request, status poll and webhook the box makes, for all users at once. A 62-invoice run fires 62 create calls at concurrency 5 and exhausts the minute in seconds — and createPayment threw on the resulting 429, which the bulk payer recorded as a permanent skip alongside "already paid". Route every CoinPay call through a paced client that stays inside the provider's window and retries what gets limited anyway, honouring Retry-After and syncing to the provider's own reset when our optimistic local count is wrong. Rate-limited invoices now come back flagged retryable with a reason that says so, and the confirm step offers to prepare them in one click — already-prepared requests are reused, so that costs one call per invoice actually still missing a quote. The batch shares a single deadline rather than a per-invoice budget: quotes are only good for 15 minutes, so the last invoice must not still be waiting out a rate-limit window long after the first quote expired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
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.
The bug
A bulk pay run reported 30 accepted invoices as un-payable:
None of them were actually un-payable.
Why
CoinPayPortal's edge proxy limits every
/api/route to 60 requests per rolling minute per client IP (coinpayportal/src/proxy.ts). ugig.net calls it from a single server IP, so that budget is shared across every payment request, status poll and webhook the box makes — for all users at once./api/invoices/bulk-payment-requestfires onepayments/createper invoice at concurrency 5 with no pacing and no retry. A 62-invoice run exhausts the minute in seconds,createPaymentthrows on the resulting 429, and the bulk payer records each one as a permanent skip — rendered in the same list as "Already paid" and "Not accepted yet".A 429 is transient by definition. Treating it as terminal is what turned a payroll run into 30 write-offs.
The fix
src/lib/coinpay-throttle.ts(new) —coinpayFetch, a paced + retryingfetchthat every CoinPay call now goes through:Retry-After/X-RateLimit-Reset, with jittered exponential backoff as fallback.Reporting — a rate-limited invoice comes back
retryable: truewith a reason that says so, instead of a raw provider string. The confirm step surfaces those separately and offers a one-click "prepare the N that were limited". Re-preparing reuses unexpired requests, so it costs one provider call per invoice actually still missing a quote.Bounding — the batch shares one deadline (4 min) rather than a per-invoice budget. Quotes are only good for 15 minutes, so the last invoice must not still be waiting out a rate-limit window long after the first quote expired. Anything unprepared when it elapses returns as a retryable skip rather than holding the request open.
Behaviour change
A large batch now takes up to ~2 minutes to prepare instead of failing fast — it waits out the provider's window rather than sprinting into it. Well inside the 15-minute quote validity.
COINPAY_MAX_REQUESTS_PER_MINUTEandCOINPAY_MAX_ATTEMPTSare env-tunable. The counters are per-process;railway.jsonpinsnumReplicas: 1, so one process is the whole outbound budget. If that ever scales out, divide the setting by the replica count — the provider counts replicas together and we can't see across them.Verification
tsc --noEmitclean,eslintclean on changed files, production build succeedsprecommit(lint + type-check + tests + build) passed on commitNot done here
CoinPayPortal's 60/min-per-IP cap is low for a server-to-server integration, and this PR works around it rather than changing it. Raising it for API-key-authenticated callers would be the other half of the fix, but it weakens a rate limiter in a payments product, so it's left as a deliberate call rather than folded in silently.
🤖 Generated with Claude Code