From e901189542e6567bc66712857b51dd0872e97bfd Mon Sep 17 00:00:00 2001 From: Jemimah Ekong Date: Sat, 17 Oct 2026 18:55:57 +0100 Subject: [PATCH] test(linq): fixture every documented KYC failure code, and stop guessing a cause (W087) The provider documented its KYC failure codes on 2026-10-14 and both clients branch on them, but nothing tested a single one against the shape it arrives in. The route tests hand-built LinqErrors and skipped the parser. - fixtures/linq/kyc-errors.json: one case per documented code, plus undocumented-code and no-code cases. Each pairs the provider's response with wraith's answer, so the clients test against the same contract. - kycErrorFixtures.test.ts drives every case through stubbed fetch, the real parser and the real route. No provider path is reachable. - A reasonless 5xx no longer reads "Linq rejected the request", which blamed the request for a cause nobody sent. 4xx wording is unchanged. - The route's own 11-digit check now carries code: invalid_nin_format, so the clients' branch for it is reachable for the commonest case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz --- docs/ngn-rails.md | 18 ++ fixtures/README.md | 62 ++++++ fixtures/linq/kyc-errors.json | 146 +++++++++++++ src/__tests__/fixtures/kycErrors.ts | 49 +++++ src/__tests__/linq/kycErrorFixtures.test.ts | 219 ++++++++++++++++++++ src/__tests__/linq/onramp.test.ts | 20 +- src/api/ngn.ts | 8 +- src/linq/onramp.ts | 26 ++- 8 files changed, 537 insertions(+), 11 deletions(-) create mode 100644 fixtures/README.md create mode 100644 fixtures/linq/kyc-errors.json create mode 100644 src/__tests__/fixtures/kycErrors.ts create mode 100644 src/__tests__/linq/kycErrorFixtures.test.ts diff --git a/docs/ngn-rails.md b/docs/ngn-rails.md index 938c573b6..2bdaacc30 100644 --- a/docs/ngn-rails.md +++ b/docs/ngn-rails.md @@ -162,6 +162,24 @@ a verification attempt, which Linq rate-limits. `submitCustomerKyc` is deliberately **not** retried — a retried identity submission is a second verification attempt against that limit. +**Failures.** The provider documented these on 2026-10-14. `code` is forwarded +verbatim; branch on it, never on `error`. + +| `code` | Provider status | wraith answers | +| --- | --- | --- | +| `invalid_nin_format` | 400 | 400 — also sent by wraith's own 11-digit check | +| `nin_already_used` | 409 | 409 | +| `nin_verification_failed` | 422 | 422 | +| `kyc_provider_error` | 502 | 502 | +| `kyc_internal_error` | 500 | 502 (a provider 5xx is ours to own) | + +An **undocumented** code is passed through as sent, never mapped onto one of +these. A body with **no usable code** omits `code` entirely, and `error` says +only that the request failed and its status — never a cause nobody sent. Every +case, with the provider's body and wraith's answer side by side, is in +[`fixtures/linq/kyc-errors.json`](../fixtures/linq/kyc-errors.json); the clients +test against that same file. + ### 3. Lock a rate ``` diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 000000000..9844ebb79 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,62 @@ +# Shared fixtures + +Response shapes that more than one codebase has to agree on. The backend's tests +read these files directly. The Veil mobile and web apps read the **same** files, +so a client cannot test against a shape the backend does not produce. + +If you change a shape here, the backend suite fails until wraith produces it, and +every client suite fails until that client handles it. That is the point. + +## `linq/kyc-errors.json` + +Failure responses for identity verification by NIN (W087). + +| Field | Meaning | +| --- | --- | +| `documented` | The provider's documented KYC failure codes (2026-10-14) and their HTTP statuses. | +| `cases[].kind` | `documented`, `undocumented` (a code the provider has sent but not documented), or `no_code` (no usable code and no usable message). | +| `cases[].provider` | What the provider sends to wraith: `status`, plus `body` (JSON) or `rawBody` (verbatim text, for non-JSON bodies). | +| `cases[].veil` | What wraith answers on `POST /ngn/customers/kyc` for that provider response. **This is what clients should test against.** | + +### What the clients can rely on + +- **`code` is forwarded verbatim.** It is never renamed, and an unknown code is + never mapped onto a known one. A client must branch on `code`, and must have a + default branch for codes it does not recognise. +- **No `code` means nobody said why.** When the provider gives no usable code, + the `code` key is absent (not `null`, not `""`). `error` then says only that + the request failed and its status; it never names a cause. Show generic copy. +- **A provider 5xx arrives as 502**, with its code intact: `kyc_internal_error` + is sent as 500 by the provider and reaches clients as 502. Branch on `code`, + not on status, for anything code-specific. +- **`invalid_nin_format` also comes from wraith itself.** The route checks the + 11-digit shape before spending a rate-limited verification attempt, and that + refusal carries the same code as the provider's. One branch handles both. +- **`error` is prose for a person.** Never match on it; it changes when the + provider rewords. + +### Using it from a client repo + +Pin to a commit, never to a branch, so the shape cannot change under you +silently, and bump the pin deliberately: + +```sh +curl -fsSL -o test/fixtures/kyc-errors.json \ + https://raw.githubusercontent.com/Miracle656/wraith//fixtures/linq/kyc-errors.json +``` + +Then, for each case, feed `veil.status` and `veil.body` to the client's error +handling and assert the copy branch it picks — and that every `no_code` and +`undocumented` case lands in the generic branch. + +### Rules for editing + +- **No NIN, real or realistic.** A NIN is personal data under the NDPA. A test + asserts the file contains no 11-digit run and no `nin` field. +- **Bodies must be ones the provider sends.** The codes and statuses come from + the provider's documentation. The `message` prose in the documented cases is + representative, not captured verbatim; replace it with the real text when a + response is captured, rather than adding fields nobody has seen sent. +- The documented code list is restated in + `src/__tests__/linq/kycErrorFixtures.test.ts`, so editing this file alone cannot + redefine what the provider documented. diff --git a/fixtures/linq/kyc-errors.json b/fixtures/linq/kyc-errors.json new file mode 100644 index 000000000..31e8a1800 --- /dev/null +++ b/fixtures/linq/kyc-errors.json @@ -0,0 +1,146 @@ +{ + "description": "Linq KYC failure responses for POST /b2b/customers/kyc, and what wraith answers for each on POST /ngn/customers/kyc. Shared by the backend tests and the Veil mobile and web clients; see fixtures/README.md.", + "documentedOn": "2026-10-14", + "documented": { + "invalid_nin_format": 400, + "nin_already_used": 409, + "nin_verification_failed": 422, + "kyc_provider_error": 502, + "kyc_internal_error": 500 + }, + "cases": [ + { + "id": "invalid_nin_format", + "kind": "documented", + "provider": { + "status": 400, + "body": { "message": "The NIN provided is not in a valid format", "code": "invalid_nin_format" } + }, + "veil": { + "status": 400, + "body": { "error": "The NIN provided is not in a valid format", "code": "invalid_nin_format" } + } + }, + { + "id": "nin_already_used", + "kind": "documented", + "provider": { + "status": 409, + "body": { + "message": "This NIN has already been used to verify another customer", + "code": "nin_already_used", + "customerRef": "veil_fixture_existing_ref" + } + }, + "veil": { + "status": 409, + "body": { "error": "This NIN has already been used to verify another customer", "code": "nin_already_used" } + } + }, + { + "id": "nin_verification_failed", + "kind": "documented", + "provider": { + "status": 422, + "body": { "message": "NIN verification failed", "code": "nin_verification_failed" } + }, + "veil": { + "status": 422, + "body": { "error": "NIN verification failed", "code": "nin_verification_failed" } + } + }, + { + "id": "kyc_provider_error", + "kind": "documented", + "provider": { + "status": 502, + "body": { "message": "The identity provider returned an error", "code": "kyc_provider_error" } + }, + "veil": { + "status": 502, + "body": { "error": "The identity provider returned an error", "code": "kyc_provider_error" } + } + }, + { + "id": "kyc_internal_error", + "kind": "documented", + "provider": { + "status": 500, + "body": { "message": "An internal error occurred during KYC", "code": "kyc_internal_error" } + }, + "veil": { + "status": 502, + "body": { "error": "An internal error occurred during KYC", "code": "kyc_internal_error" } + } + }, + { + "id": "undocumented_code_with_message", + "kind": "undocumented", + "provider": { + "status": 422, + "body": { "message": "Name does not match NIN record", "code": "nin_name_mismatch" } + }, + "veil": { + "status": 422, + "body": { "error": "Name does not match NIN record", "code": "nin_name_mismatch" } + } + }, + { + "id": "undocumented_code_without_message", + "kind": "undocumented", + "provider": { + "status": 503, + "body": { "code": "kyc_queue_unavailable" } + }, + "veil": { + "status": 502, + "body": { "error": "Linq returned an error without giving a reason (HTTP 503, kyc_queue_unavailable)", "code": "kyc_queue_unavailable" } + } + }, + { + "id": "no_code_empty_object_5xx", + "kind": "no_code", + "provider": { "status": 500, "body": {} }, + "veil": { + "status": 502, + "body": { "error": "Linq returned an error without giving a reason (HTTP 500)" } + } + }, + { + "id": "no_code_unrelated_fields_4xx", + "kind": "no_code", + "provider": { "status": 422, "body": { "ok": false } }, + "veil": { + "status": 422, + "body": { "error": "Linq rejected the request" } + } + }, + { + "id": "no_code_blank_message_and_code", + "kind": "no_code", + "provider": { "status": 500, "body": { "message": " ", "code": "" } }, + "veil": { + "status": 502, + "body": { "error": "Linq returned an error without giving a reason (HTTP 500)" } + } + }, + { + "id": "no_code_empty_body", + "kind": "no_code", + "provider": { "status": 502, "rawBody": "" }, + "veil": { + "status": 502, + "body": { "error": "Linq returned an error without giving a reason (HTTP 502)" } + } + }, + { + "id": "no_code_html_gateway_page", + "kind": "no_code", + "provider": { "status": 502, "rawBody": "

502 Bad Gateway

" }, + "veil": { + "status": 502, + "body": { "error": "Linq returned an error without giving a reason (HTTP 502)" } + } + } + ] +} diff --git a/src/__tests__/fixtures/kycErrors.ts b/src/__tests__/fixtures/kycErrors.ts new file mode 100644 index 000000000..fc8d2f37a --- /dev/null +++ b/src/__tests__/fixtures/kycErrors.ts @@ -0,0 +1,49 @@ +/** + * Typed access to `fixtures/linq/kyc-errors.json`, the KYC failure shapes the + * backend and both Veil clients test against. + * + * Read from disk rather than imported: the file lives outside `src/` on + * purpose, so the clients can consume it without reaching into backend test + * code, and `rootDir` would refuse an import from there. + */ +import { readFileSync } from "fs"; +import { join } from "path"; + +export const KYC_ERRORS_PATH = join(__dirname, "../../../fixtures/linq/kyc-errors.json"); + +export type KycErrorKind = "documented" | "undocumented" | "no_code"; + +export interface KycErrorCase { + id: string; + kind: KycErrorKind; + /** What Linq sends. Exactly one of `body` (JSON) or `rawBody` (verbatim text). */ + provider: { status: number; body?: unknown; rawBody?: string }; + /** What wraith answers on `POST /ngn/customers/kyc` for that provider response. */ + veil: { status: number; body: { error: string; code?: string } }; +} + +export interface KycErrorFixtures { + description: string; + documentedOn: string; + documented: Record; + cases: KycErrorCase[]; +} + +export const kycErrorsRaw = readFileSync(KYC_ERRORS_PATH, "utf8"); +export const kycErrors = JSON.parse(kycErrorsRaw) as KycErrorFixtures; + +/** The text a provider response carries, as `fetch` would hand it over. */ +export function providerText(c: KycErrorCase): string { + return c.provider.rawBody ?? JSON.stringify(c.provider.body); +} + +/** A `fetch` stub answering with this case's provider response. */ +export function providerResponse(c: KycErrorCase) { + const text = providerText(c); + return { + ok: c.provider.status >= 200 && c.provider.status < 300, + status: c.provider.status, + headers: new Headers(), + text: async () => text, + }; +} diff --git a/src/__tests__/linq/kycErrorFixtures.test.ts b/src/__tests__/linq/kycErrorFixtures.test.ts new file mode 100644 index 000000000..622d6eeaf --- /dev/null +++ b/src/__tests__/linq/kycErrorFixtures.test.ts @@ -0,0 +1,219 @@ +/** + * W087 — the provider's documented KYC failure codes, against the shape they + * actually arrive in. + * + * Both Veil clients branch on these codes and show different copy for each. + * Until now nothing tested a single one of them end to end: the route tests + * hand-built a `LinqError` and skipped the parser, which is exactly how the + * previous round of error handling came to read fields the provider never + * sent. Here every case in `fixtures/linq/kyc-errors.json` goes through the + * real parser and the real route, with only `global.fetch` replaced. + * + * The fixture file is the contract. The clients read the same file, so if + * this suite and theirs both pass, they agree on what wraith sends. + * + * Nothing here can reach the provider: `global.fetch` is a stub for every + * test, and the naira rails have no sandbox to fall back on if it were not. + */ +import express from "express"; +import request from "supertest"; + +import { createNgnRouter } from "../../api/ngn"; +import { networkMiddleware } from "../../middleware/network"; +import { LinqError } from "../../linq/client"; +import { submitCustomerKyc } from "../../linq/onramp"; +import { + kycErrors, + kycErrorsRaw, + providerResponse, + type KycErrorCase, +} from "../fixtures/kycErrors"; + +// The order table is Prisma-backed and irrelevant to KYC; keep the database out. +jest.mock("../../linq/ngnOrders", () => ({ + recordNgnOrder: jest.fn().mockResolvedValue(undefined), + findNgnOrder: jest.fn().mockResolvedValue(null), +})); + +/** + * The provider's documentation of 2026-10-14, restated here rather than read + * from the fixture, so that editing the fixture cannot quietly redefine it. + */ +const DOCUMENTED: Record = { + invalid_nin_format: 400, + nin_already_used: 409, + nin_verification_failed: 422, + kyc_provider_error: 502, + kyc_internal_error: 500, +}; + +/** + * Plainly not a person's NIN. It has to be eleven digits to get past the + * route's own shape check and reach the (stubbed) provider at all. + */ +const TEST_NIN = "00000000000"; + +const byKind = (kind: KycErrorCase["kind"]) => kycErrors.cases.filter((c) => c.kind === kind); +const asRows = (cases: KycErrorCase[]) => cases.map((c) => [c.id, c] as const); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use(networkMiddleware); + app.use("/ngn", createNgnRouter()); + return app; +} + +function postKyc(nin = TEST_NIN) { + return request(makeApp()) + .post("/ngn/customers/kyc") + .set("x-network", "mainnet") + .send({ customerRef: "veil_fixture_customer", nin }); +} + +function stubProvider(c: KycErrorCase): jest.Mock { + const stub = jest.fn().mockResolvedValue(providerResponse(c)); + global.fetch = stub as never; + return stub; +} + +beforeEach(() => { + process.env.LINQ_API_KEY = "biz_live_test"; + process.env.NETWORKS = "testnet,mainnet"; + jest.restoreAllMocks(); + // The parser logs every non-2xx body; that is its job, not this suite's. + jest.spyOn(console, "warn").mockImplementation(() => {}); +}); + +describe("the fixture file", () => { + it("lists exactly the documented codes, at their documented statuses", () => { + expect(kycErrors.documented).toEqual(DOCUMENTED); + }); + + it.each(Object.entries(DOCUMENTED))("has one documented case for %s (%i)", (code, status) => { + const cases = byKind("documented").filter( + (c) => (c.provider.body as { code?: string } | undefined)?.code === code, + ); + expect(cases).toHaveLength(1); + expect(cases[0].provider.status).toBe(status); + expect(cases[0].veil.body.code).toBe(code); + }); + + it("has an undocumented-code case and a no-code case to degrade against", () => { + expect(byKind("undocumented").length).toBeGreaterThan(0); + expect(byKind("no_code").length).toBeGreaterThan(0); + }); + + // A NIN is personal data under the NDPA. A fixture is committed, copied into + // two other repos, and read by anyone — it must not hold anything that could + // pass for one, and must not model a NIN as a field of any response. + it("contains nothing that could pass for a NIN", () => { + expect(kycErrorsRaw).not.toMatch(/\d{11}/); + for (const c of kycErrors.cases) { + expect(c.provider.body ?? {}).not.toHaveProperty("nin"); + expect(c.veil.body).not.toHaveProperty("nin"); + } + }); + + it("gives every case exactly one of body or rawBody, and unique ids", () => { + for (const c of kycErrors.cases) { + expect([c.provider.body, c.provider.rawBody].filter((v) => v !== undefined)).toHaveLength(1); + } + const ids = kycErrors.cases.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("submitCustomerKyc parses each fixture", () => { + it.each(asRows(kycErrors.cases))("%s", async (_id, c) => { + stubProvider(c); + + const err = await submitCustomerKyc("veil_fixture_customer", TEST_NIN).catch((e) => e); + + expect(err).toBeInstanceOf(LinqError); + expect(err.status).toBe(c.provider.status); + expect(err.code).toBe(c.veil.body.code); + expect(err.message).toBe(c.veil.body.error); + expect(JSON.stringify(err, Object.getOwnPropertyNames(err))).not.toContain(TEST_NIN); + }); +}); + +describe("POST /ngn/customers/kyc forwards each fixture to the caller", () => { + it.each(asRows(kycErrors.cases))("%s", async (_id, c) => { + const stub = stubProvider(c); + + const res = await postKyc(); + + // The provider was "called" — through the stub, at the KYC path — so this + // exercised the real parser rather than a short-circuit. + expect(stub).toHaveBeenCalled(); + expect(String(stub.mock.calls[0][0])).toContain("/b2b/customers/kyc"); + + expect(res.status).toBe(c.veil.status); + expect(res.body).toEqual(c.veil.body); + expect(JSON.stringify(res.body)).not.toContain(TEST_NIN); + }); + + // A 5xx is ours to own as a 502 — the caller did nothing wrong — but the + // code is what the clients branch on, and it must arrive untouched. + it.each(asRows(byKind("documented")))("%s: the code arrives verbatim", async (_id, c) => { + stubProvider(c); + const res = await postKyc(); + expect(res.body.code).toBe((c.provider.body as { code: string }).code); + expect(res.status).toBe(c.provider.status >= 500 ? 502 : c.provider.status); + }); +}); + +describe("an undocumented code", () => { + // Mapping an unknown code onto the nearest known one is a guess, and a guess + // here sends a person down the wrong branch with confident copy. Passing it + // through lets the client fall to its generic case honestly. + it.each(asRows(byKind("undocumented")))("%s is passed through, not mapped", async (_id, c) => { + stubProvider(c); + const res = await postKyc(); + + const sent = (c.provider.body as { code: string }).code; + expect(sent in DOCUMENTED).toBe(false); + expect(res.body.code).toBe(sent); + expect(Object.keys(DOCUMENTED)).not.toContain(res.body.code); + }); +}); + +describe("a body with no readable reason", () => { + // Words that would tell a person *why* it failed. None was sent, so none may + // appear: the KYC screen guessed "check your name" three times in a row off + // a fallback that said nothing, and was wrong each time. + const IMPLIES_A_CAUSE = /\bnin\b|identity|verif|format|already|name|customer|invalid|mismatch/i; + + it.each(asRows(byKind("no_code")))("%s: no code, and no implied cause", async (_id, c) => { + stubProvider(c); + const res = await postKyc(); + + expect(res.body).not.toHaveProperty("code"); + expect(res.body.error).not.toMatch(IMPLIES_A_CAUSE); + for (const code of Object.keys(DOCUMENTED)) expect(res.body.error).not.toContain(code); + // A provider failure is not the request being turned down. + if (c.provider.status >= 500) expect(res.body.error).not.toMatch(/reject/i); + }); +}); + +describe("the route's own NIN shape check", () => { + // Most malformed NINs never reach the provider: the route refuses them to + // save a rate-limited attempt. They must still carry the documented code, or + // the clients' `invalid_nin_format` branch is unreachable in practice. + it.each(["0000000000", "000000000000", "0000000000a", ""])( + "answers %p with the provider's invalid_nin_format shape, without calling it", + async (nin) => { + const stub = jest.fn(); + global.fetch = stub as never; + + const res = await postKyc(nin); + + expect(stub).not.toHaveBeenCalled(); + const documented = kycErrors.cases.find((c) => c.id === "invalid_nin_format")!; + expect(res.status).toBe(documented.veil.status); + expect(res.body.code).toBe(documented.veil.body.code); + expect(typeof res.body.error).toBe("string"); + }, + ); +}); diff --git a/src/__tests__/linq/onramp.test.ts b/src/__tests__/linq/onramp.test.ts index ca8ba595f..c2ba6f8ea 100644 --- a/src/__tests__/linq/onramp.test.ts +++ b/src/__tests__/linq/onramp.test.ts @@ -417,14 +417,15 @@ describe("call() error parsing", () => { }); // An HTML error page is a document, not a message — echoing it would put a - // gateway's markup on a user's screen. + // gateway's markup on a user's screen. Nor is a gateway failure the request + // being "rejected": that would blame the user for a cause nobody sent. it("refuses to echo an HTML error page", async () => { global.fetch = jest .fn() .mockResolvedValue(raw(502, "502 Bad Gateway")) as never; - await expect(submitCustomerKyc("veil_abc", "12345678901")).rejects.toThrow( - "Linq rejected the request", - ); + const err = await submitCustomerKyc("veil_abc", "12345678901").catch((e) => e); + expect(err.message).toBe("Linq returned an error without giving a reason (HTTP 502)"); + expect(err.message).not.toContain(""); }); // Three testers in a row met the bare fallback on a NIN check, which says @@ -451,11 +452,20 @@ describe("call() error parsing", () => { expect(err.message).toBe("Name does not match NIN record"); }); + // The fallback states what is known — that it failed, and the status — and + // nothing more. On a 5xx "rejected the request" would imply the request was + // at fault, which is a cause the provider never sent. (W087) it("falls back only when the body names no reason at all", async () => { global.fetch = jest.fn().mockResolvedValue(raw(500, JSON.stringify({ ok: false }))) as never; const err = await submitCustomerKyc("veil_abc", "12345678901").catch((e) => e); expect(err).toBeInstanceOf(LinqError); - expect(err.message).toBe("Linq rejected the request"); + expect(err.message).toBe("Linq returned an error without giving a reason (HTTP 500)"); expect(err.code).toBeUndefined(); }); + + it("still calls a reasonless 4xx a rejection, because it is one", async () => { + global.fetch = jest.fn().mockResolvedValue(raw(422, JSON.stringify({ ok: false }))) as never; + const err = await submitCustomerKyc("veil_abc", "12345678901").catch((e) => e); + expect(err.message).toBe("Linq rejected the request"); + }); }); diff --git a/src/api/ngn.ts b/src/api/ngn.ts index 60188e95b..c71026435 100644 --- a/src/api/ngn.ts +++ b/src/api/ngn.ts @@ -413,8 +413,14 @@ export function createNgnRouter( } // Shape-checked here so an obvious typo is answered without spending a // verification attempt, which is rate-limited on Linq's side. + // + // It carries the provider's own documented code for the same condition. + // The clients branch on `invalid_nin_format`, and this check is where that + // condition is caught almost every time — without the code, the branch the + // apps wrote for it was unreachable in practice and a typo fell through to + // their generic 400 copy. Same condition, same code: not a mapping. if (!/^\d{11}$/.test(nin)) { - res.status(400).json({ error: "A NIN is 11 digits" }); + res.status(400).json({ error: "A NIN is 11 digits", code: "invalid_nin_format" }); return; } diff --git a/src/linq/onramp.ts b/src/linq/onramp.ts index a13645e17..36f85b081 100644 --- a/src/linq/onramp.ts +++ b/src/linq/onramp.ts @@ -54,6 +54,24 @@ function firstString(...values: unknown[]): string | undefined { return undefined; } +/** + * The message for a failure whose body gave no reason a person could read. + * + * It says what is known and nothing more. A 4xx is the provider refusing the + * request, so "rejected" is simply true; a 5xx is the provider failing, and + * calling that a rejection tells the user their request was at fault — a cause + * nobody sent. The KYC screens branch on `code`, not on this sentence, so the + * sentence must never be the thing that implies one. The status and any code + * ride along because they are facts, and they are what diagnoses the next one. + */ +export function unexplainedFailure(status: number, code?: string): string { + if (status >= 400 && status < 500) { + return code ? `Linq rejected the request (${code})` : "Linq rejected the request"; + } + const detail = code ? `HTTP ${status}, ${code}` : `HTTP ${status}`; + return `Linq returned an error without giving a reason (${detail})`; +} + /** * One logical operation's whole budget, retries included — the same ceiling * `client.ts` uses, and for the same reason: a server that answers a person @@ -181,10 +199,8 @@ async function call(path: string, init: CallInit = {}): Promise { if (!response.ok && plain && plain.length <= 200 && !/^\s*(path: string, init: CallInit = {}): Promise { // difference between diagnosing the next one and asking them to try again. const message = firstString(body?.["message"], body?.["error"], body?.["msg"], body?.["detail"], body?.["reason"]) ?? - (code ? `Linq rejected the request (${code})` : "Linq rejected the request"); + unexplainedFailure(response.status, code); // Linq names the reference an already-registered identity belongs to, in a // `customerRef` field beside the code. Reading it is what lets // `provisionCustomer` adopt the existing reference instead of stranding the