Repository navigation
refactor(linq): one HTTP client for the provider, used by every rail (W078) - #261
Conversation
…(W078) client.ts, onramp.ts and bills.ts each had their own call() against the same provider, with the same key and base URL, and they had drifted: which fields an error was read from, whether a plain-text body survived, whether `code` was parsed at all. For a day the KYC screen could only say "rejected the request" because of it. src/linq/http.ts now owns the auth header, timeouts and deadlines, JSON and non-JSON error parsing, code extraction, the 429 retry and failure logging. Per-call differences are arguments: timeoutMs for the NIN check, rateLimitRetries for the offramp. LinqError and retryOnProviderFailure move there and are re-exported from client.ts, so no import changes. Where the copies disagreed, the most complete behaviour won (details in the PR). Two latent bugs fixed on the way: the NIN check's 30s timeout was being capped at 15s, and a missing API key on the onramp/bills rails reported as "Could not reach Linq". Logged response bodies now redact digit runs of 7+. src/__tests__/linq/ passes with no assertion changed. 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! 🚀 |
The single-client guard built its paths with `join`, which uses the
platform separator, then compared them against literals written with
forward slashes. On Windows every path arrived as `linq\http.ts` and
compared unequal to `linq/http.ts`, so four assertions failed:
- "linq/http.ts"
+ "linq\http.ts"
The guard itself was satisfied — only the separator differed. That made
this pass on CI's Linux and fail on a Windows checkout, which is the worst
way for a test to be wrong: the person who can see the failure is the one
least able to tell it is spurious.
Normalise with `split(sep).join("/")` at the point the path is built, so
every assertion below compares the same shape everywhere.
src/__tests__/linq: 210 passed. tsc clean.
Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
Miracle656
left a comment
There was a problem hiding this comment.
This is the PR I most wanted in this repo, and you had already rebased it onto a fix I pushed this morning.
Why it matters. src/linq/onramp.ts had its own call(), separate from src/linq/client.ts — two HTTP clients to one provider. I found that the hard way today: Linq started returning the existing customerRef on email_already_in_use, and I had to apply the same fix twice, because fixing only the shared client would have changed nothing on the onramp path. That is not a tidiness problem, it is a correctness one — the next provider change would have missed half the surface again.
And you carried that fix forward rather than clobbering it:
src/linq/http.ts:91 readonly existingCustomerRef?: string,
src/linq/http.ts:292 const existingCustomerRef = firstString(body["customerRef"], body["customer_ref"]);
src/linq/onramp.ts:139 const existing = err instanceof LinqError ? err.existingCustomerRef : undefined;
The guard tests are the part that makes this stick. only http.ts names the LINQ_BASE_URL variable, names the provider's host, names the X-API-Key header, and has no other call() or fetch() in the Linq modules but Horizon's price feed. Without those, a second client grows back the first time someone needs a one-off request, and nobody notices until the next provider change misses it. A refactor that cannot be undone by accident is worth far more than one that merely tidies up today.
The NIN property survives, which was the thing I checked hardest. Only response bodies are logged, never request bodies, and the reasoning is still written down where someone adding a catch will read it:
Log a request body. These bodies carry a NIN, a customer's name, email…
src/__tests__/linq: 210 passed, tsc --noEmit clean, verified locally against current main.
I pushed one commit to your branch. Four of your own guard assertions failed on my machine and not on CI:
- "linq/http.ts"
+ "linq\http.ts"
join uses the platform separator, and the assertions are written with forward slashes — so the guard was satisfied and the comparison was not. It passed on CI's Linux and failed on any Windows checkout, which is the worst way for a test to be wrong: the person who can see the failure is the one least able to tell it is spurious. Normalised with split(sep).join("/") where the path is built.
Same family as the CRLF problem #250 diagnosed yesterday. Worth remembering that this repo has Windows contributors.
Merging once CI confirms.
|
CI confirmed. Worth flagging separately: that integration suite has been red since at least 10-16, and it is testing the Postgres read path. Someone should look at it. |
Miracle656#261 moved every Linq request into the shared client in src/linq/http.ts, and main's bills.ts aliases it with `const call = linqCall`. The merge kept both that alias and the branch's old hand-rolled call(), which typechecked only against the client it no longer has: duplicate 'call', and CallInit, BASE_URL, TIMEOUT_MS and apiKey all gone. Same broken image build took down Integration tests and the chaos stack with it.
W078 · One HTTP client for Linq
Closes #235
Problem
client.ts,onramp.tsandbills.tseach defined their owncall()against the same provider, with the same auth and the same base URL. That is three copies, not the two the issue names. Written months apart, they drifted in exactly the details that decide what a user is told. For a full day the KYC screen could only say "rejected the request", because the copy serving it discarded the provider'scodeand its plain-text reason.What changed
src/linq/http.tsis now the only code that reaches the provider.linqCall()owns:codeextraction;timeoutMscovers the NIN check's 30s,rateLimitRetriescovers the offramp's 429 retry, anddeadline,queryandauthcover the rest.LinqError,retryOnProviderFailureandTOTAL_BUDGET_MSmove there.client.tsre-exports the first two, so no import anywhere had to change.client.ts,onramp.tsandbills.tseach lose their private client: 461 lines removed, 19 added.What differed between the copies, and which behaviour won
client.ts(offramp)onramp.tsbills.tscodecodeonlycode,errorCode,error_codemessagemessage,error,msg,detail,reasonmessage,errorLinq returned <status>Linq rejected the request, plus(<code>)when there is oneLinq rejected the requestonramp.test.ts)customerRefon an errorcustomerRefcustomerRef,customer_refRetry-AfterretryAfterSeconds; no waittimeoutMssilently capped at 15s when no deadline was passedtimeoutMs, capped only by the deadline. The NIN check's 30s now actually appliesfetch failed)Could not reach Linq, loggedCould not reach Linq, not loggedLINQ_API_KEYnot configuredCould not reach Linq(thrown inside the network catch)not configured, with no request mademalformed responseresponse we could not readresponse we could not readLinq returned a response we could not read{}nullnull{}. No caller treats either as valid, and{}doesn't throw aTypeErrorin offramp callers${BASE}${path}new URL(path, BASE), which drops any path prefix inLINQ_BASE_URLquery: prefix kept, query encodedTwo of these are bug fixes rather than tie-breaks, so they're called out:
timeoutMsat its 15s default whenever no deadline was passed, andsubmitCustomerKycpasses none. A test now proves the check waits 30s.Acceptance criteria
Exactly one
call()implementation reaches the provider. A test scanssrc/and asserts that onlylinq/http.tsmentionsLINQ_BASE_URL, the provider host orX-API-Key. It also asserts no other Linq module defines acall(), and that the only otherfetchinsrc/linq/iscoinPrice.ts, which reads Horizon, not Linq.src/__tests__/linq/passes with no changes to its assertions. No existing test file was modified (git diff origin/main -- src/__tests__touches only the new file).Every route returns the provider's
code. This is tested twice:/ngn, 4 under/offramp), each driven through stubbedfetchand the real modules.GET /offramp/orders/:idneeds a stored order and access token, so it's covered at the call level throughgetOfframpStatus.A short plain-text body survives and an HTML page does not, tested on each rail. An over-long plain body is refused too.
The PR says what differed and which won. See the table above.
Validation
npm test: 762 of 763 passing. The only failure is the pre-existingopenapiCoveragetest (get /feesundocumented), which is W082's job, as the issue notes.src/__tests__/linq/httpClient.test.ts.npm run typecheck,npx tsc --noEmitandnpm run buildall pass.global.fetch; no code path can reach the provider. The only NIN-shaped value used is00000000000.