Repository: gitify-app/gitify
Pull request #965 (2024-04-06 11:14:13)

feat: detailed error handling
Improve error handling feedback loop by inspecting err.response and determining root cause of error.

Refactored the previous `Oops` view to allow error types to be passed in.

| Error Type  | Example |
| ------------- | ------------- |
| Bad credentials  | <img width="504" alt="Screenshot 2024-04-06 at 6 33 48 PM" src="https://github.com/gitify-app/gitify/assets/386277/962dd885-cf63-49a7-bd58-bd3a30444ae4"> |
| Missing scopes |<img width="501" alt="Screenshot 2024-04-06 at 6 34 07 PM" src="https://github.com/gitify-app/gitify/assets/386277/3dd15d9a-973b-44f3-86c1-0ad9be820dea"> |
| Rate limited |<img width="497" alt="Screenshot 2024-04-06 at 6 35 31 PM" src="https://github.com/gitify-app/gitify/assets/386277/118800b8-2cae-4507-828d-8eed0d564146"> |
| Default case (existing) | <img width="501" alt="Screenshot 2024-04-06 at 6 35 47 PM" src="https://github.com/gitify-app/gitify/assets/386277/a9862ba9-bd85-437e-b68c-247f9e803e77">|







setchy: > Great improvement, very user friendly. Really love this architecture, with the data structure. Clean and clear, easy to modify later. Nice work!

thanks!

I think if/when we support partial notifications (ie: one account works, one account fails) the Error page will need to morph into an Error/Warning banner.  A future bridge for us to cross
setchy: Ended up renaming the component back to `Oops` since biome warned about using a global name `Error` (smart!)

Patch:
diff --git a/.husky/pre-commit b/.husky/pre-commit
old mode 100644
new mode 100755
index 88c91d67..c6254913
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,2 +1,2 @@
 pnpx lint-staged
-pnpm test -- --onlyChanged
\ No newline at end of file
+pnpm test -- --onlyChanged
diff --git a/README.md b/README.md
index 0e2b3462..1db31b02 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ Gitify is licensed under the MIT Open Source license. For more information, see
 [brew]: http://brew.sh/
 [homebrew-cask]: https://formulae.brew.sh/cask/gitify
 [coveralls]: https://coveralls.io/github/gitify-app/gitify
-[coveralls-badge]: https://img.shields.io/coverallsCoverage/github/gitify-app/gitify
+[coveralls-badge]: https://coveralls.io/repos/github/gitify-app/gitify/badge.svg
 [build-workflow-badge]: https://github.com/gitify-app/gitify/actions/workflows/build-app.yml/badge.svg
 [release-workflow-badge]: https://github.com/gitify-app/gitify/actions/workflows/release.yml/badge.svg
 [downloads-total-badge]: https://img.shields.io/github/downloads/gitify-app/gitify/total?label=downloads@all
diff --git a/src/components/Oops.tsx b/src/components/Oops.tsx
index 8025d5a7..1619b986 100644
--- a/src/components/Oops.tsx
+++ b/src/components/Oops.tsx
@@ -1,12 +1,14 @@
-import { useMemo } from 'react';
-import { Constants } from '../utils/constants';
+import { type FC, useMemo } from 'react';
 
-export const Oops = () => {
+import type { GitifyError } from '../types';
+
+interface IProps {
+  error: GitifyError;
+}
+
+export const Oops: FC<IProps> = ({ error }) => {
   const emoji = useMemo(
-    () =>
-      Constants.ERROR_EMOJIS[
-        Math.floor(Math.random() * Constants.ERROR_EMOJIS.length)
-      ],
+    () => error.emojis[Math.floor(Math.random() * error.emojis.length)],
     [],
   );
 
@@ -15,9 +17,9 @@ export const Oops = () => {
       <h1 className="text-5xl mb-5">{emoji}</h1>
 
       <h2 className="font-semibold text-xl mb-2 text-semibold">
-        Something went wrong.
+        {error.title}
       </h2>
-      <div>Couldn't get your notifications.</div>
+      <div>{error.description}</div>
     </div>
   );
 };
diff --git a/src/context/App.tsx b/src/context/App.tsx
index 96fb5e7b..5023eb20 100644
--- a/src/context/App.tsx
+++ b/src/context/App.tsx
@@ -14,6 +14,7 @@ import {
   type AuthOptions,
   type AuthState,
   type AuthTokenOptions,
+  type GitifyError,
   type SettingsState,
   Theme,
 } from '../types';
@@ -56,6 +57,7 @@ interface AppContextState {
   notifications: AccountNotifications[];
   isFetching: boolean;
   requestFailed: boolean;
+  errorDetails: GitifyError;
   removeNotificationFromState: (id: string, hostname: string) => void;
   fetchNotifications: () => Promise<void>;
   markNotificationRead: (id: string, hostname: string) => Promise<void>;
@@ -80,6 +82,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
     fetchNotifications,
     notifications,
     requestFailed,
+    errorDetails,
     isFetching,
     removeNotificationFromState,
     markNotificationRead,
@@ -238,6 +241,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
         notifications,
         isFetching,
         requestFailed,
+        errorDetails,
         removeNotificationFromState,
         fetchNotifications: fetchNotificationsWithAccounts,
         markNotificationRead: markNotificationReadWithAccounts,
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 802e2512..a248c75e 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -1,10 +1,15 @@
-import axios, { type AxiosPromise } from 'axios';
+import axios, { type AxiosError, type AxiosPromise } from 'axios';
 import { useCallback, useState } from 'react';
 
-import type { AccountNotifications, AuthState, SettingsState } from '../types';
-import type { Notification } from '../typesGithub';
+import type {
+  AccountNotifications,
+  AuthState,
+  GitifyError,
+  SettingsState,
+} from '../types';
+import type { GithubRESTError, Notification } from '../typesGithub';
 import { apiRequestAuth } from '../utils/api-requests';
-import Constants from '../utils/constants';
+import Constants, { Errors } from '../utils/constants';
 import {
   generateGitHubAPIUrl,
   getEnterpriseAccountToken,
@@ -54,11 +59,14 @@ interface NotificationsState {
   ) => Promise<void>;
   isFetching: boolean;
   requestFailed: boolean;
+  errorDetails: GitifyError;
 }
 
 export const useNotifications = (colors: boolean): NotificationsState => {
   const [isFetching, setIsFetching] = useState(false);
   const [requestFailed, setRequestFailed] = useState(false);
+  const [errorDetails, setErrorDetails] = useState<GitifyError>();
+
   const [notifications, setNotifications] = useState<AccountNotifications[]>(
     [],
   );
@@ -195,9 +203,10 @@ export const useNotifications = (colors: boolean): NotificationsState => {
               });
           }),
         )
-        .catch(() => {
+        .catch((err: AxiosError<GithubRESTError>) => {
           setIsFetching(false);
           setRequestFailed(true);
+          setErrorDetails(determineFailureType(err));
         });
     },
     [notifications],
@@ -388,6 +397,7 @@ export const useNotifications = (colors: boolean): NotificationsState => {
   return {
     isFetching,
     requestFailed,
+    errorDetails,
     notifications,
 
     removeNotificationFromState,
@@ -399,3 +409,27 @@ export const useNotifications = (colors: boolean): NotificationsState => {
     markRepoNotificationsDone,
   };
 };
+
+function determineFailureType(err: AxiosError<GithubRESTError>): GitifyError {
+  const status = err.response.status;
+  const message = err.response.data.message;
+
+  if (status === 401) {
+    return Errors.BAD_CREDENTIALS;
+  }
+
+  if (status === 403) {
+    if (message.includes("Missing the 'notifications' scope")) {
+      return Errors.MISSING_SCOPES;
+    }
+
+    if (
+      message.includes('API rate limit exceeded') ||
+      message.includes('You have exceeded a secondary rate limit')
+    ) {
+      return Errors.RATE_LIMITED;
+    }
+  }
+
+  return Errors.UNKNOWN;
+}
diff --git a/src/routes/Notifications.tsx b/src/routes/Notifications.tsx
index cafde45e..f006efe2 100644
--- a/src/routes/Notifications.tsx
+++ b/src/routes/Notifications.tsx
@@ -4,10 +4,12 @@ import { AccountNotifications } from '../components/AccountNotifications';
 import { AllRead } from '../components/AllRead';
 import { Oops } from '../components/Oops';
 import { AppContext } from '../context/App';
+import { Errors } from '../utils/constants';
 import { getNotificationCount } from '../utils/notifications';
 
 export const NotificationsRoute: FC = () => {
-  const { notifications, requestFailed, settings } = useContext(AppContext);
+  const { notifications, requestFailed, errorDetails, settings } =
+    useContext(AppContext);
 
   const hasMultipleAccounts = useMemo(
     () => notifications.length > 1,
@@ -23,7 +25,7 @@ export const NotificationsRoute: FC = () => {
   );
 
   if (requestFailed) {
-    return <Oops />;
+    return <Oops error={errorDetails ?? Errors.UNKNOWN} />;
   }
 
   if (!hasNotifications) {
diff --git a/src/types.ts b/src/types.ts
index 5b0b0515..c53f9dc0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -75,3 +75,15 @@ export interface GitifyUser {
   name: string;
   id: number;
 }
+
+export interface GitifyError {
+  title: string;
+  description: string;
+  emojis: string[];
+}
+
+export type ErrorType =
+  | 'BAD_CREDENTIALS'
+  | 'MISSING_SCOPES'
+  | 'RATE_LIMITED'
+  | 'UNKNOWN';
diff --git a/src/typesGithub.ts b/src/typesGithub.ts
index 14724f14..fb7e8eb1 100644
--- a/src/typesGithub.ts
+++ b/src/typesGithub.ts
@@ -369,3 +369,8 @@ export interface WorkflowRunAttributes {
   statusDisplayName: string;
   status: CheckSuiteStatus | null;
 }
+
+export interface GithubRESTError {
+  message: string;
+  documentation_url: string;
+}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index e83de689..f99d2025 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -1,3 +1,5 @@
+import type { ErrorType, GitifyError } from '../types';
+
 export const Constants = {
   // GitHub OAuth
   AUTH_SCOPE: ['read:user', 'notifications', 'repo'],
@@ -14,8 +16,29 @@ export const Constants = {
   STORAGE_KEY: 'gitify-storage',
 
   ALLREAD_EMOJIS: ['😉', '🎉', '🐯', '🙈', '🎈', '🎊', '👏', '🎪', '🍝'],
+};
 
-  ERROR_EMOJIS: ['🤔', '😞', '😤', '😱', '😭'],
+export const Errors: Record<ErrorType, GitifyError> = {
+  BAD_CREDENTIALS: {
+    title: 'Bad Credentials',
+    description: 'Your credentials are either invalid or expired.',
+    emojis: ['🔓'],
+  },
+  MISSING_SCOPES: {
+    title: 'Missing Scopes',
+    description: 'Your credentials are missing a required API scope.',
+    emojis: ['🙃'],
+  },
+  RATE_LIMITED: {
+    title: 'Rate Limited',
+    description: 'Please wait a while before trying again.',
+    emojis: ['😮‍💨'],
+  },
+  UNKNOWN: {
+    title: 'Oops! Something went wrong',
+    description: 'Please try again later.',
+    emojis: ['🤔', '😞', '😤', '😱', '😭'],
+  },
 };
 
 export default Constants;
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index cb918752..814023fa 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -82,11 +82,14 @@ export function formatSearchQueryString(
 }
 
 export async function getHtmlUrl(url: string, token: string): Promise<string> {
-  const response: Issue | IssueComments | PullRequest = (
-    await apiRequestAuth(url, 'GET', token)
-  ).data;
-
-  return response.html_url;
+  try {
+    const response: Issue | IssueComments | PullRequest = (
+      await apiRequestAuth(url, 'GET', token)
+    ).data;
+    return response.html_url;
+  } catch (err) {
+    console.error('Failed to get html url');
+  }
 }
 
 export function getCheckSuiteUrl(notification: Notification) {
diff --git a/src/utils/subject.ts b/src/utils/subject.ts
index 11fecf09..dba28b6e 100644
--- a/src/utils/subject.ts
+++ b/src/utils/subject.ts
@@ -137,49 +137,57 @@ async function getGitifySubjectForIssue(
   notification: Notification,
   token: string,
 ): Promise<GitifySubject> {
-  const issue: Issue = (
-    await apiRequestAuth(notification.subject.url, 'GET', token)
-  ).data;
+  try {
+    const issue: Issue = (
+      await apiRequestAuth(notification.subject.url, 'GET', token)
+    ).data;
 
-  const issueCommentUser = await getLatestCommentUser(notification, token);
+    const issueCommentUser = await getLatestCommentUser(notification, token);
 
-  return {
-    state: issue.state_reason ?? issue.state,
-    user: {
-      login: issueCommentUser?.login ?? issue.user.login,
-      html_url: issueCommentUser?.html_url ?? issue.user.html_url,
-      avatar_url: issueCommentUser?.avatar_url ?? issue.user.avatar_url,
-      type: issueCommentUser?.type ?? issue.user.type,
-    },
-  };
+    return {
+      state: issue.state_reason ?? issue.state,
+      user: {
+        login: issueCommentUser?.login ?? issue.user.login,
+        html_url: issueCommentUser?.html_url ?? issue.user.html_url,
+        avatar_url: issueCommentUser?.avatar_url ?? issue.user.avatar_url,
+        type: issueCommentUser?.type ?? issue.user.type,
+      },
+    };
+  } catch (err) {
+    console.error('Issue subject retrieval failed');
+  }
 }
 
 async function getGitifySubjectForPullRequest(
   notification: Notification,
   token: string,
 ): Promise<GitifySubject> {
-  const pr: PullRequest = (
-    await apiRequestAuth(notification.subject.url, 'GET', token)
-  ).data;
-
-  let prState: PullRequestStateType = pr.state;
-  if (pr.merged) {
-    prState = 'merged';
-  } else if (pr.draft) {
-    prState = 'draft';
-  }
+  try {
+    const pr: PullRequest = (
+      await apiRequestAuth(notification.subject.url, 'GET', token)
+    ).data;
+
+    let prState: PullRequestStateType = pr.state;
+    if (pr.merged) {
+      prState = 'merged';
+    } else if (pr.draft) {
+      prState = 'draft';
+    }
 
-  const prCommentUser = await getLatestCommentUser(notification, token);
+    const prCommentUser = await getLatestCommentUser(notification, token);
 
-  return {
-    state: prState,
-    user: {
-      login: prCommentUser?.login ?? pr.user.login,
-      html_url: prCommentUser?.html_url ?? pr.user.html_url,
-      avatar_url: prCommentUser?.avatar_url ?? pr.user.avatar_url,
-      type: prCommentUser?.type ?? pr.user.type,
-    },
-  };
+    return {
+      state: prState,
+      user: {
+        login: prCommentUser?.login ?? pr.user.login,
+        html_url: prCommentUser?.html_url ?? pr.user.html_url,
+        avatar_url: prCommentUser?.avatar_url ?? pr.user.avatar_url,
+        type: prCommentUser?.type ?? pr.user.type,
+      },
+    };
+  } catch (err) {
+    console.error('Pull Request subject retrieval failed');
+  }
 }
 
 async function getGitifySubjectForRelease(
@@ -250,11 +258,19 @@ async function getLatestCommentUser(
     return null;
   }
 
-  const response: IssueComments | ReleaseComments = (
-    await apiRequestAuth(notification.subject.latest_comment_url, 'GET', token)
-  )?.data;
-
-  return (
-    (response as IssueComments)?.user ?? (response as ReleaseComments).author
-  );
+  try {
+    const response: IssueComments | ReleaseComments = (
+      await apiRequestAuth(
+        notification.subject.latest_comment_url,
+        'GET',
+        token,
+      )
+    )?.data;
+
+    return (
+      (response as IssueComments)?.user ?? (response as ReleaseComments).author
+    );
+  } catch (err) {
+    console.error('Discussion latest comment retrieval failed');
+  }
 }


Test patch:
diff --git a/src/components/Oops.test.tsx b/src/components/Oops.test.tsx
index b78b5138..601f9ef0 100644
--- a/src/components/Oops.test.tsx
+++ b/src/components/Oops.test.tsx
@@ -1,16 +1,15 @@
 import * as TestRenderer from 'react-test-renderer';
 
-import { mockMathRandom } from './test-utils';
-
 import { Oops } from './Oops';
 
 describe('components/Oops.tsx', () => {
-  // The error emoji randomly rotates, but then the snapshots would never match
-  // Have to make it consistent so the emojis are always the same
-  mockMathRandom(0.1);
-
   it('should render itself & its children', () => {
-    const tree = TestRenderer.create(<Oops />);
+    const mockError = {
+      title: 'Error title',
+      description: 'Error description',
+      emojis: ['🔥'],
+    };
+    const tree = TestRenderer.create(<Oops error={mockError} />);
 
     expect(tree).toMatchSnapshot();
   });
diff --git a/src/components/__snapshots__/Oops.test.tsx.snap b/src/components/__snapshots__/Oops.test.tsx.snap
index 661f8d20..4d777f8f 100644
--- a/src/components/__snapshots__/Oops.test.tsx.snap
+++ b/src/components/__snapshots__/Oops.test.tsx.snap
@@ -7,15 +7,15 @@ exports[`components/Oops.tsx should render itself & its children 1`] = `
   <h1
     className="text-5xl mb-5"
   >
-    🤔
+    🔥
   </h1>
   <h2
     className="font-semibold text-xl mb-2 text-semibold"
   >
-    Something went wrong.
+    Error title
   </h2>
   <div>
-    Couldn't get your notifications.
+    Error description
   </div>
 </div>
 `;
diff --git a/src/hooks/useNotifications.test.ts b/src/hooks/useNotifications.test.ts
index 43196bd0..0df092ec 100644
--- a/src/hooks/useNotifications.test.ts
+++ b/src/hooks/useNotifications.test.ts
@@ -5,6 +5,7 @@ import nock from 'nock';
 import { mockAccounts, mockSettings } from '../__mocks__/mock-state';
 import { mockedNotificationUser, mockedUser } from '../__mocks__/mockedData';
 import type { AuthState } from '../types';
+import { Errors } from '../utils/constants';
 import { useNotifications } from './useNotifications';
 
 describe('hooks/useNotifications.ts', () => {
@@ -48,30 +49,129 @@ describe('hooks/useNotifications.ts', () => {
         expect(result.current.notifications[1].hostname).toBe('github.com');
       });
 
-      it('should fetch notifications with failure - github.com & enterprise', async () => {
-        const message = 'Oops! Something went wrong.';
+      describe('should fetch notifications with failures - github.com & enterprise', () => {
+        it('bad credentials', async () => {
+          const status = 401;
+          const message = 'Bad credentials';
 
-        nock('https://api.github.com/')
-          .get('/notifications?participating=false')
-          .reply(400, { message });
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
 
-        nock('https://github.gitify.io/api/v3/')
-          .get('/notifications?participating=false')
-          .reply(400, { message });
+          nock('https://github.gitify.io/api/v3/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
 
-        const { result } = renderHook(() => useNotifications(false));
+          const { result } = renderHook(() => useNotifications(false));
 
-        act(() => {
-          result.current.fetchNotifications(mockAccounts, mockSettings);
+          act(() => {
+            result.current.fetchNotifications(mockAccounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS);
+          });
         });
 
-        expect(result.current.isFetching).toBe(true);
+        it('missing scopes', async () => {
+          const status = 403;
+          const message = "Missing the 'notifications' scope";
 
-        await waitFor(() => {
-          expect(result.current.isFetching).toBe(false);
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          nock('https://github.gitify.io/api/v3/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(mockAccounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES);
+          });
         });
 
-        expect(result.current.requestFailed).toBe(true);
+        it('rate limited - primary', async () => {
+          const status = 403;
+          const message = 'API rate limit exceeded';
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          nock('https://github.gitify.io/api/v3/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(mockAccounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
+          });
+        });
+
+        it('rate limited - secondary', async () => {
+          const status = 403;
+          const message = 'You have exceeded a secondary rate limit';
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          nock('https://github.gitify.io/api/v3/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(mockAccounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
+          });
+        });
+
+        it('default error', async () => {
+          const status = 400;
+          const message = 'Oops! Something went wrong.';
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          nock('https://github.gitify.io/api/v3/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(mockAccounts, mockSettings);
+          });
+
+          expect(result.current.isFetching).toBe(true);
+
+          await waitFor(() => {
+            expect(result.current.isFetching).toBe(false);
+          });
+
+          expect(result.current.requestFailed).toBe(true);
+        });
       });
     });
 
@@ -158,24 +258,125 @@ describe('hooks/useNotifications.ts', () => {
         expect(result.current.notifications[0].notifications.length).toBe(2);
       });
 
-      it('should fetch notifications with failure - github.com only', async () => {
-        const accounts: AuthState = {
-          ...mockAccounts,
-          enterpriseAccounts: [],
-        };
+      describe('should fetch notifications with failures - github.com only', () => {
+        it('bad credentials', async () => {
+          const status = 401;
+          const message = 'Bad credentials';
+          const accounts: AuthState = {
+            ...mockAccounts,
+            enterpriseAccounts: [],
+          };
 
-        nock('https://api.github.com/')
-          .get('/notifications?participating=false')
-          .reply(400, { message: 'Oops! Something went wrong.' });
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
 
-        const { result } = renderHook(() => useNotifications(false));
+          const { result } = renderHook(() => useNotifications(false));
 
-        act(() => {
-          result.current.fetchNotifications(accounts, mockSettings);
+          act(() => {
+            result.current.fetchNotifications(accounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS);
+          });
         });
 
-        await waitFor(() => {
-          expect(result.current.requestFailed).toBe(true);
+        it('missing scopes', async () => {
+          const status = 403;
+          const message = "Missing the 'notifications' scope";
+          const accounts: AuthState = {
+            ...mockAccounts,
+            enterpriseAccounts: [],
+          };
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(accounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES);
+          });
+        });
+
+        it('rate limited - primary', async () => {
+          const status = 403;
+          const message = 'API rate limit exceeded';
+          const accounts: AuthState = {
+            ...mockAccounts,
+            enterpriseAccounts: [],
+          };
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(accounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
+          });
+        });
+
+        it('rate limited - secondary', async () => {
+          const status = 403;
+          const message = 'You have exceeded a secondary rate limit';
+          const accounts: AuthState = {
+            ...mockAccounts,
+            enterpriseAccounts: [],
+          };
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(accounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
+          });
+        });
+
+        it('default error', async () => {
+          const status = 400;
+          const message = 'Oops! Something went wrong.';
+          const accounts: AuthState = {
+            ...mockAccounts,
+            enterpriseAccounts: [],
+          };
+
+          nock('https://api.github.com/')
+            .get('/notifications?participating=false')
+            .reply(status, { message });
+
+          const { result } = renderHook(() => useNotifications(false));
+
+          act(() => {
+            result.current.fetchNotifications(accounts, mockSettings);
+          });
+
+          await waitFor(() => {
+            expect(result.current.requestFailed).toBe(true);
+            expect(result.current.errorDetails).toBe(Errors.UNKNOWN);
+          });
         });
       });
     });
diff --git a/src/routes/Notifications.test.tsx b/src/routes/Notifications.test.tsx
index 55057020..b46e0f27 100644
--- a/src/routes/Notifications.test.tsx
+++ b/src/routes/Notifications.test.tsx
@@ -3,6 +3,7 @@ import TestRenderer from 'react-test-renderer';
 import { mockSettings } from '../__mocks__/mock-state';
 import { mockedAccountNotifications } from '../__mocks__/mockedData';
 import { AppContext } from '../context/App';
+import { Errors } from '../utils/constants';
 import { NotificationsRoute } from './Notifications';
 
 jest.mock('../components/AccountNotifications', () => ({
@@ -53,13 +54,85 @@ describe('routes/Notifications.tsx', () => {
     expect(tree).toMatchSnapshot();
   });
 
-  it('should render itself & its children (error page - oops)', () => {
-    const tree = TestRenderer.create(
-      <AppContext.Provider value={{ notifications: [], requestFailed: true }}>
-        <NotificationsRoute />
-      </AppContext.Provider>,
-    );
+  describe('should render itself & its children (error conditions - oops)', () => {
+    it('bad credentials', () => {
+      const tree = TestRenderer.create(
+        <AppContext.Provider
+          value={{
+            notifications: [],
+            requestFailed: true,
+            errorDetails: Errors.BAD_CREDENTIALS,
+          }}
+        >
+          <NotificationsRoute />
+        </AppContext.Provider>,
+      );
 
-    expect(tree).toMatchSnapshot();
+      expect(tree).toMatchSnapshot();
+    });
+
+    it('missing scopes', () => {
+      const tree = TestRenderer.create(
+        <AppContext.Provider
+          value={{
+            notifications: [],
+            requestFailed: true,
+            errorDetails: Errors.MISSING_SCOPES,
+          }}
+        >
+          <NotificationsRoute />
+        </AppContext.Provider>,
+      );
+
+      expect(tree).toMatchSnapshot();
+    });
+
+    it('rate limited', () => {
+      const tree = TestRenderer.create(
+        <AppContext.Provider
+          value={{
+            notifications: [],
+            requestFailed: true,
+            errorDetails: Errors.RATE_LIMITED,
+          }}
+        >
+          <NotificationsRoute />
+        </AppContext.Provider>,
+      );
+
+      expect(tree).toMatchSnapshot();
+    });
+
+    it('unknown error', () => {
+      const tree = TestRenderer.create(
+        <AppContext.Provider
+          value={{
+            notifications: [],
+            requestFailed: true,
+            errorDetails: Errors.UNKNOWN,
+          }}
+        >
+          <NotificationsRoute />
+        </AppContext.Provider>,
+      );
+
+      expect(tree).toMatchSnapshot();
+    });
+
+    it('default error', () => {
+      const tree = TestRenderer.create(
+        <AppContext.Provider
+          value={{
+            notifications: [],
+            requestFailed: true,
+            errorDetails: null,
+          }}
+        >
+          <NotificationsRoute />
+        </AppContext.Provider>,
+      );
+
+      expect(tree).toMatchSnapshot();
+    });
   });
 });
diff --git a/src/routes/__snapshots__/Notifications.test.tsx.snap b/src/routes/__snapshots__/Notifications.test.tsx.snap
index 6af8f8fb..6dd25e22 100644
--- a/src/routes/__snapshots__/Notifications.test.tsx.snap
+++ b/src/routes/__snapshots__/Notifications.test.tsx.snap
@@ -2,7 +2,83 @@
 
 exports[`routes/Notifications.tsx should render itself & its children (all read notifications) 1`] = `<AllRead />`;
 
-exports[`routes/Notifications.tsx should render itself & its children (error page - oops) 1`] = `<Oops />`;
+exports[`routes/Notifications.tsx should render itself & its children (error conditions - oops) bad credentials 1`] = `
+<Oops
+  error={
+    {
+      "description": "Your credentials are either invalid or expired.",
+      "emojis": [
+        "🔓",
+      ],
+      "title": "Bad Credentials",
+    }
+  }
+/>
+`;
+
+exports[`routes/Notifications.tsx should render itself & its children (error conditions - oops) default error 1`] = `
+<Oops
+  error={
+    {
+      "description": "Please try again later.",
+      "emojis": [
+        "🤔",
+        "😞",
+        "😤",
+        "😱",
+        "😭",
+      ],
+      "title": "Oops! Something went wrong",
+    }
+  }
+/>
+`;
+
+exports[`routes/Notifications.tsx should render itself & its children (error conditions - oops) missing scopes 1`] = `
+<Oops
+  error={
+    {
+      "description": "Your credentials are missing a required API scope.",
+      "emojis": [
+        "🙃",
+      ],
+      "title": "Missing Scopes",
+    }
+  }
+/>
+`;
+
+exports[`routes/Notifications.tsx should render itself & its children (error conditions - oops) rate limited 1`] = `
+<Oops
+  error={
+    {
+      "description": "Please wait a while before trying again.",
+      "emojis": [
+        "😮‍💨",
+      ],
+      "title": "Rate Limited",
+    }
+  }
+/>
+`;
+
+exports[`routes/Notifications.tsx should render itself & its children (error conditions - oops) unknown error 1`] = `
+<Oops
+  error={
+    {
+      "description": "Please try again later.",
+      "emojis": [
+        "🤔",
+        "😞",
+        "😤",
+        "😱",
+        "😭",
+      ],
+      "title": "Oops! Something went wrong",
+    }
+  }
+/>
+`;
 
 exports[`routes/Notifications.tsx should render itself & its children (show account hostname) 1`] = `
 <div