From 7b9546288b449629597ab7910e1e79c4d427a73a Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 20 Oct 2026 11:29:19 -0400 Subject: [PATCH 1/8] Add tests for retrying HTTP 429/5xx responses Queries bail on any HTTP error and mutations retry every HTTP error, including 4xx, and surface graphql-request's raw ClientError. Co-Authored-By: Claude Fable 5.1 --- tests/retries.test.ts | 133 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 3 deletions(-) diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 422572e..81a3f95 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -1,8 +1,12 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { setupServer } from "msw/node"; -import { http, graphql, HttpResponse } from "msw"; +import { http, graphql, HttpResponse, type JsonBodyType } from "msw"; import { createFragmentClient } from "../src/client.js"; -import { BadRequestError, InternalError } from "../src/errors.js"; +import { + BadRequestError, + FragmentError, + InternalError, +} from "../src/errors.js"; const fragmentApi = graphql.link(`https://fragment-api.example.com/graphql`); @@ -45,6 +49,10 @@ beforeAll(() => { server.listen(); }); +afterEach(() => { + server.resetHandlers(); +}); + describe("Client retries", () => { it("handles BRE retryable errors according to the retry config provided", async () => { const onRetry = vi.fn(); @@ -149,3 +157,122 @@ describe("Client retries", () => { expect(onRetry).not.toHaveBeenCalled(); }); }); + +describe("HTTP error retries", () => { + const apiUrl = "https://fragment-api.example.com/graphql"; + const makeClient = (onRetry: () => void) => + createFragmentClient({ + params: { + clientId: "test", + clientSecret: "test", + scope: "*", + authUrl: "https://fragment-auth.example.com/oauth2/token", + apiUrl, + }, + retryConfig: { retries: 3, minTimeout: 1, maxTimeout: 1, onRetry }, + }); + + const respondWithStatus = (status: number, body: JsonBodyType) => + http.post(apiUrl, () => HttpResponse.json(body, { status })); + + const serviceUnavailable = () => + respondWithStatus(503, { message: "Service Unavailable" }); + + const unknownField = () => + respondWithStatus(400, { errors: [{ message: "Unknown field" }] }); + + const addLedgerEntryInput = { + ik: "add-ledger-entry-ik", + ledgerIk: "ledger-ik", + type: "runtime-entry", + parameters: {}, + }; + + it("retries a query that fails with a 5xx and surfaces the status once exhausted", async () => { + server.use(serviceUnavailable()); + const onRetry = vi.fn(); + + const error = await makeClient(onRetry) + .getLedger({ ik: "ledger-ik" }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(FragmentError); + expect((error as FragmentError).status).toEqual(503); + expect(onRetry).toHaveBeenCalledTimes(3); + }); + + it("returns the data once a retried query succeeds", async () => { + const ledger = { id: "ledger-id", ik: "ledger-ik", name: "Ledger" }; + let attempts = 0; + server.use( + http.post(apiUrl, () => { + attempts += 1; + return attempts <= 2 + ? HttpResponse.json( + { message: "Service Unavailable" }, + { status: 503 }, + ) + : undefined; + }), + fragmentApi.query("getLedger", () => + HttpResponse.json({ data: { ledger } }), + ), + ); + const onRetry = vi.fn(); + + const result = await makeClient(onRetry).getLedger({ ik: "ledger-ik" }); + + expect(result.ledger).toEqual(ledger); + expect(onRetry).toHaveBeenCalledTimes(2); + }); + + it("retries a query that is rate limited", async () => { + server.use(respondWithStatus(429, { message: "Too Many Requests" })); + const onRetry = vi.fn(); + + const error = await makeClient(onRetry) + .getLedger({ ik: "ledger-ik" }) + .catch((e: unknown) => e); + + expect((error as FragmentError).status).toEqual(429); + expect(onRetry).toHaveBeenCalledTimes(3); + }); + + it("does not retry a query that fails with a 4xx", async () => { + server.use(unknownField()); + const onRetry = vi.fn(); + + const error = await makeClient(onRetry) + .getLedger({ ik: "ledger-ik" }) + .catch((e: unknown) => e); + + expect((error as FragmentError).status).toEqual(400); + expect((error as FragmentError).message).toEqual("Unknown field"); + expect(onRetry).not.toHaveBeenCalled(); + }); + + it("retries a mutation that fails with a 5xx", async () => { + server.use(serviceUnavailable()); + const onRetry = vi.fn(); + + const error = await makeClient(onRetry) + .addLedgerEntry(addLedgerEntryInput) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(FragmentError); + expect((error as FragmentError).status).toEqual(503); + expect(onRetry).toHaveBeenCalledTimes(3); + }); + + it("does not retry a mutation that fails with a 4xx", async () => { + server.use(unknownField()); + const onRetry = vi.fn(); + + const error = await makeClient(onRetry) + .addLedgerEntry(addLedgerEntryInput) + .catch((e: unknown) => e); + + expect((error as FragmentError).status).toEqual(400); + expect(onRetry).not.toHaveBeenCalled(); + }); +}); From ffd22dd8872798055a2967689ab74dc004b63662 Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 20 Oct 2026 11:29:19 -0400 Subject: [PATCH 2/8] Retry HTTP 429 and 5xx responses for queries and mutations API Gateway returns an occasional 503 when its Lambda invoke stalls. The query path passed every ClientError to bail(), so a balance read failed on the first attempt. Both operation types now send the request through one path: 429/5xx throws a FragmentError so async-retry retries it, any other status bails with the same FragmentError. Mutations previously let the raw ClientError escape and retried 4xx responses too. Co-Authored-By: Claude Fable 5.1 --- README.md | 15 ++++++++++++++ src/client.ts | 56 ++++++++++++++++++++++++++------------------------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d7dc91f..3a3964e 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,21 @@ const result = await fragment.listMultiCurrencyLedgerAccountBalances({ assert(result.ledger?.ledgerAccounts?.nodes, "Failed to list ledger accounts"); ``` +## Retries + +The client retries a request that fails with an HTTP 429 or 5xx, and a mutation whose response is an `InternalError` or `BadRequestError` with `retryable: true`. Retries use exponential backoff, 5 retries from 10ms up to 3s by default. Any other failure throws immediately. + +Override the defaults with `retryConfig`, which takes [async-retry](https://github.com/vercel/async-retry) options: + +```typescript +const fragment = createFragmentClient({ + params: { + // ... + }, + retryConfig: { retries: 3, minTimeout: 100, maxTimeout: 2000 }, +}); +``` + ## Using custom queries While the SDK comes with GraphQL queries out of the box, you may want to customize these queries for your product. In order to do that: diff --git a/src/client.ts b/src/client.ts index d3e304c..0ac63f2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,6 +18,9 @@ import { DEFAULT_RETRY_CONFIG, type RetryConfig } from "./retryConfig.js"; type RetryableErrorObject = T & { retryable: boolean }; +const isRetryableHttpStatus = (status: number): boolean => + status === 429 || status >= 500; + const getRetryableField = (error: T): boolean => { if (Object.prototype.hasOwnProperty.call(error, "retryable")) { if (typeof (error as RetryableErrorObject).retryable === "boolean") { @@ -85,8 +88,32 @@ const createRequestWrapper = }; return retry(async (bail): ReturnType => { + if (operationType !== "query" && operationType !== "mutation") { + bail( + new FragmentError({ + message: `Unknown operation type: ${operationType}`, + }), + ); + return {} as never; + } + + let data: Awaited>; + try { + data = await request(requestHeaders); + } catch (error) { + if (!(error instanceof ClientError)) { + bail(error as Error); + return {} as never; + } + const err = new FragmentError({ cause: error }); + if (isRetryableHttpStatus(error.response.status)) { + throw err; + } + bail(err); + return {} as never; + } + if (operationType === "mutation") { - const data = await request(requestHeaders); const dataKeys = Object.keys(data as any); if (dataKeys.length > 1) { bail( @@ -158,33 +185,8 @@ const createRequestWrapper = } break; } - return data; } - - if (operationType === "query") { - try { - const data = await request(requestHeaders); - return data; - } catch (error) { - if (error instanceof ClientError) { - bail( - new FragmentError({ - cause: error, - }) - ); - } else { - bail(error as Error); - } - } - } - - bail( - new FragmentError({ - message: `Unknown operation type: ${operationType}`, - }) - ); - - return {} as never; + return data; }, retryConfig); }; From 1556601176c38a5b93f9b541315e8c9f8c3b2a43 Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 20 Oct 2026 11:38:56 -0400 Subject: [PATCH 3/8] Retry network errors as well A connection reset or DNS failure is as transient as a 503. Errors that are not a ClientError propagate out of the retry callback so async-retry retries them, and the last one is what the caller sees. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/client.ts | 3 +-- tests/retries.test.ts | 13 +++++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a3964e..ff8b52e 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ assert(result.ledger?.ledgerAccounts?.nodes, "Failed to list ledger accounts"); ## Retries -The client retries a request that fails with an HTTP 429 or 5xx, and a mutation whose response is an `InternalError` or `BadRequestError` with `retryable: true`. Retries use exponential backoff, 5 retries from 10ms up to 3s by default. Any other failure throws immediately. +The client retries a request that fails at the network layer or with an HTTP 429 or 5xx, and a mutation whose response is an `InternalError` or `BadRequestError` with `retryable: true`. Retries use exponential backoff, 5 retries from 10ms up to 3s by default. Any other failure throws immediately. Override the defaults with `retryConfig`, which takes [async-retry](https://github.com/vercel/async-retry) options: diff --git a/src/client.ts b/src/client.ts index 0ac63f2..d10f453 100644 --- a/src/client.ts +++ b/src/client.ts @@ -102,8 +102,7 @@ const createRequestWrapper = data = await request(requestHeaders); } catch (error) { if (!(error instanceof ClientError)) { - bail(error as Error); - return {} as never; + throw error; } const err = new FragmentError({ cause: error }); if (isRetryableHttpStatus(error.response.status)) { diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 81a3f95..d77923a 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -226,6 +226,19 @@ describe("HTTP error retries", () => { expect(onRetry).toHaveBeenCalledTimes(2); }); + it("retries a query that fails at the network layer", async () => { + server.use(http.post(apiUrl, () => HttpResponse.error())); + const onRetry = vi.fn(); + + const error = await makeClient(onRetry) + .getLedger({ ik: "ledger-ik" }) + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(FragmentError); + expect(onRetry).toHaveBeenCalledTimes(3); + }); + it("retries a query that is rate limited", async () => { server.use(respondWithStatus(429, { message: "Too Many Requests" })); const onRetry = vi.fn(); From d01de99cd31d4a8db25189e4b0ac27e09a2ccb9a Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 20 Oct 2026 11:39:32 -0400 Subject: [PATCH 4/8] Rebuild dist Co-Authored-By: Claude Fable 5.1 --- dist/cjs/src/client.js | 45 +++++++++++++++--------------- dist/cjs/types/src/client.d.ts.map | 2 +- dist/esm/src/client.js | 45 +++++++++++++++--------------- dist/esm/types/src/client.d.ts.map | 2 +- 4 files changed, 48 insertions(+), 46 deletions(-) diff --git a/dist/cjs/src/client.js b/dist/cjs/src/client.js index 42bb45e..f10cfbc 100644 --- a/dist/cjs/src/client.js +++ b/dist/cjs/src/client.js @@ -20,6 +20,7 @@ const version_js_1 = require("../generated/version.js"); const getToken_js_1 = require("./getToken.js"); const errors_js_1 = require("./errors.js"); const retryConfig_js_1 = require("./retryConfig.js"); +const isRetryableHttpStatus = (status) => status === 429 || status >= 500; const getRetryableField = (error) => { if (Object.prototype.hasOwnProperty.call(error, "retryable")) { if (typeof error.retryable === "boolean") { @@ -58,8 +59,28 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => (request, oper }; return (0, async_retry_1.default)((bail) => __awaiter(void 0, void 0, void 0, function* () { var _a; + if (operationType !== "query" && operationType !== "mutation") { + bail(new errors_js_1.FragmentError({ + message: `Unknown operation type: ${operationType}`, + })); + return {}; + } + let data; + try { + data = yield request(requestHeaders); + } + catch (error) { + if (!(error instanceof graphql_request_1.ClientError)) { + throw error; + } + const err = new errors_js_1.FragmentError({ cause: error }); + if (isRetryableHttpStatus(error.response.status)) { + throw err; + } + bail(err); + return {}; + } if (operationType === "mutation") { - const data = yield request(requestHeaders); const dataKeys = Object.keys(data); if (dataKeys.length > 1) { bail(new errors_js_1.FragmentError({ @@ -126,28 +147,8 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => (request, oper } break; } - return data; - } - if (operationType === "query") { - try { - const data = yield request(requestHeaders); - return data; - } - catch (error) { - if (error instanceof graphql_request_1.ClientError) { - bail(new errors_js_1.FragmentError({ - cause: error, - })); - } - else { - bail(error); - } - } } - bail(new errors_js_1.FragmentError({ - message: `Unknown operation type: ${operationType}`, - })); - return {}; + return data; }), retryConfig); }); const createFragmentClient = ({ params, getSdk, retryConfig = retryConfig_js_1.DEFAULT_RETRY_CONFIG, }) => { diff --git a/dist/cjs/types/src/client.d.ts.map b/dist/cjs/types/src/client.d.ts.map index afad477..9c787ef 100644 --- a/dist/cjs/types/src/client.d.ts.map +++ b/dist/cjs/types/src/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAa1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAyJF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAgB1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAuJF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file diff --git a/dist/esm/src/client.js b/dist/esm/src/client.js index 22fcc7c..2ce2a5a 100644 --- a/dist/esm/src/client.js +++ b/dist/esm/src/client.js @@ -5,6 +5,7 @@ import { version } from "../generated/version.js"; import { getToken } from "./getToken.js"; import { AddLedgerEntriesError, BadRequestError, FragmentError, InternalError, } from "./errors.js"; import { DEFAULT_RETRY_CONFIG } from "./retryConfig.js"; +const isRetryableHttpStatus = (status) => status === 429 || status >= 500; const getRetryableField = (error) => { if (Object.prototype.hasOwnProperty.call(error, "retryable")) { if (typeof error.retryable === "boolean") { @@ -42,8 +43,28 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => async (request ["X-Fragment-Client"]: `node-client@${version}`, }; return retry(async (bail) => { + if (operationType !== "query" && operationType !== "mutation") { + bail(new FragmentError({ + message: `Unknown operation type: ${operationType}`, + })); + return {}; + } + let data; + try { + data = await request(requestHeaders); + } + catch (error) { + if (!(error instanceof ClientError)) { + throw error; + } + const err = new FragmentError({ cause: error }); + if (isRetryableHttpStatus(error.response.status)) { + throw err; + } + bail(err); + return {}; + } if (operationType === "mutation") { - const data = await request(requestHeaders); const dataKeys = Object.keys(data); if (dataKeys.length > 1) { bail(new FragmentError({ @@ -111,28 +132,8 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => async (request } break; } - return data; - } - if (operationType === "query") { - try { - const data = await request(requestHeaders); - return data; - } - catch (error) { - if (error instanceof ClientError) { - bail(new FragmentError({ - cause: error, - })); - } - else { - bail(error); - } - } } - bail(new FragmentError({ - message: `Unknown operation type: ${operationType}`, - })); - return {}; + return data; }, retryConfig); }; export const createFragmentClient = ({ params, getSdk, retryConfig = DEFAULT_RETRY_CONFIG, }) => { diff --git a/dist/esm/types/src/client.d.ts.map b/dist/esm/types/src/client.d.ts.map index afad477..9c787ef 100644 --- a/dist/esm/types/src/client.d.ts.map +++ b/dist/esm/types/src/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAa1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAyJF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAgB1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAuJF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file From 32fb43d3b1c637012c9a073464afb3bfe84a0a1e Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 27 Oct 2026 10:46:14 -0400 Subject: [PATCH 5/8] Classify request outcomes once before retrying Each attempt resolves to either data or an error tagged retryable, so the retry callback has one throw site, one bail site and no fall-through after bailing on a multi-key mutation response. The mutation payload is typed instead of cast through `any`, and the unknown-operation-type guard runs before retry() starts. Tests await the rejection matchers directly instead of capturing the error with `.catch`, share one `makeClient`, and cover a mutation that succeeds after two 503s. Co-Authored-By: Claude Fable 5.1 --- dist/cjs/src/client.js | 166 ++++++++++-------------- dist/cjs/types/src/client.d.ts.map | 2 +- dist/esm/src/client.js | 165 ++++++++++-------------- dist/esm/types/src/client.d.ts.map | 2 +- src/client.ts | 199 +++++++++++++---------------- tests/retries.test.ts | 199 ++++++++++++++--------------- 6 files changed, 325 insertions(+), 408 deletions(-) diff --git a/dist/cjs/src/client.js b/dist/cjs/src/client.js index f10cfbc..609ae26 100644 --- a/dist/cjs/src/client.js +++ b/dist/cjs/src/client.js @@ -21,15 +21,69 @@ const getToken_js_1 = require("./getToken.js"); const errors_js_1 = require("./errors.js"); const retryConfig_js_1 = require("./retryConfig.js"); const isRetryableHttpStatus = (status) => status === 429 || status >= 500; -const getRetryableField = (error) => { - if (Object.prototype.hasOwnProperty.call(error, "retryable")) { - if (typeof error.retryable === "boolean") { - return error.retryable; - } +const classifyRequestError = (error) => error instanceof graphql_request_1.ClientError + ? { + error: new errors_js_1.FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + } + : { error: error, retryable: true }; +const classifyMutationData = (data, operationName) => { + var _a; + const payloads = data; + const dataKeys = Object.keys(payloads); + if (dataKeys.length > 1) { + return { + error: new errors_js_1.FragmentError({ + message: `You can only invoke a single mutation with a mutation operation. You specified ${dataKeys.length} for operation ${operationName}.`, + }), + retryable: false, + }; + } + const [graphQlOperationName] = dataKeys; + const payload = payloads[graphQlOperationName]; + const { __typename: typeName, code, message } = payload; + const retryable = payload.retryable === true; + // `cause` carries the raw GraphQL error payload, which is not an Error instance. + const cause = payload; + switch (typeName) { + case "InternalError": + return { error: new errors_js_1.InternalError({ cause, message }), retryable }; + case "AddLedgerEntriesError": + // The errors for each Ledger Entry responsible for the failure are + // what tell a caller which entries to fix, so they are surfaced + // rather than collapsed into the top-level message. + return { + error: new errors_js_1.AddLedgerEntriesError({ + code, + cause, + message, + errors: (_a = payload.errors) !== null && _a !== void 0 ? _a : [], + }), + retryable, + }; + case "BadRequestError": + return { + error: new errors_js_1.BadRequestError({ code, cause, message }), + retryable, + }; + default: + return typeName.endsWith("Error") + ? { + error: new errors_js_1.FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause, + }), + retryable: false, + } + : { data }; } - return false; }; const createRequestWrapper = (tokenCache, params, retryConfig) => (request, operationName, operationType) => __awaiter(void 0, void 0, void 0, function* () { + if (operationType !== "query" && operationType !== "mutation") { + throw new errors_js_1.FragmentError({ + message: `Unknown operation type: ${operationType}`, + }); + } const { clientId, clientSecret, scope, authUrl } = params; const updateToken = () => __awaiter(void 0, void 0, void 0, function* () { const { access_token, expiry_time } = yield (0, getToken_js_1.getToken)({ @@ -58,97 +112,15 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => (request, oper ["X-Fragment-Client"]: `node-client@${version_js_1.version}`, }; return (0, async_retry_1.default)((bail) => __awaiter(void 0, void 0, void 0, function* () { - var _a; - if (operationType !== "query" && operationType !== "mutation") { - bail(new errors_js_1.FragmentError({ - message: `Unknown operation type: ${operationType}`, - })); - return {}; - } - let data; - try { - data = yield request(requestHeaders); - } - catch (error) { - if (!(error instanceof graphql_request_1.ClientError)) { - throw error; - } - const err = new errors_js_1.FragmentError({ cause: error }); - if (isRetryableHttpStatus(error.response.status)) { - throw err; - } - bail(err); - return {}; - } - if (operationType === "mutation") { - const dataKeys = Object.keys(data); - if (dataKeys.length > 1) { - bail(new errors_js_1.FragmentError({ - message: `You can only invoke a single mutation with a mutation operation. You specified ${dataKeys.length} for operation ${operationName}.`, - })); - } - const [graphQlOperationName] = dataKeys; - const typeName = data[graphQlOperationName] - .__typename; - const { code, message } = data[graphQlOperationName]; - const retryable = getRetryableField(data[graphQlOperationName]); - switch (typeName) { - case "InternalError": { - const err = new errors_js_1.InternalError({ - cause: data[graphQlOperationName], - message, - }); - if (!retryable) { - bail(err); - } - else { - throw err; - } - break; - } - case "AddLedgerEntriesError": { - // The errors for each Ledger Entry responsible for the failure are - // what tell a caller which entries to fix, so they are surfaced - // rather than collapsed into the top-level message. - const err = new errors_js_1.AddLedgerEntriesError({ - code, - cause: data[graphQlOperationName], - message, - errors: ((_a = data[graphQlOperationName].errors) !== null && _a !== void 0 ? _a : []), - }); - if (!retryable) { - bail(err); - } - else { - throw err; - } - break; - } - case "BadRequestError": { - const err = new errors_js_1.BadRequestError({ - code: code, - cause: data[graphQlOperationName], - message, - }); - if (!retryable) { - bail(err); - } - else { - throw err; - } - break; - } - default: - if (typeName.endsWith("Error")) { - bail(new errors_js_1.FragmentError({ - message: `Encountered unsupported error type: ${typeName}`, - cause: data[graphQlOperationName], - })); - } - break; - } - } - return data; + const attempt = yield request(requestHeaders).then((data) => operationType === "mutation" + ? classifyMutationData(data, operationName) + : { data }, classifyRequestError); + if ("data" in attempt) + return attempt.data; + if (attempt.retryable) + throw attempt.error; + bail(attempt.error); + return undefined; }), retryConfig); }); const createFragmentClient = ({ params, getSdk, retryConfig = retryConfig_js_1.DEFAULT_RETRY_CONFIG, }) => { diff --git a/dist/cjs/types/src/client.d.ts.map b/dist/cjs/types/src/client.d.ts.map index 9c787ef..0a9c9b1 100644 --- a/dist/cjs/types/src/client.d.ts.map +++ b/dist/cjs/types/src/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAgB1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAuJF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AA8E1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAsEF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file diff --git a/dist/esm/src/client.js b/dist/esm/src/client.js index 2ce2a5a..07bb733 100644 --- a/dist/esm/src/client.js +++ b/dist/esm/src/client.js @@ -6,15 +6,68 @@ import { getToken } from "./getToken.js"; import { AddLedgerEntriesError, BadRequestError, FragmentError, InternalError, } from "./errors.js"; import { DEFAULT_RETRY_CONFIG } from "./retryConfig.js"; const isRetryableHttpStatus = (status) => status === 429 || status >= 500; -const getRetryableField = (error) => { - if (Object.prototype.hasOwnProperty.call(error, "retryable")) { - if (typeof error.retryable === "boolean") { - return error.retryable; - } +const classifyRequestError = (error) => error instanceof ClientError + ? { + error: new FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + } + : { error: error, retryable: true }; +const classifyMutationData = (data, operationName) => { + const payloads = data; + const dataKeys = Object.keys(payloads); + if (dataKeys.length > 1) { + return { + error: new FragmentError({ + message: `You can only invoke a single mutation with a mutation operation. You specified ${dataKeys.length} for operation ${operationName}.`, + }), + retryable: false, + }; + } + const [graphQlOperationName] = dataKeys; + const payload = payloads[graphQlOperationName]; + const { __typename: typeName, code, message } = payload; + const retryable = payload.retryable === true; + // `cause` carries the raw GraphQL error payload, which is not an Error instance. + const cause = payload; + switch (typeName) { + case "InternalError": + return { error: new InternalError({ cause, message }), retryable }; + case "AddLedgerEntriesError": + // The errors for each Ledger Entry responsible for the failure are + // what tell a caller which entries to fix, so they are surfaced + // rather than collapsed into the top-level message. + return { + error: new AddLedgerEntriesError({ + code, + cause, + message, + errors: payload.errors ?? [], + }), + retryable, + }; + case "BadRequestError": + return { + error: new BadRequestError({ code, cause, message }), + retryable, + }; + default: + return typeName.endsWith("Error") + ? { + error: new FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause, + }), + retryable: false, + } + : { data }; } - return false; }; const createRequestWrapper = (tokenCache, params, retryConfig) => async (request, operationName, operationType) => { + if (operationType !== "query" && operationType !== "mutation") { + throw new FragmentError({ + message: `Unknown operation type: ${operationType}`, + }); + } const { clientId, clientSecret, scope, authUrl } = params; const updateToken = async () => { const { access_token, expiry_time } = await getToken({ @@ -43,97 +96,15 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => async (request ["X-Fragment-Client"]: `node-client@${version}`, }; return retry(async (bail) => { - if (operationType !== "query" && operationType !== "mutation") { - bail(new FragmentError({ - message: `Unknown operation type: ${operationType}`, - })); - return {}; - } - let data; - try { - data = await request(requestHeaders); - } - catch (error) { - if (!(error instanceof ClientError)) { - throw error; - } - const err = new FragmentError({ cause: error }); - if (isRetryableHttpStatus(error.response.status)) { - throw err; - } - bail(err); - return {}; - } - if (operationType === "mutation") { - const dataKeys = Object.keys(data); - if (dataKeys.length > 1) { - bail(new FragmentError({ - message: `You can only invoke a single mutation with a mutation operation. You specified ${dataKeys.length} for operation ${operationName}.`, - })); - } - const [graphQlOperationName] = dataKeys; - const typeName = data[graphQlOperationName] - .__typename; - const { code, message } = data[graphQlOperationName]; - const retryable = getRetryableField(data[graphQlOperationName]); - switch (typeName) { - case "InternalError": { - const err = new InternalError({ - cause: data[graphQlOperationName], - message, - }); - if (!retryable) { - bail(err); - } - else { - throw err; - } - break; - } - case "AddLedgerEntriesError": { - // The errors for each Ledger Entry responsible for the failure are - // what tell a caller which entries to fix, so they are surfaced - // rather than collapsed into the top-level message. - const err = new AddLedgerEntriesError({ - code, - cause: data[graphQlOperationName], - message, - errors: (data[graphQlOperationName].errors ?? - []), - }); - if (!retryable) { - bail(err); - } - else { - throw err; - } - break; - } - case "BadRequestError": { - const err = new BadRequestError({ - code: code, - cause: data[graphQlOperationName], - message, - }); - if (!retryable) { - bail(err); - } - else { - throw err; - } - break; - } - default: - if (typeName.endsWith("Error")) { - bail(new FragmentError({ - message: `Encountered unsupported error type: ${typeName}`, - cause: data[graphQlOperationName], - })); - } - break; - } - } - return data; + const attempt = await request(requestHeaders).then((data) => operationType === "mutation" + ? classifyMutationData(data, operationName) + : { data }, classifyRequestError); + if ("data" in attempt) + return attempt.data; + if (attempt.retryable) + throw attempt.error; + bail(attempt.error); + return undefined; }, retryConfig); }; export const createFragmentClient = ({ params, getSdk, retryConfig = DEFAULT_RETRY_CONFIG, }) => { diff --git a/dist/esm/types/src/client.d.ts.map b/dist/esm/types/src/client.d.ts.map index 9c787ef..0a9c9b1 100644 --- a/dist/esm/types/src/client.d.ts.map +++ b/dist/esm/types/src/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAgB1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAuJF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AA8E1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAsEF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index d10f453..df7b420 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,18 +16,80 @@ import { } from "./errors.js"; import { DEFAULT_RETRY_CONFIG, type RetryConfig } from "./retryConfig.js"; -type RetryableErrorObject = T & { retryable: boolean }; +type Attempt = { data: T } | { error: Error; retryable: boolean }; + +type MutationErrorPayload = { + __typename: string; + code?: string; + message?: string; + retryable?: boolean; + errors?: LedgerEntryBatchError[]; +}; const isRetryableHttpStatus = (status: number): boolean => status === 429 || status >= 500; -const getRetryableField = (error: T): boolean => { - if (Object.prototype.hasOwnProperty.call(error, "retryable")) { - if (typeof (error as RetryableErrorObject).retryable === "boolean") { - return (error as RetryableErrorObject).retryable; - } +const classifyRequestError = (error: unknown): Attempt => + error instanceof ClientError + ? { + error: new FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + } + : { error: error as Error, retryable: true }; + +const classifyMutationData = ( + data: T, + operationName: string, +): Attempt => { + const payloads = data as Record; + const dataKeys = Object.keys(payloads); + if (dataKeys.length > 1) { + return { + error: new FragmentError({ + message: `You can only invoke a single mutation with a mutation operation. You specified ${dataKeys.length} for operation ${operationName}.`, + }), + retryable: false, + }; + } + const [graphQlOperationName] = dataKeys; + const payload = payloads[graphQlOperationName]; + const { __typename: typeName, code, message } = payload; + const retryable = payload.retryable === true; + // `cause` carries the raw GraphQL error payload, which is not an Error instance. + const cause = payload as unknown as Error; + + switch (typeName) { + case "InternalError": + return { error: new InternalError({ cause, message }), retryable }; + case "AddLedgerEntriesError": + // The errors for each Ledger Entry responsible for the failure are + // what tell a caller which entries to fix, so they are surfaced + // rather than collapsed into the top-level message. + return { + error: new AddLedgerEntriesError({ + code, + cause, + message, + errors: payload.errors ?? [], + }), + retryable, + }; + case "BadRequestError": + return { + error: new BadRequestError({ code, cause, message }), + retryable, + }; + default: + return typeName.endsWith("Error") + ? { + error: new FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause, + }), + retryable: false, + } + : { data }; } - return false; }; export type FragmentClient = ReturnType; @@ -51,9 +113,15 @@ const createRequestWrapper = ( tokenCache: TokenCache, params: CreateFragmentClientParams, - retryConfig: RetryConfig + retryConfig: RetryConfig, ): SdkFunctionWrapper => async (request, operationName, operationType) => { + if (operationType !== "query" && operationType !== "mutation") { + throw new FragmentError({ + message: `Unknown operation type: ${operationType}`, + }); + } + const { clientId, clientSecret, scope, authUrl } = params; const updateToken = async () => { const { access_token, expiry_time } = await getToken({ @@ -87,105 +155,18 @@ const createRequestWrapper = ["X-Fragment-Client"]: `node-client@${version}`, }; - return retry(async (bail): ReturnType => { - if (operationType !== "query" && operationType !== "mutation") { - bail( - new FragmentError({ - message: `Unknown operation type: ${operationType}`, - }), - ); - return {} as never; - } - - let data: Awaited>; - try { - data = await request(requestHeaders); - } catch (error) { - if (!(error instanceof ClientError)) { - throw error; - } - const err = new FragmentError({ cause: error }); - if (isRetryableHttpStatus(error.response.status)) { - throw err; - } - bail(err); - return {} as never; - } - - if (operationType === "mutation") { - const dataKeys = Object.keys(data as any); - if (dataKeys.length > 1) { - bail( - new FragmentError({ - message: `You can only invoke a single mutation with a mutation operation. You specified ${dataKeys.length} for operation ${operationName}.`, - }) - ); - } - const [graphQlOperationName] = dataKeys; - const typeName = (data as any)[graphQlOperationName] - .__typename as string; - const { code, message } = (data as any)[graphQlOperationName]; - const retryable = getRetryableField( - (data as any)[graphQlOperationName] as any - ); - - switch (typeName) { - case "InternalError": { - const err = new InternalError({ - cause: (data as any)[graphQlOperationName], - message, - }); - if (!retryable) { - bail(err); - } else { - throw err; - } - break; - } - case "AddLedgerEntriesError": { - // The errors for each Ledger Entry responsible for the failure are - // what tell a caller which entries to fix, so they are surfaced - // rather than collapsed into the top-level message. - const err = new AddLedgerEntriesError({ - code, - cause: (data as any)[graphQlOperationName], - message, - errors: ((data as any)[graphQlOperationName].errors ?? - []) as LedgerEntryBatchError[], - }); - if (!retryable) { - bail(err); - } else { - throw err; - } - break; - } - case "BadRequestError": { - const err = new BadRequestError({ - code: code, - cause: (data as any)[graphQlOperationName], - message, - }); - if (!retryable) { - bail(err); - } else { - throw err; - } - break; - } - default: - if (typeName.endsWith("Error")) { - bail( - new FragmentError({ - message: `Encountered unsupported error type: ${typeName}`, - cause: (data as any)[graphQlOperationName], - }) - ); - } - break; - } - } - return data; + return retry(async (bail) => { + const attempt = await request(requestHeaders).then( + (data) => + operationType === "mutation" + ? classifyMutationData(data, operationName) + : { data }, + classifyRequestError, + ); + if ("data" in attempt) return attempt.data; + if (attempt.retryable) throw attempt.error; + bail(attempt.error); + return undefined as never; }, retryConfig); }; @@ -208,7 +189,7 @@ type CreateFragmentClientResult< export const createFragmentClient = < T extends ( client: GraphQLClient, - wrapper: SdkFunctionWrapper + wrapper: SdkFunctionWrapper, ) => any = typeof getDefaultSdk, >({ params, @@ -227,6 +208,6 @@ export const createFragmentClient = < } return getDefaultSdk( graphql, - requestWrapper + requestWrapper, ) as CreateFragmentClientResult; }; diff --git a/tests/retries.test.ts b/tests/retries.test.ts index d77923a..4651bcf 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -7,8 +7,10 @@ import { FragmentError, InternalError, } from "../src/errors.js"; +import type { RetryConfig } from "../src/retryConfig.js"; -const fragmentApi = graphql.link(`https://fragment-api.example.com/graphql`); +const apiUrl = "https://fragment-api.example.com/graphql"; +const fragmentApi = graphql.link(apiUrl); export const handlers = [ fragmentApi.mutation("storeSchema", () => { @@ -53,23 +55,33 @@ afterEach(() => { server.resetHandlers(); }); +const makeClient = (retryConfig?: RetryConfig) => + createFragmentClient({ + params: { + clientId: "test", + clientSecret: "test", + scope: "*", + authUrl: "https://fragment-auth.example.com/oauth2/token", + apiUrl, + }, + retryConfig, + }); + +const addLedgerEntryInput = { + ik: "add-ledger-entry-ik", + ledgerIk: "ledger-ik", + type: "runtime-entry", + parameters: {}, +}; + describe("Client retries", () => { it("handles BRE retryable errors according to the retry config provided", async () => { const onRetry = vi.fn(); - const client = createFragmentClient({ - params: { - clientId: "test", - clientSecret: "test", - scope: "*", - authUrl: "https://fragment-auth.example.com/oauth2/token", - apiUrl: "https://fragment-api.example.com/graphql", - }, - retryConfig: { - retries: 5, - minTimeout: 10, - maxTimeout: 100, - onRetry, - }, + const client = makeClient({ + retries: 5, + minTimeout: 10, + maxTimeout: 100, + onRetry, }); try { @@ -87,23 +99,10 @@ describe("Client retries", () => { it("skips retries if the BRE response has retryable set to false", async () => { const onRetry = vi.fn(); - const client = createFragmentClient({ - params: { - clientId: "test", - clientSecret: "test", - scope: "*", - authUrl: "https://fragment-auth.example.com/oauth2/token", - apiUrl: "https://fragment-api.example.com/graphql", - }, - }); + const client = makeClient(); try { - await client.addLedgerEntry({ - ik: "add-ledger-entry-ik", - ledgerIk: "ledger-ik", - type: "runtime-entry", - parameters: {}, - }); + await client.addLedgerEntry(addLedgerEntryInput); } catch (e) {} expect(onRetry).toHaveBeenCalledTimes(0); @@ -128,25 +127,16 @@ describe("Client retries", () => { ); const onRetry = vi.fn(); - const client = createFragmentClient({ - params: { - clientId: "test", - clientSecret: "test", - scope: "*", - authUrl: "https://fragment-auth.example.com/oauth2/token", - apiUrl: "https://fragment-api.example.com/graphql", - }, - retryConfig: { retries: 2, minTimeout: 1, maxTimeout: 1, onRetry }, + const client = makeClient({ + retries: 2, + minTimeout: 1, + maxTimeout: 1, + onRetry, }); let thrown: unknown; try { - await client.addLedgerEntry({ - ik: "add-ledger-entry-ik", - ledgerIk: "ledger-ik", - type: "runtime-entry", - parameters: {}, - }); + await client.addLedgerEntry(addLedgerEntryInput); } catch (error) { thrown = error; } @@ -159,18 +149,8 @@ describe("Client retries", () => { }); describe("HTTP error retries", () => { - const apiUrl = "https://fragment-api.example.com/graphql"; - const makeClient = (onRetry: () => void) => - createFragmentClient({ - params: { - clientId: "test", - clientSecret: "test", - scope: "*", - authUrl: "https://fragment-auth.example.com/oauth2/token", - apiUrl, - }, - retryConfig: { retries: 3, minTimeout: 1, maxTimeout: 1, onRetry }, - }); + const makeRetryingClient = (onRetry: () => void) => + makeClient({ retries: 3, minTimeout: 1, maxTimeout: 1, onRetry }); const respondWithStatus = (status: number, body: JsonBodyType) => http.post(apiUrl, () => HttpResponse.json(body, { status })); @@ -178,49 +158,43 @@ describe("HTTP error retries", () => { const serviceUnavailable = () => respondWithStatus(503, { message: "Service Unavailable" }); + const serviceUnavailableTwice = () => { + let attempts = 0; + return http.post(apiUrl, () => { + attempts += 1; + return attempts <= 2 + ? HttpResponse.json({ message: "Service Unavailable" }, { status: 503 }) + : undefined; + }); + }; + const unknownField = () => respondWithStatus(400, { errors: [{ message: "Unknown field" }] }); - const addLedgerEntryInput = { - ik: "add-ledger-entry-ik", - ledgerIk: "ledger-ik", - type: "runtime-entry", - parameters: {}, - }; - it("retries a query that fails with a 5xx and surfaces the status once exhausted", async () => { server.use(serviceUnavailable()); const onRetry = vi.fn(); - const error = await makeClient(onRetry) - .getLedger({ ik: "ledger-ik" }) - .catch((e: unknown) => e); + const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); - expect(error).toBeInstanceOf(FragmentError); - expect((error as FragmentError).status).toEqual(503); + await expect(promise).rejects.toThrow(FragmentError); + await expect(promise).rejects.toHaveProperty("status", 503); expect(onRetry).toHaveBeenCalledTimes(3); }); it("returns the data once a retried query succeeds", async () => { const ledger = { id: "ledger-id", ik: "ledger-ik", name: "Ledger" }; - let attempts = 0; server.use( - http.post(apiUrl, () => { - attempts += 1; - return attempts <= 2 - ? HttpResponse.json( - { message: "Service Unavailable" }, - { status: 503 }, - ) - : undefined; - }), + serviceUnavailableTwice(), fragmentApi.query("getLedger", () => HttpResponse.json({ data: { ledger } }), ), ); const onRetry = vi.fn(); - const result = await makeClient(onRetry).getLedger({ ik: "ledger-ik" }); + const result = await makeRetryingClient(onRetry).getLedger({ + ik: "ledger-ik", + }); expect(result.ledger).toEqual(ledger); expect(onRetry).toHaveBeenCalledTimes(2); @@ -230,12 +204,10 @@ describe("HTTP error retries", () => { server.use(http.post(apiUrl, () => HttpResponse.error())); const onRetry = vi.fn(); - const error = await makeClient(onRetry) - .getLedger({ ik: "ledger-ik" }) - .catch((e: unknown) => e); + const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); - expect(error).toBeInstanceOf(Error); - expect(error).not.toBeInstanceOf(FragmentError); + await expect(promise).rejects.toThrow(Error); + await expect(promise).rejects.not.toBeInstanceOf(FragmentError); expect(onRetry).toHaveBeenCalledTimes(3); }); @@ -243,11 +215,10 @@ describe("HTTP error retries", () => { server.use(respondWithStatus(429, { message: "Too Many Requests" })); const onRetry = vi.fn(); - const error = await makeClient(onRetry) - .getLedger({ ik: "ledger-ik" }) - .catch((e: unknown) => e); + const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); - expect((error as FragmentError).status).toEqual(429); + await expect(promise).rejects.toThrow(FragmentError); + await expect(promise).rejects.toHaveProperty("status", 429); expect(onRetry).toHaveBeenCalledTimes(3); }); @@ -255,12 +226,10 @@ describe("HTTP error retries", () => { server.use(unknownField()); const onRetry = vi.fn(); - const error = await makeClient(onRetry) - .getLedger({ ik: "ledger-ik" }) - .catch((e: unknown) => e); + const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); - expect((error as FragmentError).status).toEqual(400); - expect((error as FragmentError).message).toEqual("Unknown field"); + await expect(promise).rejects.toHaveProperty("status", 400); + await expect(promise).rejects.toHaveProperty("message", "Unknown field"); expect(onRetry).not.toHaveBeenCalled(); }); @@ -268,24 +237,48 @@ describe("HTTP error retries", () => { server.use(serviceUnavailable()); const onRetry = vi.fn(); - const error = await makeClient(onRetry) - .addLedgerEntry(addLedgerEntryInput) - .catch((e: unknown) => e); + const promise = + makeRetryingClient(onRetry).addLedgerEntry(addLedgerEntryInput); - expect(error).toBeInstanceOf(FragmentError); - expect((error as FragmentError).status).toEqual(503); + await expect(promise).rejects.toThrow(FragmentError); + await expect(promise).rejects.toHaveProperty("status", 503); expect(onRetry).toHaveBeenCalledTimes(3); }); + it("returns the data once a retried mutation succeeds", async () => { + const addLedgerEntry = { + __typename: "AddLedgerEntryResult", + isIkReplay: false, + entry: { + type: "runtime-entry", + id: "entry-id", + ik: "add-ledger-entry-ik", + posted: "2024-01-01T00:00:00.000Z", + }, + }; + server.use( + serviceUnavailableTwice(), + fragmentApi.mutation("addLedgerEntry", () => + HttpResponse.json({ data: { addLedgerEntry } }), + ), + ); + const onRetry = vi.fn(); + + const result = + await makeRetryingClient(onRetry).addLedgerEntry(addLedgerEntryInput); + + expect(result.addLedgerEntry).toEqual(addLedgerEntry); + expect(onRetry).toHaveBeenCalledTimes(2); + }); + it("does not retry a mutation that fails with a 4xx", async () => { server.use(unknownField()); const onRetry = vi.fn(); - const error = await makeClient(onRetry) - .addLedgerEntry(addLedgerEntryInput) - .catch((e: unknown) => e); + const promise = + makeRetryingClient(onRetry).addLedgerEntry(addLedgerEntryInput); - expect((error as FragmentError).status).toEqual(400); + await expect(promise).rejects.toHaveProperty("status", 400); expect(onRetry).not.toHaveBeenCalled(); }); }); From f72b451ebff5279d6803116d8ef368b3f5cc8f9d Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 27 Oct 2026 10:56:10 -0400 Subject: [PATCH 6/8] Retry only transport failures among thrown errors A rejection that is not a ClientError is retried only when it is node-fetch's FetchError of type "system". An AbortError or a bug in the SDK surfaces on the first attempt instead of after the backoff. FragmentError.cause now admits the mutation error payload it has always carried at runtime, so client.ts no longer launders it as an Error. Co-Authored-By: Claude Fable 5.1 --- dist/cjs/src/client.js | 20 ++++++++++++------- dist/cjs/types/src/errors.d.ts | 12 +++++++++-- dist/cjs/types/src/errors.d.ts.map | 2 +- dist/esm/src/client.js | 20 ++++++++++++------- dist/esm/types/src/errors.d.ts | 12 +++++++++-- dist/esm/types/src/errors.d.ts.map | 2 +- src/client.ts | 32 +++++++++++++++--------------- src/errors.ts | 13 ++++++++++-- tests/retries.test.ts | 15 ++++++++++++++ 9 files changed, 90 insertions(+), 38 deletions(-) diff --git a/dist/cjs/src/client.js b/dist/cjs/src/client.js index 609ae26..a701f59 100644 --- a/dist/cjs/src/client.js +++ b/dist/cjs/src/client.js @@ -21,12 +21,17 @@ const getToken_js_1 = require("./getToken.js"); const errors_js_1 = require("./errors.js"); const retryConfig_js_1 = require("./retryConfig.js"); const isRetryableHttpStatus = (status) => status === 429 || status >= 500; +// graphql-request's node-fetch rejects a failed connection with a FetchError of +// type "system"; any other rejection is not a transport failure. +const isNetworkError = (error) => error instanceof Error && + error.name === "FetchError" && + error.type === "system"; const classifyRequestError = (error) => error instanceof graphql_request_1.ClientError ? { error: new errors_js_1.FragmentError({ cause: error }), retryable: isRetryableHttpStatus(error.response.status), } - : { error: error, retryable: true }; + : { error: error, retryable: isNetworkError(error) }; const classifyMutationData = (data, operationName) => { var _a; const payloads = data; @@ -43,11 +48,12 @@ const classifyMutationData = (data, operationName) => { const payload = payloads[graphQlOperationName]; const { __typename: typeName, code, message } = payload; const retryable = payload.retryable === true; - // `cause` carries the raw GraphQL error payload, which is not an Error instance. - const cause = payload; switch (typeName) { case "InternalError": - return { error: new errors_js_1.InternalError({ cause, message }), retryable }; + return { + error: new errors_js_1.InternalError({ cause: payload, message }), + retryable, + }; case "AddLedgerEntriesError": // The errors for each Ledger Entry responsible for the failure are // what tell a caller which entries to fix, so they are surfaced @@ -55,7 +61,7 @@ const classifyMutationData = (data, operationName) => { return { error: new errors_js_1.AddLedgerEntriesError({ code, - cause, + cause: payload, message, errors: (_a = payload.errors) !== null && _a !== void 0 ? _a : [], }), @@ -63,7 +69,7 @@ const classifyMutationData = (data, operationName) => { }; case "BadRequestError": return { - error: new errors_js_1.BadRequestError({ code, cause, message }), + error: new errors_js_1.BadRequestError({ code, cause: payload, message }), retryable, }; default: @@ -71,7 +77,7 @@ const classifyMutationData = (data, operationName) => { ? { error: new errors_js_1.FragmentError({ message: `Encountered unsupported error type: ${typeName}`, - cause, + cause: payload, }), retryable: false, } diff --git a/dist/cjs/types/src/errors.d.ts b/dist/cjs/types/src/errors.d.ts index 1797494..0e51f3f 100644 --- a/dist/cjs/types/src/errors.d.ts +++ b/dist/cjs/types/src/errors.d.ts @@ -1,11 +1,19 @@ import { ClientError } from "graphql-request"; +/** The error object a mutation returns in place of its result, such as `{ __typename: "BadRequestError", ... }`. */ +export type MutationErrorPayload = { + __typename: string; + code?: string; + message?: string; + retryable?: boolean; + errors?: LedgerEntryBatchError[]; +}; type FragmentErrorParams = { - cause?: ClientError | Error; + cause?: ClientError | Error | MutationErrorPayload; message?: string; code?: string; }; export declare class FragmentError extends Error { - readonly cause?: ClientError | Error; + readonly cause?: ClientError | Error | MutationErrorPayload; readonly status?: number; readonly code?: string; constructor({ cause, code, message: messageParam }: FragmentErrorParams); diff --git a/dist/cjs/types/src/errors.d.ts.map b/dist/cjs/types/src/errors.d.ts.map index c9eddc0..95ec789 100644 --- a/dist/cjs/types/src/errors.d.ts.map +++ b/dist/cjs/types/src/errors.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9C,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEX,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,mBAAmB;CAmBxE;AAED,MAAM,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAEzD,qBAAa,aAAc,SAAQ,aAAa;gBAClC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,sBAAsB;CAGvD;AAED,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,CAAC;AAExD,qBAAa,eAAgB,SAAQ,aAAa;gBACpC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,qBAAqB;CAG5D;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,6GAA6G;IAC7G,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG;IAC9D,6FAA6F;IAC7F,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;CAClC,CAAC;AAEF;;;;;;;GAOG;AACH,qBAAa,qBAAsB,SAAQ,aAAa;IACtD,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,qBAAqB,EAAE,CAAC;gBAE7B,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,2BAA2B;CAQ1E"} \ No newline at end of file +{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9C,oHAAoH;AACpH,MAAM,MAAM,oBAAoB,GAAG;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;CAClC,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,GAAG,oBAAoB,CAAC;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,GAAG,oBAAoB,CAAC;IAC5D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEX,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,mBAAmB;CAmBxE;AAED,MAAM,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAEzD,qBAAa,aAAc,SAAQ,aAAa;gBAClC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,sBAAsB;CAGvD;AAED,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,CAAC;AAExD,qBAAa,eAAgB,SAAQ,aAAa;gBACpC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,qBAAqB;CAG5D;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,6GAA6G;IAC7G,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG;IAC9D,6FAA6F;IAC7F,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;CAClC,CAAC;AAEF;;;;;;;GAOG;AACH,qBAAa,qBAAsB,SAAQ,aAAa;IACtD,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,qBAAqB,EAAE,CAAC;gBAE7B,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,2BAA2B;CAQ1E"} \ No newline at end of file diff --git a/dist/esm/src/client.js b/dist/esm/src/client.js index 07bb733..2b1480e 100644 --- a/dist/esm/src/client.js +++ b/dist/esm/src/client.js @@ -6,12 +6,17 @@ import { getToken } from "./getToken.js"; import { AddLedgerEntriesError, BadRequestError, FragmentError, InternalError, } from "./errors.js"; import { DEFAULT_RETRY_CONFIG } from "./retryConfig.js"; const isRetryableHttpStatus = (status) => status === 429 || status >= 500; +// graphql-request's node-fetch rejects a failed connection with a FetchError of +// type "system"; any other rejection is not a transport failure. +const isNetworkError = (error) => error instanceof Error && + error.name === "FetchError" && + error.type === "system"; const classifyRequestError = (error) => error instanceof ClientError ? { error: new FragmentError({ cause: error }), retryable: isRetryableHttpStatus(error.response.status), } - : { error: error, retryable: true }; + : { error: error, retryable: isNetworkError(error) }; const classifyMutationData = (data, operationName) => { const payloads = data; const dataKeys = Object.keys(payloads); @@ -27,11 +32,12 @@ const classifyMutationData = (data, operationName) => { const payload = payloads[graphQlOperationName]; const { __typename: typeName, code, message } = payload; const retryable = payload.retryable === true; - // `cause` carries the raw GraphQL error payload, which is not an Error instance. - const cause = payload; switch (typeName) { case "InternalError": - return { error: new InternalError({ cause, message }), retryable }; + return { + error: new InternalError({ cause: payload, message }), + retryable, + }; case "AddLedgerEntriesError": // The errors for each Ledger Entry responsible for the failure are // what tell a caller which entries to fix, so they are surfaced @@ -39,7 +45,7 @@ const classifyMutationData = (data, operationName) => { return { error: new AddLedgerEntriesError({ code, - cause, + cause: payload, message, errors: payload.errors ?? [], }), @@ -47,7 +53,7 @@ const classifyMutationData = (data, operationName) => { }; case "BadRequestError": return { - error: new BadRequestError({ code, cause, message }), + error: new BadRequestError({ code, cause: payload, message }), retryable, }; default: @@ -55,7 +61,7 @@ const classifyMutationData = (data, operationName) => { ? { error: new FragmentError({ message: `Encountered unsupported error type: ${typeName}`, - cause, + cause: payload, }), retryable: false, } diff --git a/dist/esm/types/src/errors.d.ts b/dist/esm/types/src/errors.d.ts index 1797494..0e51f3f 100644 --- a/dist/esm/types/src/errors.d.ts +++ b/dist/esm/types/src/errors.d.ts @@ -1,11 +1,19 @@ import { ClientError } from "graphql-request"; +/** The error object a mutation returns in place of its result, such as `{ __typename: "BadRequestError", ... }`. */ +export type MutationErrorPayload = { + __typename: string; + code?: string; + message?: string; + retryable?: boolean; + errors?: LedgerEntryBatchError[]; +}; type FragmentErrorParams = { - cause?: ClientError | Error; + cause?: ClientError | Error | MutationErrorPayload; message?: string; code?: string; }; export declare class FragmentError extends Error { - readonly cause?: ClientError | Error; + readonly cause?: ClientError | Error | MutationErrorPayload; readonly status?: number; readonly code?: string; constructor({ cause, code, message: messageParam }: FragmentErrorParams); diff --git a/dist/esm/types/src/errors.d.ts.map b/dist/esm/types/src/errors.d.ts.map index c9eddc0..95ec789 100644 --- a/dist/esm/types/src/errors.d.ts.map +++ b/dist/esm/types/src/errors.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9C,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEX,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,mBAAmB;CAmBxE;AAED,MAAM,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAEzD,qBAAa,aAAc,SAAQ,aAAa;gBAClC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,sBAAsB;CAGvD;AAED,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,CAAC;AAExD,qBAAa,eAAgB,SAAQ,aAAa;gBACpC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,qBAAqB;CAG5D;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,6GAA6G;IAC7G,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG;IAC9D,6FAA6F;IAC7F,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;CAClC,CAAC;AAEF;;;;;;;GAOG;AACH,qBAAa,qBAAsB,SAAQ,aAAa;IACtD,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,qBAAqB,EAAE,CAAC;gBAE7B,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,2BAA2B;CAQ1E"} \ No newline at end of file +{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9C,oHAAoH;AACpH,MAAM,MAAM,oBAAoB,GAAG;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;CAClC,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACzB,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,GAAG,oBAAoB,CAAC;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,GAAG,KAAK,GAAG,oBAAoB,CAAC;IAC5D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAEzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEX,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE,mBAAmB;CAmBxE;AAED,MAAM,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAEzD,qBAAa,aAAc,SAAQ,aAAa;gBAClC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,sBAAsB;CAGvD;AAED,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,CAAC;AAExD,qBAAa,eAAgB,SAAQ,aAAa;gBACpC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,qBAAqB;CAG5D;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,6GAA6G;IAC7G,EAAE,EAAE,MAAM,CAAC;IACX,4EAA4E;IAC5E,IAAI,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,gDAAgD;IAChD,SAAS,EAAE,OAAO,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GAAG,mBAAmB,GAAG;IAC9D,6FAA6F;IAC7F,MAAM,CAAC,EAAE,qBAAqB,EAAE,CAAC;CAClC,CAAC;AAEF;;;;;;;GAOG;AACH,qBAAa,qBAAsB,SAAQ,aAAa;IACtD,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,qBAAqB,EAAE,CAAC;gBAE7B,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,2BAA2B;CAQ1E"} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index df7b420..ab27cd1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,30 +12,29 @@ import { BadRequestError, FragmentError, InternalError, - type LedgerEntryBatchError, + type MutationErrorPayload, } from "./errors.js"; import { DEFAULT_RETRY_CONFIG, type RetryConfig } from "./retryConfig.js"; type Attempt = { data: T } | { error: Error; retryable: boolean }; -type MutationErrorPayload = { - __typename: string; - code?: string; - message?: string; - retryable?: boolean; - errors?: LedgerEntryBatchError[]; -}; - const isRetryableHttpStatus = (status: number): boolean => status === 429 || status >= 500; +// graphql-request's node-fetch rejects a failed connection with a FetchError of +// type "system"; any other rejection is not a transport failure. +const isNetworkError = (error: unknown): boolean => + error instanceof Error && + error.name === "FetchError" && + (error as { type?: unknown }).type === "system"; + const classifyRequestError = (error: unknown): Attempt => error instanceof ClientError ? { error: new FragmentError({ cause: error }), retryable: isRetryableHttpStatus(error.response.status), } - : { error: error as Error, retryable: true }; + : { error: error as Error, retryable: isNetworkError(error) }; const classifyMutationData = ( data: T, @@ -55,12 +54,13 @@ const classifyMutationData = ( const payload = payloads[graphQlOperationName]; const { __typename: typeName, code, message } = payload; const retryable = payload.retryable === true; - // `cause` carries the raw GraphQL error payload, which is not an Error instance. - const cause = payload as unknown as Error; switch (typeName) { case "InternalError": - return { error: new InternalError({ cause, message }), retryable }; + return { + error: new InternalError({ cause: payload, message }), + retryable, + }; case "AddLedgerEntriesError": // The errors for each Ledger Entry responsible for the failure are // what tell a caller which entries to fix, so they are surfaced @@ -68,7 +68,7 @@ const classifyMutationData = ( return { error: new AddLedgerEntriesError({ code, - cause, + cause: payload, message, errors: payload.errors ?? [], }), @@ -76,7 +76,7 @@ const classifyMutationData = ( }; case "BadRequestError": return { - error: new BadRequestError({ code, cause, message }), + error: new BadRequestError({ code, cause: payload, message }), retryable, }; default: @@ -84,7 +84,7 @@ const classifyMutationData = ( ? { error: new FragmentError({ message: `Encountered unsupported error type: ${typeName}`, - cause, + cause: payload, }), retryable: false, } diff --git a/src/errors.ts b/src/errors.ts index 07aeba4..85f20a6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,13 +1,22 @@ import { ClientError } from "graphql-request"; +/** The error object a mutation returns in place of its result, such as `{ __typename: "BadRequestError", ... }`. */ +export type MutationErrorPayload = { + __typename: string; + code?: string; + message?: string; + retryable?: boolean; + errors?: LedgerEntryBatchError[]; +}; + type FragmentErrorParams = { - cause?: ClientError | Error; + cause?: ClientError | Error | MutationErrorPayload; message?: string; code?: string; }; export class FragmentError extends Error { - readonly cause?: ClientError | Error; + readonly cause?: ClientError | Error | MutationErrorPayload; readonly status?: number; readonly code?: string; diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 4651bcf..5f72450 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { setupServer } from "msw/node"; import { http, graphql, HttpResponse, type JsonBodyType } from "msw"; +import { GraphQLClient } from "graphql-request"; import { createFragmentClient } from "../src/client.js"; import { BadRequestError, @@ -53,6 +54,7 @@ beforeAll(() => { afterEach(() => { server.resetHandlers(); + vi.restoreAllMocks(); }); const makeClient = (retryConfig?: RetryConfig) => @@ -211,6 +213,19 @@ describe("HTTP error retries", () => { expect(onRetry).toHaveBeenCalledTimes(3); }); + it("does not retry a query that fails with a non-network error", async () => { + const abortError = Object.assign(new Error("aborted"), { + name: "AbortError", + }); + vi.spyOn(GraphQLClient.prototype, "request").mockRejectedValue(abortError); + const onRetry = vi.fn(); + + const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); + + await expect(promise).rejects.toBe(abortError); + expect(onRetry).not.toHaveBeenCalled(); + }); + it("retries a query that is rate limited", async () => { server.use(respondWithStatus(429, { message: "Too Many Requests" })); const onRetry = vi.fn(); From ce51ed2a21c2f2469f123510f7ee4a32c1490e86 Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 27 Oct 2026 12:49:05 -0400 Subject: [PATCH 7/8] Test HTTP retries against the default retry config Attempts are counted at the mock server instead of through onRetry, so the tests exercise DEFAULT_RETRY_CONFIG as shipped. The README Retries section is dropped; the defaults are what callers get. Co-Authored-By: Claude Fable 5.1 --- README.md | 28 ++++--------- tests/retries.test.ts | 98 +++++++++++++++++++++++-------------------- 2 files changed, 61 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index ff8b52e..f6738d2 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ await fragment.PostUserFundsAccount({ ``` The SDK handles different naming conventions: + - Hyphenated: `user-funds-account` → `PostUserFundsAccount` - CamelCase: `fundingSettlement` → `PostFundingSettlement` - Underscore: `payment_processing` → `PostPaymentProcessing` @@ -105,6 +106,7 @@ The SDK handles different naming conventions: When your schema defines multiple versions of the same entry type using `typeVersion`, the SDK generates separate methods for each version. Version 1 uses the base method name, and subsequent versions append `_v2`, `_v3`, etc. For example, an entry type `user-funds-account` with `typeVersion: 1` and `typeVersion: 2` generates: + - `PostUserFundsAccount` for version 1 - `PostUserFundsAccount_v2` for version 2 @@ -132,7 +134,7 @@ Each version can have different parameters and line structures, and the generate To [post](https://fragment.dev/guides/post-ledger-entries#batch-ledger-entries) a batch of Ledger Entries atomically: ```typescript -import { userFundsAccountV1 } from './src/fragment-client.ts'; +import { userFundsAccountV1 } from "./src/fragment-client.ts"; await fragment.addLedgerEntries({ entries: [ @@ -321,28 +323,13 @@ const result = await fragment.listMultiCurrencyLedgerAccountBalances({ assert(result.ledger?.ledgerAccounts?.nodes, "Failed to list ledger accounts"); ``` -## Retries - -The client retries a request that fails at the network layer or with an HTTP 429 or 5xx, and a mutation whose response is an `InternalError` or `BadRequestError` with `retryable: true`. Retries use exponential backoff, 5 retries from 10ms up to 3s by default. Any other failure throws immediately. - -Override the defaults with `retryConfig`, which takes [async-retry](https://github.com/vercel/async-retry) options: - -```typescript -const fragment = createFragmentClient({ - params: { - // ... - }, - retryConfig: { retries: 3, minTimeout: 100, maxTimeout: 2000 }, -}); -``` - ## Using custom queries While the SDK comes with GraphQL queries out of the box, you may want to customize these queries for your product. In order to do that: 1. Define your custom GraphQL queries in a GraphQL file. For example, in `custom-queries.graphql`: -``` graphql +```graphql mutation getSchemaName($key: SafeString!) { schema(schema: { key: $key }) { key @@ -354,19 +341,20 @@ mutation getSchemaName($key: SafeString!) { 2. Run `fragment-node-client-codegen` to generate the client for your custom queries. Using `npx`: -``` bash + +```bash npx fragment-node-client-codegen -i custom-queries.graphql -o src/fragment-client.ts ``` Using `yarn`: -``` bash +```bash yarn fragment-node-client-codegen -i custom-queries.graphql -o src/fragment-client.ts ``` 3. Pass the `getSdk` function from the generated file to `createFragmentClient` to use your custom query! -``` typescript +```typescript import { createFragmentClient } from "@fragment-dev/node-client"; import { getSdk } from './src/fragment-client.ts'; diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 5f72450..24664cd 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -8,7 +8,7 @@ import { FragmentError, InternalError, } from "../src/errors.js"; -import type { RetryConfig } from "../src/retryConfig.js"; +import { DEFAULT_RETRY_CONFIG, type RetryConfig } from "../src/retryConfig.js"; const apiUrl = "https://fragment-api.example.com/graphql"; const fragmentApi = graphql.link(apiUrl); @@ -151,8 +151,12 @@ describe("Client retries", () => { }); describe("HTTP error retries", () => { - const makeRetryingClient = (onRetry: () => void) => - makeClient({ retries: 3, minTimeout: 1, maxTimeout: 1, onRetry }); + const defaultAttempts = 1 + (DEFAULT_RETRY_CONFIG.retries ?? 0); + + const countAttempts = (attempts: () => void) => + http.post(apiUrl, () => { + attempts(); + }); const respondWithStatus = (status: number, body: JsonBodyType) => http.post(apiUrl, () => HttpResponse.json(body, { status })); @@ -161,10 +165,10 @@ describe("HTTP error retries", () => { respondWithStatus(503, { message: "Service Unavailable" }); const serviceUnavailableTwice = () => { - let attempts = 0; + let failures = 0; return http.post(apiUrl, () => { - attempts += 1; - return attempts <= 2 + failures += 1; + return failures <= 2 ? HttpResponse.json({ message: "Service Unavailable" }, { status: 503 }) : undefined; }); @@ -174,90 +178,95 @@ describe("HTTP error retries", () => { respondWithStatus(400, { errors: [{ message: "Unknown field" }] }); it("retries a query that fails with a 5xx and surfaces the status once exhausted", async () => { - server.use(serviceUnavailable()); - const onRetry = vi.fn(); + const attempts = vi.fn(); + server.use(countAttempts(attempts), serviceUnavailable()); - const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); + const promise = makeClient().getLedger({ ik: "ledger-ik" }); await expect(promise).rejects.toThrow(FragmentError); await expect(promise).rejects.toHaveProperty("status", 503); - expect(onRetry).toHaveBeenCalledTimes(3); + expect(attempts).toHaveBeenCalledTimes(defaultAttempts); }); it("returns the data once a retried query succeeds", async () => { const ledger = { id: "ledger-id", ik: "ledger-ik", name: "Ledger" }; + const attempts = vi.fn(); server.use( + countAttempts(attempts), serviceUnavailableTwice(), fragmentApi.query("getLedger", () => HttpResponse.json({ data: { ledger } }), ), ); - const onRetry = vi.fn(); - const result = await makeRetryingClient(onRetry).getLedger({ - ik: "ledger-ik", - }); + const result = await makeClient().getLedger({ ik: "ledger-ik" }); expect(result.ledger).toEqual(ledger); - expect(onRetry).toHaveBeenCalledTimes(2); + expect(attempts).toHaveBeenCalledTimes(3); }); it("retries a query that fails at the network layer", async () => { - server.use(http.post(apiUrl, () => HttpResponse.error())); - const onRetry = vi.fn(); + const attempts = vi.fn(); + server.use( + countAttempts(attempts), + http.post(apiUrl, () => HttpResponse.error()), + ); - const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); + const promise = makeClient().getLedger({ ik: "ledger-ik" }); await expect(promise).rejects.toThrow(Error); await expect(promise).rejects.not.toBeInstanceOf(FragmentError); - expect(onRetry).toHaveBeenCalledTimes(3); + expect(attempts).toHaveBeenCalledTimes(defaultAttempts); }); it("does not retry a query that fails with a non-network error", async () => { const abortError = Object.assign(new Error("aborted"), { name: "AbortError", }); - vi.spyOn(GraphQLClient.prototype, "request").mockRejectedValue(abortError); - const onRetry = vi.fn(); + const request = vi + .spyOn(GraphQLClient.prototype, "request") + .mockRejectedValue(abortError); - const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); + const promise = makeClient().getLedger({ ik: "ledger-ik" }); await expect(promise).rejects.toBe(abortError); - expect(onRetry).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(1); }); it("retries a query that is rate limited", async () => { - server.use(respondWithStatus(429, { message: "Too Many Requests" })); - const onRetry = vi.fn(); + const attempts = vi.fn(); + server.use( + countAttempts(attempts), + respondWithStatus(429, { message: "Too Many Requests" }), + ); - const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); + const promise = makeClient().getLedger({ ik: "ledger-ik" }); await expect(promise).rejects.toThrow(FragmentError); await expect(promise).rejects.toHaveProperty("status", 429); - expect(onRetry).toHaveBeenCalledTimes(3); + expect(attempts).toHaveBeenCalledTimes(defaultAttempts); }); it("does not retry a query that fails with a 4xx", async () => { - server.use(unknownField()); - const onRetry = vi.fn(); + const attempts = vi.fn(); + server.use(countAttempts(attempts), unknownField()); - const promise = makeRetryingClient(onRetry).getLedger({ ik: "ledger-ik" }); + const promise = makeClient().getLedger({ ik: "ledger-ik" }); await expect(promise).rejects.toHaveProperty("status", 400); await expect(promise).rejects.toHaveProperty("message", "Unknown field"); - expect(onRetry).not.toHaveBeenCalled(); + expect(attempts).toHaveBeenCalledTimes(1); }); it("retries a mutation that fails with a 5xx", async () => { - server.use(serviceUnavailable()); - const onRetry = vi.fn(); + const attempts = vi.fn(); + server.use(countAttempts(attempts), serviceUnavailable()); - const promise = - makeRetryingClient(onRetry).addLedgerEntry(addLedgerEntryInput); + const promise = makeClient().addLedgerEntry(addLedgerEntryInput); await expect(promise).rejects.toThrow(FragmentError); await expect(promise).rejects.toHaveProperty("status", 503); - expect(onRetry).toHaveBeenCalledTimes(3); + expect(attempts).toHaveBeenCalledTimes(defaultAttempts); }); it("returns the data once a retried mutation succeeds", async () => { @@ -271,29 +280,28 @@ describe("HTTP error retries", () => { posted: "2024-01-01T00:00:00.000Z", }, }; + const attempts = vi.fn(); server.use( + countAttempts(attempts), serviceUnavailableTwice(), fragmentApi.mutation("addLedgerEntry", () => HttpResponse.json({ data: { addLedgerEntry } }), ), ); - const onRetry = vi.fn(); - const result = - await makeRetryingClient(onRetry).addLedgerEntry(addLedgerEntryInput); + const result = await makeClient().addLedgerEntry(addLedgerEntryInput); expect(result.addLedgerEntry).toEqual(addLedgerEntry); - expect(onRetry).toHaveBeenCalledTimes(2); + expect(attempts).toHaveBeenCalledTimes(3); }); it("does not retry a mutation that fails with a 4xx", async () => { - server.use(unknownField()); - const onRetry = vi.fn(); + const attempts = vi.fn(); + server.use(countAttempts(attempts), unknownField()); - const promise = - makeRetryingClient(onRetry).addLedgerEntry(addLedgerEntryInput); + const promise = makeClient().addLedgerEntry(addLedgerEntryInput); await expect(promise).rejects.toHaveProperty("status", 400); - expect(onRetry).not.toHaveBeenCalled(); + expect(attempts).toHaveBeenCalledTimes(1); }); }); From 19d9e2fdeda39dc019b2f3aa6ee282a77a838e77 Mon Sep 17 00:00:00 2001 From: Omi Date: Tue, 27 Oct 2026 15:17:10 -0400 Subject: [PATCH 8/8] Replace ternaries with if branches in the retry classifiers Co-Authored-By: Claude Fable 5.1 --- dist/cjs/src/client.js | 32 ++++++++++++-------- dist/cjs/types/src/client.d.ts.map | 2 +- dist/esm/src/client.js | 32 ++++++++++++-------- dist/esm/types/src/client.d.ts.map | 2 +- src/client.ts | 47 +++++++++++++++++------------- tests/retries.test.ts | 8 +++-- 6 files changed, 74 insertions(+), 49 deletions(-) diff --git a/dist/cjs/src/client.js b/dist/cjs/src/client.js index a701f59..ad8d607 100644 --- a/dist/cjs/src/client.js +++ b/dist/cjs/src/client.js @@ -26,12 +26,15 @@ const isRetryableHttpStatus = (status) => status === 429 || status >= 500; const isNetworkError = (error) => error instanceof Error && error.name === "FetchError" && error.type === "system"; -const classifyRequestError = (error) => error instanceof graphql_request_1.ClientError - ? { - error: new errors_js_1.FragmentError({ cause: error }), - retryable: isRetryableHttpStatus(error.response.status), +const classifyRequestError = (error) => { + if (error instanceof graphql_request_1.ClientError) { + return { + error: new errors_js_1.FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + }; } - : { error: error, retryable: isNetworkError(error) }; + return { error: error, retryable: isNetworkError(error) }; +}; const classifyMutationData = (data, operationName) => { var _a; const payloads = data; @@ -73,15 +76,16 @@ const classifyMutationData = (data, operationName) => { retryable, }; default: - return typeName.endsWith("Error") - ? { + if (typeName.endsWith("Error")) { + return { error: new errors_js_1.FragmentError({ message: `Encountered unsupported error type: ${typeName}`, cause: payload, }), retryable: false, - } - : { data }; + }; + } + return { data }; } }; const createRequestWrapper = (tokenCache, params, retryConfig) => (request, operationName, operationType) => __awaiter(void 0, void 0, void 0, function* () { @@ -117,10 +121,14 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => (request, oper Authorization: `Bearer ${tokenCache.token.access_token}`, ["X-Fragment-Client"]: `node-client@${version_js_1.version}`, }; + const classifyData = (data) => { + if (operationType === "mutation") { + return classifyMutationData(data, operationName); + } + return { data }; + }; return (0, async_retry_1.default)((bail) => __awaiter(void 0, void 0, void 0, function* () { - const attempt = yield request(requestHeaders).then((data) => operationType === "mutation" - ? classifyMutationData(data, operationName) - : { data }, classifyRequestError); + const attempt = yield request(requestHeaders).then(classifyData, classifyRequestError); if ("data" in attempt) return attempt.data; if (attempt.retryable) diff --git a/dist/cjs/types/src/client.d.ts.map b/dist/cjs/types/src/client.d.ts.map index 0a9c9b1..6d3f8a7 100644 --- a/dist/cjs/types/src/client.d.ts.map +++ b/dist/cjs/types/src/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AA8E1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAsEF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAiF1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AA0EF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file diff --git a/dist/esm/src/client.js b/dist/esm/src/client.js index 2b1480e..286746a 100644 --- a/dist/esm/src/client.js +++ b/dist/esm/src/client.js @@ -11,12 +11,15 @@ const isRetryableHttpStatus = (status) => status === 429 || status >= 500; const isNetworkError = (error) => error instanceof Error && error.name === "FetchError" && error.type === "system"; -const classifyRequestError = (error) => error instanceof ClientError - ? { - error: new FragmentError({ cause: error }), - retryable: isRetryableHttpStatus(error.response.status), +const classifyRequestError = (error) => { + if (error instanceof ClientError) { + return { + error: new FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + }; } - : { error: error, retryable: isNetworkError(error) }; + return { error: error, retryable: isNetworkError(error) }; +}; const classifyMutationData = (data, operationName) => { const payloads = data; const dataKeys = Object.keys(payloads); @@ -57,15 +60,16 @@ const classifyMutationData = (data, operationName) => { retryable, }; default: - return typeName.endsWith("Error") - ? { + if (typeName.endsWith("Error")) { + return { error: new FragmentError({ message: `Encountered unsupported error type: ${typeName}`, cause: payload, }), retryable: false, - } - : { data }; + }; + } + return { data }; } }; const createRequestWrapper = (tokenCache, params, retryConfig) => async (request, operationName, operationType) => { @@ -101,10 +105,14 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => async (request Authorization: `Bearer ${tokenCache.token.access_token}`, ["X-Fragment-Client"]: `node-client@${version}`, }; + const classifyData = (data) => { + if (operationType === "mutation") { + return classifyMutationData(data, operationName); + } + return { data }; + }; return retry(async (bail) => { - const attempt = await request(requestHeaders).then((data) => operationType === "mutation" - ? classifyMutationData(data, operationName) - : { data }, classifyRequestError); + const attempt = await request(requestHeaders).then(classifyData, classifyRequestError); if ("data" in attempt) return attempt.data; if (attempt.retryable) diff --git a/dist/esm/types/src/client.d.ts.map b/dist/esm/types/src/client.d.ts.map index 0a9c9b1..6d3f8a7 100644 --- a/dist/esm/types/src/client.d.ts.map +++ b/dist/esm/types/src/client.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AA8E1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAsEF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file +{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,EACL,kBAAkB,EAClB,MAAM,IAAI,aAAa,EACxB,MAAM,2BAA2B,CAAC;AAUnC,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAiF1E,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AAE9D,KAAK,0BAA0B,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AA0EF,KAAK,yBAAyB,CAC5B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE;IACF,MAAM,EAAE,0BAA0B,CAAC;IACnC,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B,CAAC;AAEF,KAAK,0BAA0B,CAC7B,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,GAAG,IACnE,CAAC,SAAS,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,kBAAkB,KAAK,MAAM,CAAC,GACzE,CAAC,SAAS,cAAc,GACtB,CAAC,GACD,cAAc,GAAG,CAAC,GACpB,KAAK,CAAC;AAEV,eAAO,MAAM,oBAAoB,sBAErB,aAAa,WACZ,kBAAkB,KACxB,GAAG,2DAKP,0BAA0B,CAAC,CAAC,KAAG,2BAA2B,CAAC,CAe7D,CAAC"} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index ab27cd1..183fa1c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,13 +28,15 @@ const isNetworkError = (error: unknown): boolean => error.name === "FetchError" && (error as { type?: unknown }).type === "system"; -const classifyRequestError = (error: unknown): Attempt => - error instanceof ClientError - ? { - error: new FragmentError({ cause: error }), - retryable: isRetryableHttpStatus(error.response.status), - } - : { error: error as Error, retryable: isNetworkError(error) }; +const classifyRequestError = (error: unknown): Attempt => { + if (error instanceof ClientError) { + return { + error: new FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + }; + } + return { error: error as Error, retryable: isNetworkError(error) }; +}; const classifyMutationData = ( data: T, @@ -80,15 +82,16 @@ const classifyMutationData = ( retryable, }; default: - return typeName.endsWith("Error") - ? { - error: new FragmentError({ - message: `Encountered unsupported error type: ${typeName}`, - cause: payload, - }), - retryable: false, - } - : { data }; + if (typeName.endsWith("Error")) { + return { + error: new FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause: payload, + }), + retryable: false, + }; + } + return { data }; } }; @@ -155,12 +158,16 @@ const createRequestWrapper = ["X-Fragment-Client"]: `node-client@${version}`, }; + const classifyData = (data: T): Attempt => { + if (operationType === "mutation") { + return classifyMutationData(data, operationName); + } + return { data }; + }; + return retry(async (bail) => { const attempt = await request(requestHeaders).then( - (data) => - operationType === "mutation" - ? classifyMutationData(data, operationName) - : { data }, + classifyData, classifyRequestError, ); if ("data" in attempt) return attempt.data; diff --git a/tests/retries.test.ts b/tests/retries.test.ts index 24664cd..c8dc32b 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -168,9 +168,11 @@ describe("HTTP error retries", () => { let failures = 0; return http.post(apiUrl, () => { failures += 1; - return failures <= 2 - ? HttpResponse.json({ message: "Service Unavailable" }, { status: 503 }) - : undefined; + if (failures > 2) return undefined; + return HttpResponse.json( + { message: "Service Unavailable" }, + { status: 503 }, + ); }); };