diff --git a/README.md b/README.md index d7dc91f..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: [ @@ -327,7 +329,7 @@ While the SDK comes with GraphQL queries out of the box, you may want to customi 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 @@ -339,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/dist/cjs/src/client.js b/dist/cjs/src/client.js index 42bb45e..ad8d607 100644 --- a/dist/cjs/src/client.js +++ b/dist/cjs/src/client.js @@ -20,15 +20,80 @@ 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 getRetryableField = (error) => { - if (Object.prototype.hasOwnProperty.call(error, "retryable")) { - if (typeof error.retryable === "boolean") { - return error.retryable; - } +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) => { + if (error instanceof graphql_request_1.ClientError) { + return { + error: new errors_js_1.FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + }; + } + return { error: error, retryable: isNetworkError(error) }; +}; +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; + switch (typeName) { + case "InternalError": + 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 + // rather than collapsed into the top-level message. + return { + error: new errors_js_1.AddLedgerEntriesError({ + code, + cause: payload, + message, + errors: (_a = payload.errors) !== null && _a !== void 0 ? _a : [], + }), + retryable, + }; + case "BadRequestError": + return { + error: new errors_js_1.BadRequestError({ code, cause: payload, message }), + retryable, + }; + default: + if (typeName.endsWith("Error")) { + return { + error: new errors_js_1.FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause: payload, + }), + retryable: false, + }; + } + return { 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)({ @@ -56,98 +121,20 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => (request, oper Authorization: `Bearer ${tokenCache.token.access_token}`, ["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; + const classifyData = (data) => { if (operationType === "mutation") { - const data = yield request(requestHeaders); - 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; - } - 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); - } - } + return classifyMutationData(data, operationName); } - bail(new errors_js_1.FragmentError({ - message: `Unknown operation type: ${operationType}`, - })); - return {}; + return { data }; + }; + return (0, async_retry_1.default)((bail) => __awaiter(void 0, void 0, void 0, function* () { + const attempt = yield request(requestHeaders).then(classifyData, 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 afad477..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;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;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/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 22fcc7c..286746a 100644 --- a/dist/esm/src/client.js +++ b/dist/esm/src/client.js @@ -5,15 +5,79 @@ 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 getRetryableField = (error) => { - if (Object.prototype.hasOwnProperty.call(error, "retryable")) { - if (typeof error.retryable === "boolean") { - return error.retryable; - } +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) => { + if (error instanceof ClientError) { + return { + error: new FragmentError({ cause: error }), + retryable: isRetryableHttpStatus(error.response.status), + }; + } + return { error: error, retryable: isNetworkError(error) }; +}; +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; + switch (typeName) { + case "InternalError": + 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 + // rather than collapsed into the top-level message. + return { + error: new AddLedgerEntriesError({ + code, + cause: payload, + message, + errors: payload.errors ?? [], + }), + retryable, + }; + case "BadRequestError": + return { + error: new BadRequestError({ code, cause: payload, message }), + retryable, + }; + default: + if (typeName.endsWith("Error")) { + return { + error: new FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause: payload, + }), + retryable: false, + }; + } + return { 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({ @@ -41,98 +105,20 @@ const createRequestWrapper = (tokenCache, params, retryConfig) => async (request Authorization: `Bearer ${tokenCache.token.access_token}`, ["X-Fragment-Client"]: `node-client@${version}`, }; - return retry(async (bail) => { + const classifyData = (data) => { if (operationType === "mutation") { - const data = await request(requestHeaders); - 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; + return classifyMutationData(data, operationName); } - 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 }; + }; + return retry(async (bail) => { + const attempt = await request(requestHeaders).then(classifyData, 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 afad477..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;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;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/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 d3e304c..183fa1c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,19 +12,87 @@ import { BadRequestError, FragmentError, InternalError, - type LedgerEntryBatchError, + type MutationErrorPayload, } 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 }; -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 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 => { + 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, + 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; + + switch (typeName) { + case "InternalError": + 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 + // rather than collapsed into the top-level message. + return { + error: new AddLedgerEntriesError({ + code, + cause: payload, + message, + errors: payload.errors ?? [], + }), + retryable, + }; + case "BadRequestError": + return { + error: new BadRequestError({ code, cause: payload, message }), + retryable, + }; + default: + if (typeName.endsWith("Error")) { + return { + error: new FragmentError({ + message: `Encountered unsupported error type: ${typeName}`, + cause: payload, + }), + retryable: false, + }; + } + return { data }; } - return false; }; export type FragmentClient = ReturnType; @@ -48,9 +116,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({ @@ -84,107 +158,22 @@ const createRequestWrapper = ["X-Fragment-Client"]: `node-client@${version}`, }; - return retry(async (bail): ReturnType => { + const classifyData = (data: T): Attempt => { if (operationType === "mutation") { - const data = await request(requestHeaders); - 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; - } - - 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); - } - } + return classifyMutationData(data, operationName); } + return { data }; + }; - bail( - new FragmentError({ - message: `Unknown operation type: ${operationType}`, - }) + return retry(async (bail) => { + const attempt = await request(requestHeaders).then( + classifyData, + classifyRequestError, ); - - return {} as never; + if ("data" in attempt) return attempt.data; + if (attempt.retryable) throw attempt.error; + bail(attempt.error); + return undefined as never; }, retryConfig); }; @@ -207,7 +196,7 @@ type CreateFragmentClientResult< export const createFragmentClient = < T extends ( client: GraphQLClient, - wrapper: SdkFunctionWrapper + wrapper: SdkFunctionWrapper, ) => any = typeof getDefaultSdk, >({ params, @@ -226,6 +215,6 @@ export const createFragmentClient = < } return getDefaultSdk( graphql, - requestWrapper + requestWrapper, ) as CreateFragmentClientResult; }; 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 422572e..c8dc32b 100644 --- a/tests/retries.test.ts +++ b/tests/retries.test.ts @@ -1,10 +1,17 @@ -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 { GraphQLClient } from "graphql-request"; import { createFragmentClient } from "../src/client.js"; -import { BadRequestError, InternalError } from "../src/errors.js"; +import { + BadRequestError, + FragmentError, + InternalError, +} from "../src/errors.js"; +import { DEFAULT_RETRY_CONFIG, 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", () => { @@ -45,23 +52,38 @@ beforeAll(() => { server.listen(); }); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); + +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 { @@ -79,23 +101,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); @@ -120,25 +129,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; } @@ -149,3 +149,161 @@ describe("Client retries", () => { expect(onRetry).not.toHaveBeenCalled(); }); }); + +describe("HTTP error retries", () => { + 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 })); + + const serviceUnavailable = () => + respondWithStatus(503, { message: "Service Unavailable" }); + + const serviceUnavailableTwice = () => { + let failures = 0; + return http.post(apiUrl, () => { + failures += 1; + if (failures > 2) return undefined; + return HttpResponse.json( + { message: "Service Unavailable" }, + { status: 503 }, + ); + }); + }; + + const unknownField = () => + respondWithStatus(400, { errors: [{ message: "Unknown field" }] }); + + it("retries a query that fails with a 5xx and surfaces the status once exhausted", async () => { + const attempts = vi.fn(); + server.use(countAttempts(attempts), serviceUnavailable()); + + const promise = makeClient().getLedger({ ik: "ledger-ik" }); + + await expect(promise).rejects.toThrow(FragmentError); + await expect(promise).rejects.toHaveProperty("status", 503); + 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 result = await makeClient().getLedger({ ik: "ledger-ik" }); + + expect(result.ledger).toEqual(ledger); + expect(attempts).toHaveBeenCalledTimes(3); + }); + + it("retries a query that fails at the network layer", async () => { + const attempts = vi.fn(); + server.use( + countAttempts(attempts), + http.post(apiUrl, () => HttpResponse.error()), + ); + + const promise = makeClient().getLedger({ ik: "ledger-ik" }); + + await expect(promise).rejects.toThrow(Error); + await expect(promise).rejects.not.toBeInstanceOf(FragmentError); + 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", + }); + const request = vi + .spyOn(GraphQLClient.prototype, "request") + .mockRejectedValue(abortError); + + const promise = makeClient().getLedger({ ik: "ledger-ik" }); + + await expect(promise).rejects.toBe(abortError); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("retries a query that is rate limited", async () => { + const attempts = vi.fn(); + server.use( + countAttempts(attempts), + respondWithStatus(429, { message: "Too Many Requests" }), + ); + + const promise = makeClient().getLedger({ ik: "ledger-ik" }); + + await expect(promise).rejects.toThrow(FragmentError); + await expect(promise).rejects.toHaveProperty("status", 429); + expect(attempts).toHaveBeenCalledTimes(defaultAttempts); + }); + + it("does not retry a query that fails with a 4xx", async () => { + const attempts = vi.fn(); + server.use(countAttempts(attempts), unknownField()); + + const promise = makeClient().getLedger({ ik: "ledger-ik" }); + + await expect(promise).rejects.toHaveProperty("status", 400); + await expect(promise).rejects.toHaveProperty("message", "Unknown field"); + expect(attempts).toHaveBeenCalledTimes(1); + }); + + it("retries a mutation that fails with a 5xx", async () => { + const attempts = vi.fn(); + server.use(countAttempts(attempts), serviceUnavailable()); + + const promise = makeClient().addLedgerEntry(addLedgerEntryInput); + + await expect(promise).rejects.toThrow(FragmentError); + await expect(promise).rejects.toHaveProperty("status", 503); + expect(attempts).toHaveBeenCalledTimes(defaultAttempts); + }); + + 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", + }, + }; + const attempts = vi.fn(); + server.use( + countAttempts(attempts), + serviceUnavailableTwice(), + fragmentApi.mutation("addLedgerEntry", () => + HttpResponse.json({ data: { addLedgerEntry } }), + ), + ); + + const result = await makeClient().addLedgerEntry(addLedgerEntryInput); + + expect(result.addLedgerEntry).toEqual(addLedgerEntry); + expect(attempts).toHaveBeenCalledTimes(3); + }); + + it("does not retry a mutation that fails with a 4xx", async () => { + const attempts = vi.fn(); + server.use(countAttempts(attempts), unknownField()); + + const promise = makeClient().addLedgerEntry(addLedgerEntryInput); + + await expect(promise).rejects.toHaveProperty("status", 400); + expect(attempts).toHaveBeenCalledTimes(1); + }); +});