From e2ffb8df19e50c979514631f1caf857ef8be15cd Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Wed, 11 Mar 2026 13:58:44 +0800 Subject: [PATCH 1/4] fix: add subscription retries --- src/GraphQL/Client/BaseClients/Apollo.js | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/GraphQL/Client/BaseClients/Apollo.js b/src/GraphQL/Client/BaseClients/Apollo.js index bde5f80..463081c 100644 --- a/src/GraphQL/Client/BaseClients/Apollo.js +++ b/src/GraphQL/Client/BaseClients/Apollo.js @@ -48,16 +48,41 @@ const createClientWithWebsockets = function (opts) { }, }); + // Close codes that indicate expected/benign disconnects + // 1000: Normal closure, 1001: Going away (tab close, navigation, mobile backgrounding) + const expectedCloseCodes = new Set([1000, 1001]); + const wsLink = new GraphQLWsLink( createWsClient({ webSocketImpl: globalThis.WebSocket, url: opts.websocketUrl, timeout: 30000, + retryAttempts: Infinity, + shouldRetry: () => true, connectionParams: { headers: opts.authToken ? { Authorization: `Bearer ${opts.authToken}` } : {}, }, + on: { + closed: (event) => { + if (expectedCloseCodes.has(event.code)) { + return; + } + console.warn("[graphql-ws] Socket closed unexpectedly", { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + url: opts.websocketUrl, + }); + }, + error: (error) => { + console.error("[graphql-ws] WebSocket error", { + error, + url: opts.websocketUrl, + }); + }, + }, }), ); From b7b1d955365b85931ad3e4c27a14d6b41120dedb Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Wed, 11 Mar 2026 14:14:16 +0800 Subject: [PATCH 2/4] types --- src/GraphQL/Client/BaseClients/Apollo.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/GraphQL/Client/BaseClients/Apollo.js b/src/GraphQL/Client/BaseClients/Apollo.js index 463081c..3d18acf 100644 --- a/src/GraphQL/Client/BaseClients/Apollo.js +++ b/src/GraphQL/Client/BaseClients/Apollo.js @@ -66,13 +66,16 @@ const createClientWithWebsockets = function (opts) { }, on: { closed: (event) => { - if (expectedCloseCodes.has(event.code)) { + const code = /** @type {number | undefined} */ (event?.code); + const reason = /** @type {string | undefined} */ (event?.reason); + const wasClean = /** @type {boolean | undefined} */ (event?.wasClean); + if (expectedCloseCodes.has(code)) { return; } console.warn("[graphql-ws] Socket closed unexpectedly", { - code: event.code, - reason: event.reason, - wasClean: event.wasClean, + code, + reason, + wasClean, url: opts.websocketUrl, }); }, From eeeea26824451d18846f5b576abc3d401418a419 Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Wed, 11 Mar 2026 14:14:51 +0800 Subject: [PATCH 3/4] types --- src/GraphQL/Client/BaseClients/Apollo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GraphQL/Client/BaseClients/Apollo.js b/src/GraphQL/Client/BaseClients/Apollo.js index 3d18acf..48ebecf 100644 --- a/src/GraphQL/Client/BaseClients/Apollo.js +++ b/src/GraphQL/Client/BaseClients/Apollo.js @@ -65,7 +65,7 @@ const createClientWithWebsockets = function (opts) { : {}, }, on: { - closed: (event) => { + closed: (/** @type {CloseEvent} */ event) => { const code = /** @type {number | undefined} */ (event?.code); const reason = /** @type {string | undefined} */ (event?.reason); const wasClean = /** @type {boolean | undefined} */ (event?.wasClean); From ece2c4eaa8449918e1032df1275dc6280ead5b0f Mon Sep 17 00:00:00 2001 From: Brad Reed Date: Wed, 11 Mar 2026 18:37:50 +0800 Subject: [PATCH 4/4] only retry mutations when safe to do so --- src/GraphQL/Client/Query.js | 25 +++++++++++++++++++++++++ src/GraphQL/Client/Query.purs | 19 +++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/GraphQL/Client/Query.js diff --git a/src/GraphQL/Client/Query.js b/src/GraphQL/Client/Query.js new file mode 100644 index 0000000..d7910c3 --- /dev/null +++ b/src/GraphQL/Client/Query.js @@ -0,0 +1,25 @@ +// Error codes where the request definitely never reached the server +const noConnectionErrorCodes = new Set([ + "ECONNREFUSED", + "ENETUNREACH", +]); + +// Error codes where the connection may have been established +// before failing, so the server may have received the request +const ambiguousConnectionErrorCodes = new Set([ + "ETIMEDOUT", + "ECONNRESET", +]); + +const getCode = (error) => { + const code = error?.cause?.code; + return typeof code === "string" ? code : ""; +}; + +export const isNoConnectionError = (error) => + noConnectionErrorCodes.has(getCode(error)); + +export const isConnectionError = (error) => { + const code = getCode(error); + return noConnectionErrorCodes.has(code) || ambiguousConnectionErrorCodes.has(code); +}; diff --git a/src/GraphQL/Client/Query.purs b/src/GraphQL/Client/Query.purs index a320056..80de53f 100644 --- a/src/GraphQL/Client/Query.purs +++ b/src/GraphQL/Client/Query.purs @@ -43,6 +43,9 @@ import GraphQL.Client.Types (class GqlQuery, class QueryClient, Client(..), GqlR import GraphQL.Client.Variables (class VarsTypeChecked, getVarsJson, getVarsTypeNames) import Type.Proxy (Proxy(..)) +foreign import isConnectionError :: Error -> Boolean +foreign import isNoConnectionError :: Error -> Boolean + -- | Run a graphQL query with a custom decoder and custom options queryOptsWithDecoder :: forall client directives schema query returns queryOpts mutationOpts sr @@ -199,8 +202,12 @@ runQuery -> Aff returns runQuery decodeFn opts client _ queryNameUnsafe q = addErrorInfo (Proxy @schema) queryName q do - json <- clientQuery opts client queryName (getVarsTypeNames (Proxy :: _ schema) q <> toGqlQueryString q) - (getVarsJson (Proxy :: _ schema) q) + let doQuery = clientQuery opts client queryName (getVarsTypeNames (Proxy :: _ schema) q <> toGqlQueryString q) + (getVarsJson (Proxy :: _ schema) q) + json <- doQuery `catchError` \err -> + -- Retry once on connection-level errors (ETIMEDOUT, ECONNREFUSED, etc.) + if isConnectionError err then doQuery + else throwError err decodeJsonData decodeFn json where queryName = safeQueryName queryNameUnsafe @@ -218,8 +225,12 @@ runMutation -> Aff returns runMutation decodeFn opts client _ queryNameUnsafe q = addErrorInfo (Proxy @schema) queryName q do - json <- clientMutation opts client queryName (getVarsTypeNames (Proxy :: _ schema) q <> toGqlQueryString q) - (getVarsJson (Proxy :: _ schema) q) + let doMutation = clientMutation opts client queryName (getVarsTypeNames (Proxy :: _ schema) q <> toGqlQueryString q) + (getVarsJson (Proxy :: _ schema) q) + json <- doMutation `catchError` \err -> + -- Only retry on ECONNREFUSED/ENETUNREACH where the request never reached the server + if isNoConnectionError err then doMutation + else throwError err decodeJsonData decodeFn json where queryName = safeQueryName queryNameUnsafe