Repository: urql-graphql/urql (ts)

Issue:
fetchOperation throws "Cannot read properties of undefined" when client is offline
### Describe the bug

TypeError: Cannot read properties of undefined (reading 'status')

The [response](https://github.com/urql-graphql/urql/blob/8b1d16418a36b54ad5a027268869a36410e11303/packages/core/src/internal/fetchSource.ts#L121) gets lost 
when the fetch throws an error causing the offline logic to fail since it cant check status etc. 

Repro steps: 
1) Open the repro codesandbox. https://codesandbox.io/s/beautiful-leakey-fg4px1
2) Open F12 and select Offline in network settings
3) Execute an mutation for example clicking the todo li item. 

Expected: 
The makeErrorResult to return an error down the pipe and not to throw a "Cannot read properties of undefined" error. 

Actual: 
The makeErrorResult fails to process the fetch response and instead throw "Cannot read properties of undefined" when trying to get the response.status. 

Observations: 
I have not created a full offline example here but just trying to replicate the "undefined" response issue. 
The fix is to handle the throw more gracefully in the catch. 





### Reproduction

https://codesandbox.io/s/beautiful-leakey-fg4px1

### Urql version

"@urql/exchange-graphcache": "^6.0.1",
"@urql/core": "^4.0.2",
"urql": "^4.0.0"

### Validations

- [X] I can confirm that this is a bug report, and not a feature request, RFC, question, or discussion, for which [GitHub Discussions](https://github.com/urql-graphql/urql/discussions) should be used
- [X] Read the [docs](https://formidable.com/open-source/urql/docs/).
- [X] Follow our [Code of Conduct](https://github.com/urql-graphql/urql/blob/main/CODE_OF_CONDUCT.md)

Pull request:
fix(core): Handle fetch rejections and pass to makeErrorResult
Resolves #3128

## Summary

We had a missing catch case, where `!response` wasn't handled, which can happen if `fetch` rejects immediately rather than delivering a failed response. This would happen in particular due to offline network errors or broken connection attempts.

This is a regression introduced in the fetch refactor, and was introduced because we don't have two catch handlers anymore, hence `response!` was an invalid non-undefined assertion.

## Set of changes

- Add missing `!response` case
- Add test


Patch:
diff --git a/.changeset/eight-kangaroos-wave.md b/.changeset/eight-kangaroos-wave.md
new file mode 100644
index 00000000..1432187b
--- /dev/null
+++ b/.changeset/eight-kangaroos-wave.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': patch
+---
+
+Handle `fetch` rejections in `makeFetchSource` and properly hand them over to `CombinedError`s.
diff --git a/docs/advanced/subscriptions.md b/docs/advanced/subscriptions.md
index 9af3de1e..9af6acbf 100644
--- a/docs/advanced/subscriptions.md
+++ b/docs/advanced/subscriptions.md
@@ -34,8 +34,17 @@ page.](../architecture.md)
 In the above example, we add the `subscriptionExchange` to the `Client` with the default exchanges
 added before it. The `subscriptionExchange` is a factory that accepts additional options and returns
 the actual `Exchange` function. It does not make any assumption over the transport protocol and
-scheme that is used. Instead, we need to pass a `forwardSubscription` function, which is called with
-an "enriched" _Operation_ every time the `Client` attempts to execute a GraphQL Subscription.
+scheme that is used. Instead, we need to pass a `forwardSubscription` function.
+
+The `forwardSubscription` is called when the `subscriptionExchange` receives an `Operation`, so
+typically, when you’re executing a GraphQL subscription. This will call the `forwardSubscription`
+function with a GraphQL request body, in the same shape that a GraphQL HTTP API may receive it as
+JSON input.
+
+If you’re using TypeScript, you may notice that the input that `forwardSubscription` receives has
+an optional `query` property. This is because of persisted query support. For some transports, the
+`query` property may have to be defaulted to an empty string, which matches the GraphQL over HTTP
+specification more closely.
 
 When we define this function it must return an "Observable-like" object, which needs to follow the
 [Observable spec](https://github.com/tc39/proposal-observable), which comes down to having an
@@ -59,11 +68,15 @@ const client = new Client({
     cacheExchange,
     fetchExchange,
     subscriptionExchange({
-      forwardSubscription: request => ({
-        subscribe: sink => ({
-          unsubscribe: wsClient.subscribe(request, sink),
-        }),
-      }),
+      forwardSubscription(request) {
+        const input = { ...request, query: request.query || '' };
+        return {
+          subscribe(sink) {
+            const unsubscribe = wsClient.subscribe(input, sink);
+            return { unsubscribe };
+          },
+        };
+      },
     }),
   ],
 });
diff --git a/packages/core/src/internal/fetchSource.ts b/packages/core/src/internal/fetchSource.ts
index ca8df035..6df113c9 100644
--- a/packages/core/src/internal/fetchSource.ts
+++ b/packages/core/src/internal/fetchSource.ts
@@ -107,7 +107,7 @@ async function* fetchOperation(
   let networkMode = true;
   let abortController: AbortController | void;
   let result: OperationResult | null = null;
-  let response: Response;
+  let response: Response | void;
 
   try {
     if (typeof AbortController !== 'undefined') {
@@ -151,11 +151,12 @@ async function* fetchOperation(
 
     yield makeErrorResult(
       operation,
-      (response!.status < 200 || response!.status >= 300) &&
-        response!.statusText
-        ? new Error(response!.statusText)
+      response &&
+        (response.status < 200 || response.status >= 300) &&
+        response.statusText
+        ? new Error(response.statusText)
         : error,
-      response!
+      response
     );
   } finally {
     if (abortController) abortController.abort();


Test patch:
diff --git a/packages/core/src/internal/fetchSource.test.ts b/packages/core/src/internal/fetchSource.test.ts
index 8ebcf3b3..3693156e 100644
--- a/packages/core/src/internal/fetchSource.test.ts
+++ b/packages/core/src/internal/fetchSource.test.ts
@@ -106,6 +106,19 @@ describe('on error', () => {
     });
   });
 
+  it('handles network errors', async () => {
+    const error = new Error('test');
+    fetch.mockRejectedValue(error);
+
+    const fetchOptions = {};
+    const data = await pipe(
+      makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions),
+      toPromise
+    );
+
+    expect(data).toHaveProperty('error.networkError', error);
+  });
+
   it('returns error data', async () => {
     const fetchOptions = {};
     const data = await pipe(