Skip to content

Commit

Permalink
feat(error): handle network connection errors (#1030)
Browse files Browse the repository at this point in the history
  • Loading branch information
setchy authored Apr 17, 2024
1 parent 96058b1 commit 23c1bae
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 80 deletions.
2 changes: 1 addition & 1 deletion src/components/Oops.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('components/Oops.tsx', () => {
it('should render itself & its children', () => {
const mockError = {
title: 'Error title',
description: 'Error description',
descriptions: ['Error description'],
emojis: ['🔥'],
};
const tree = TestRenderer.create(<Oops error={mockError} />);
Expand Down
9 changes: 8 additions & 1 deletion src/components/Oops.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ export const Oops: FC<IProps> = ({ error }) => {
<h2 className="font-semibold text-xl mb-2 text-semibold">
{error.title}
</h2>
<div>{error.description}</div>
{error.descriptions.map((description, i) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: using index for key to keep the error constants clean
<div className="text-center mb-2" key={`error_description_${i}`}>
{description}
</div>
);
})}
</div>
);
};
4 changes: 3 additions & 1 deletion src/components/__snapshots__/Oops.test.tsx.snap

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

273 changes: 207 additions & 66 deletions src/hooks/useNotifications.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import axios from 'axios';
import axios, { AxiosError } from 'axios';
import nock from 'nock';

import { mockAccounts, mockSettings } from '../__mocks__/mock-state';
Expand Down Expand Up @@ -50,17 +50,20 @@ describe('hooks/useNotifications.ts', () => {
});

describe('should fetch notifications with failures - github.com & enterprise', () => {
it('bad credentials', async () => {
const status = 401;
const message = 'Bad credentials';
it('network error', async () => {
const code = AxiosError.ERR_NETWORK;

nock('https://api.github.com/')
.get('/notifications?participating=false')
.reply(status, { message });
.replyWithError({
code: code,
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.reply(status, { message });
.replyWithError({
code: code,
});

const { result } = renderHook(() => useNotifications());

Expand All @@ -70,107 +73,245 @@ describe('hooks/useNotifications.ts', () => {

await waitFor(() => {
expect(result.current.requestFailed).toBe(true);
expect(result.current.errorDetails).toBe(Errors.BAD_CREDENTIALS);
expect(result.current.errorDetails).toBe(Errors.NETWORK);
});
});

it('missing scopes', async () => {
const status = 403;
const message = "Missing the 'notifications' scope";

nock('https://api.github.com/')
.get('/notifications?participating=false')
.reply(status, { message });
describe('bad request errors', () => {
it('bad credentials', async () => {
const code = AxiosError.ERR_BAD_REQUEST;
const status = 401;
const message = 'Bad credentials';

nock('https://api.github.com/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.reply(status, { message });
const { result } = renderHook(() => useNotifications());

const { result } = renderHook(() => useNotifications());
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);
});
});

await waitFor(() => {
expect(result.current.requestFailed).toBe(true);
expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES);
});
});
it('missing scopes', async () => {
const code = AxiosError.ERR_BAD_REQUEST;
const status = 403;
const message = "Missing the 'notifications' scope";

nock('https://api.github.com/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

it('rate limited - primary', async () => {
const status = 403;
const message = 'API rate limit exceeded';
const { result } = renderHook(() => useNotifications());

nock('https://api.github.com/')
.get('/notifications?participating=false')
.reply(status, { message });
act(() => {
result.current.fetchNotifications(mockAccounts, mockSettings);
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.reply(status, { message });
await waitFor(() => {
expect(result.current.requestFailed).toBe(true);
expect(result.current.errorDetails).toBe(Errors.MISSING_SCOPES);
});
});

const { result } = renderHook(() => useNotifications());
it('rate limited - primary', async () => {
const code = AxiosError.ERR_BAD_REQUEST;
const status = 403;
const message = 'API rate limit exceeded';

nock('https://api.github.com/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

act(() => {
result.current.fetchNotifications(mockAccounts, mockSettings);
const { result } = renderHook(() => useNotifications());

act(() => {
result.current.fetchNotifications(mockAccounts, mockSettings);
});

await waitFor(() => {
expect(result.current.requestFailed).toBe(true);
expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
});
});

await waitFor(() => {
expect(result.current.requestFailed).toBe(true);
expect(result.current.errorDetails).toBe(Errors.RATE_LIMITED);
it('rate limited - secondary', async () => {
const code = AxiosError.ERR_BAD_REQUEST;
const status = 403;
const message = 'You have exceeded a secondary rate limit';

nock('https://api.github.com/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

const { result } = renderHook(() => useNotifications());

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';
it('unhandled bad request error', async () => {
const code = AxiosError.ERR_BAD_REQUEST;
const status = 400;
const message = 'Oops! Something went wrong.';

nock('https://api.github.com/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.replyWithError({
code,
response: {
status,
data: {
message,
},
},
});

nock('https://api.github.com/')
.get('/notifications?participating=false')
.reply(status, { message });
const { result } = renderHook(() => useNotifications());

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.reply(status, { message });
act(() => {
result.current.fetchNotifications(mockAccounts, mockSettings);
});

const { result } = renderHook(() => useNotifications());
expect(result.current.isFetching).toBe(true);

act(() => {
result.current.fetchNotifications(mockAccounts, mockSettings);
});
await waitFor(() => {
expect(result.current.isFetching).toBe(false);
});

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.';
it('unknown error', async () => {
const code = 'anything';

nock('https://api.github.com/')
.get('/notifications?participating=false')
.reply(status, { message });
.replyWithError({
code: code,
});

nock('https://github.gitify.io/api/v3/')
.get('/notifications?participating=false')
.reply(status, { message });
.replyWithError({
code: code,
});

const { result } = renderHook(() => useNotifications());

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);
expect(result.current.errorDetails).toBe(Errors.UNKNOWN);
});

expect(result.current.requestFailed).toBe(true);
});
});
});
Expand Down
12 changes: 11 additions & 1 deletion src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import axios, { type AxiosError, type AxiosPromise } from 'axios';
import axios, { AxiosError, type AxiosPromise } from 'axios';
import { useCallback, useState } from 'react';

import type {
Expand Down Expand Up @@ -405,6 +405,16 @@ export const useNotifications = (): NotificationsState => {
};

function determineFailureType(err: AxiosError<GithubRESTError>): GitifyError {
const code = err.code;

if (code === AxiosError.ERR_NETWORK) {
return Errors.NETWORK;
}

if (code !== AxiosError.ERR_BAD_REQUEST) {
return Errors.UNKNOWN;
}

const status = err.response.status;
const message = err.response.data.message;

Expand Down
Loading

0 comments on commit 23c1bae

Please sign in to comment.