Repository navigation
fix(linq): retry a NIN check the provider failed, never one it answered - #262
Miracle656 merged 2 commits into
Conversation
…ed (W079) submitCustomerKyc called straight through, so one transient 5xx from the identity provider was final. A NIN verifies once, ever, against one customer reference, and on 2026-10-14 the endpoint returned a 5xx in ~2.2s for everyone; nobody could tell that blip from a refusal. - Retried: a provider 5xx, or a connection that got no answer. Backoff 1s then 2s, at most three attempts. - Never retried: any 4xx (nin_already_used, invalid_nin_format, a 429 — answers, and each retry spends a rate-limited attempt), and our own timeout, because the provider may still be working on the NIN we sent. - Before any resend, GET /b2b/customers/status (reference only, no NIN). A customer already verified is the answer and the NIN is not sent again; a status that cannot be read means no blind resend. - One 35s deadline bounds every attempt, wait and status check — under the clients' 40s ceiling. Passing it also lets the 30s per-attempt timeout apply; without a deadline it was silently capped at 15s. Transport failures are now tagged (timeout | unreachable) so our own abort can be told apart from a provider's 504. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz
|
@JemimahEkong Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
Approving on substance. It needs to go in after #261, which consolidates the two Linq HTTP clients and conflicts with this — I will tell you when to rebase, and nothing else is queued between them.
This is a careful piece of work on the one call in the service where being careless is unrecoverable.
The title is the design. "Retry a NIN check the provider failed, never one it answered." A NIN verifies once, ever, against one customer reference — so a retry that reaches an endpoint which already answered does not cost a round trip, it costs the person their verification.
The timeout decision is the one most people get wrong, and you got it right:
if (transport === "timeout") return false;
if (transport === "unreachable") return true;
return err.status >= 500;Not for our own timeout. The provider may still be working on the NIN we sent, so sending it again is exactly the duplicate attempt this must never make.
A timeout is ambiguous, not a failure — treating it as "may have been answered" is the conservative read and the correct one. unreachable is the opposite: the request never arrived, so nothing was spent. Distinguishing those two is the whole safety argument, and most implementations collapse them into "network error, retry".
Asking before resending is better than anything I would have specified:
A failure can hide a success: the provider may have verified the customer and then failed to tell us. Before any resend we ask
GET /b2b/customers/status.
That endpoint takes the reference alone — no NIN — which is exactly why it exists. If the first attempt landed and the response was lost, the status call finds it and the NIN never travels twice. If even that cannot be answered, you stop. That is the right failure.
The observed data backs the issue too: a 5xx in about 2.2 seconds, consistently, on 2026-10-14, with every user meeting it bare. That is the shape of a provider blip, not a refusal, and it was reaching people as "your identity could not be verified".
src/__tests__/linq: 143 passed. The NIN-never-escapes assertions still hold.
Rebase on main once #261 lands — it is approved and waiting on CI. Ping me and I will merge this straight after.
|
#261 has landed — the two Linq HTTP clients are now one Your Note |
Brings the branch onto Miracle656#261's single Linq client (src/linq/http.ts). Conflict in src/linq/onramp.ts: this branch tagged timeout/unreachable inside onramp's private call(), which Miracle656#261 removed. The tagging now lives in the shared client, as requested in review: http.ts marks the failures it raises itself (`transport: "timeout" | "unreachable"`, including the deadline-exhausted 504) and exports linqTransport(err). isRetryableKycFailure reads it from there. LinqError's fields are unchanged, and http.ts remains the only module naming the base URL, host or X-API-Key. Verified: typecheck, tsc --noEmit, build; jest 63 suites / 933 tests. Removing the timeout tag fails kycRetry ("never resend after our own timeout"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz
W079 · Retry a NIN check the provider failed, never one it answered
Closes #236
Problem
provisionCustomerandcreateOnrampOrderwrap their provider call inretryOnProviderFailure.submitCustomerKyccalled straight through, so a single transient 5xx from the identity provider was final.That is the least forgiving place to have no retry. A NIN verifies once, ever, against one customer reference, and a person who meets a blip can't tell it from a refusal. Nor can the screen behind them. On 2026-10-14 the endpoint returned a 5xx in about 2.2 seconds, consistently, and every user hit it bare.
What changed
All in
src/linq/onramp.ts.Which failures are retried (
isRetryableKycFailure):nin_already_used,invalid_nin_format,nin_verification_failed, 429Our own timeout and a provider's 504 are both status 504, so the status alone can't tell them apart. Transport failures are therefore now tagged
timeoutorunreachablewhere they are thrown.The NIN is never resent to a route that has already answered. A failure can hide a success: the provider may have verified the customer and then failed to say so. Before any resend, the code calls
GET /b2b/customers/status, which takes the customer reference alone and no NIN.Backoff and budget.
KYC_BUDGET_MS= 35s, bounds every attempt, wait and status check. It is under the clients' own 40s ceiling, so nobody is told the check failed while this server is still running it.min(30s, what is left), and a wait that would end past the deadline isn't taken.A fix on the way.
submitCustomerKycused to passtimeoutMs: 30_000with no deadline. In that case the onramp client silently capped the timeout at its 15s default, so the 30s added for NIMC's slow answers never applied. Passing the deadline makes it apply.docs/ngn-rails.mdsaid the call was "deliberately not retried". It now describes the policy above.Acceptance criteria
POST /ngn/customers/kyc.nin_already_usedis returned immediately, with no retry. Tested for 409, 400invalid_nin_formatand 422: one post, no status check, no backoff slept. A 429 is still not retried.Validation
npm test: 735 of 736 passing. The single failure is the pre-existingopenapiCoveragetest (get /feesundocumented), which is W082's job and is unchanged by this PR.src/__tests__/linq/kycRetry.test.ts.npm run typecheck,npx tsc --noEmitandnpm run buildall pass.global.fetch; nothing can reach the provider. The only NIN-shaped value used is00000000000.Note for reviewers
This PR and the W078 PR (one HTTP client for Linq) both change
src/linq/onramp.ts, so whichever merges second needs a rebase. If W078 lands first, two pieces of this change move intosrc/linq/http.ts:The retry policy itself stays in
onramp.ts.