diff --git a/src/GraphQL/Client/BaseClients/Apollo.js b/src/GraphQL/Client/BaseClients/Apollo.js index bde5f80..48ebecf 100644 --- a/src/GraphQL/Client/BaseClients/Apollo.js +++ b/src/GraphQL/Client/BaseClients/Apollo.js @@ -48,16 +48,44 @@ 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: (/** @type {CloseEvent} */ event) => { + 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, + reason, + wasClean, + url: opts.websocketUrl, + }); + }, + error: (error) => { + console.error("[graphql-ws] WebSocket error", { + error, + url: opts.websocketUrl, + }); + }, + }, }), ); 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