Repository: boardgameio/boardgame.io
Pull request #898 (2021-01-27 14:55:04)

Add details to exceptions raised in LobbyClient
This pull request customizes the error type used in `LobbyClient` to enable passing-through to callers additional details about the specific error that the client encountered. Amongst others, this behavior is useful in situations where the errors thrown by the Lobby API may contain remediation hints for the user. For example, when using custom setupData validation (see https://github.com/boardgameio/boardgame.io/pull/831), the errors returned by the API may contain instructions to the user setting up a game on what exactly is incorrect about their configuration.

An alternative implementation could also expose the `response` object directly on `LobbyClientError` which would allow more flexibility to callers but also increase coupling with the client's implementation (e.g. changing away from `fetch` would be a breaking change).

#### Checklist

* [x] Use a separate branch in your local repo (not `master`).
* [x] Test coverage is 100% (or you have a story for why it's ok).



Patch:
diff --git a/packages/client.ts b/packages/client.ts
index def9654..d2e2da6 100644
--- a/packages/client.ts
+++ b/packages/client.ts
@@ -7,6 +7,6 @@
  */
 
 import { Client } from '../src/client/client';
-import { LobbyClient } from '../src/lobby/client';
+import { LobbyClient, LobbyClientError } from '../src/lobby/client';
 
-export { Client, LobbyClient };
+export { Client, LobbyClient, LobbyClientError };
diff --git a/src/lobby/client.ts b/src/lobby/client.ts
index 9d123e0..0dfd4d9 100644
--- a/src/lobby/client.ts
+++ b/src/lobby/client.ts
@@ -24,6 +24,15 @@ const validateBody = (
   }
 };
 
+export class LobbyClientError extends Error {
+  readonly details: any;
+
+  constructor(message: string, details: any) {
+    super(message);
+    this.details = details;
+  }
+}
+
 /**
  * Create a boardgame.io Lobby API client.
  * @param server The API’s base URL, e.g. `http://localhost:8000`.
@@ -38,7 +47,23 @@ export class LobbyClient {
 
   private async request(route: string, init?: RequestInit) {
     const response = await fetch(this.server + route, init);
-    if (!response.ok) throw new Error(`HTTP status ${response.status}`);
+
+    if (!response.ok) {
+      let details: any;
+
+      try {
+        details = await response.json();
+      } catch {
+        try {
+          details = await response.text();
+        } catch (error) {
+          details = error.message;
+        }
+      }
+
+      throw new LobbyClientError(`HTTP status ${response.status}`, details);
+    }
+
     return response.json();
   }
 


Test patch:
diff --git a/src/lobby/client.test.ts b/src/lobby/client.test.ts
index 1e99d0c..474d4e3 100644
--- a/src/lobby/client.test.ts
+++ b/src/lobby/client.test.ts
@@ -67,17 +67,74 @@ describe('LobbyClient', () => {
 
   describe('status errors', () => {
     beforeEach(async () => {
+      client = new LobbyClient();
+    });
+
+    test('404 throws an error', async () => {
       (global as any).fetch = jest.fn(async () => ({
         ok: false,
         status: 404,
         json: async () => {},
       }));
-      client = new LobbyClient();
-    });
 
-    test('404 throws an error', async () => {
       await expect(client.listGames()).rejects.toThrow('HTTP status 404');
     });
+
+    test('404 throws an error with json details', async () => {
+      (global as any).fetch = jest.fn(async () => ({
+        ok: false,
+        status: 404,
+        json: async () => ({ moreInformation: 'some helpful details' }),
+      }));
+
+      await expect(client.listGames()).rejects.toThrow(
+        expect.objectContaining({
+          message: 'HTTP status 404',
+          details: {
+            moreInformation: 'some helpful details',
+          },
+        })
+      );
+    });
+
+    test('404 throws an error with text details', async () => {
+      (global as any).fetch = jest.fn(async () => ({
+        ok: false,
+        status: 404,
+        json: async () => {
+          throw new Error('impossible to parse json');
+        },
+        text: async () =>
+          '<moreInformation>some helpful details</moreInformation>',
+      }));
+
+      await expect(client.listGames()).rejects.toThrow(
+        expect.objectContaining({
+          message: 'HTTP status 404',
+          details: '<moreInformation>some helpful details</moreInformation>',
+        })
+      );
+    });
+
+    test('404 throws an error without details', async () => {
+      (global as any).fetch = jest.fn(async () => ({
+        ok: false,
+        status: 404,
+        json: async () => {
+          throw new Error('impossible to parse json');
+        },
+        text: async () => {
+          throw new Error('something went wrong in the connection');
+        },
+      }));
+
+      await expect(client.listGames()).rejects.toThrow(
+        expect.objectContaining({
+          message: 'HTTP status 404',
+          details: 'something went wrong in the connection',
+        })
+      );
+    });
   });
 
   describe('listGames', () => {