-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Show error summary from API when transcript fails to load #1030
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import type { APIError } from '../utils/api'; | ||
|
||
export type TranscriptErrorProps = { | ||
error: APIError; | ||
}; | ||
|
||
export default function TranscriptError({ error }: TranscriptErrorProps) { | ||
return ( | ||
<div className="p-3"> | ||
<h2 className="text-lg">Unable to load transcript</h2> | ||
<p className="mb-3">{error.error?.title ?? error.message}</p> | ||
{error.error?.detail && ( | ||
<details> | ||
<summary className="mb-2">Error details:</summary> | ||
<p>{error.error.detail}</p> | ||
</details> | ||
)} | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -26,6 +26,7 @@ import { formatTranscript, mergeSegments } from '../utils/transcript'; | |
import HypothesisClient from './HypothesisClient'; | ||
import Transcript from './Transcript'; | ||
import type { TranscriptControls } from './Transcript'; | ||
import TranscriptError from './TranscriptError'; | ||
import YouTubeVideoPlayer from './YouTubeVideoPlayer'; | ||
import { PauseIcon, PlayIcon, SyncIcon } from './icons'; | ||
|
||
|
@@ -459,9 +460,7 @@ export default function VideoPlayerApp({ | |
</Transcript> | ||
)} | ||
{transcript instanceof Error && ( | ||
<div data-testid="transcript-error"> | ||
Unable to load transcript: {transcript.message} | ||
</div> | ||
<TranscriptError error={transcript} /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's neat that the same reference can accommodate either an actual transcript or an error (and am of the opinion that typing makes this possible versus a bug waiting to happen). But I admit I did a double-take at There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I might rename this to |
||
)} | ||
<div | ||
id={bucketContainerId} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { mount } from 'enzyme'; | ||
|
||
import { APIError } from '../../utils/api'; | ||
import TranscriptError from '../TranscriptError'; | ||
|
||
describe('TranscriptError', () => { | ||
it('displays just `Error.message` if there are no error details', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this test description need adjustment in light of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is still valid as "error details" includes the title. This case is for when we don't have a server-generated error at all, or it doesn't include even a title. |
||
const wrapper = mount( | ||
<TranscriptError error={new Error('Something went wrong')} /> | ||
); | ||
assert.equal( | ||
wrapper.text(), | ||
['Unable to load transcript', 'Something went wrong'].join('') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
); | ||
}); | ||
|
||
it('displays `APIError.error.title` field if present', () => { | ||
const error = new APIError(404, { | ||
code: 'VideoNotFound', | ||
title: 'The video was not found', | ||
detail: 'Some long details here', | ||
}); | ||
const wrapper = mount(<TranscriptError error={error} />); | ||
assert.equal( | ||
wrapper.text(), | ||
[ | ||
'Unable to load transcript', | ||
'The video was not found', | ||
'Error details:Some long details here', | ||
].join('') | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,22 +10,17 @@ export type APIMethod = { | |
url: string; | ||
}; | ||
|
||
export class APIError extends Error { | ||
/** The HTTP status code of the response. */ | ||
/** | ||
* Structure of a JSON API error. | ||
* | ||
* See https://jsonapi.org/format/#error-objects. | ||
*/ | ||
export type JSONAPIError = { | ||
status: number; | ||
|
||
/** | ||
* Payload of the response. | ||
*/ | ||
data: unknown; | ||
|
||
constructor(status: number, data: unknown) { | ||
super('API call failed'); | ||
|
||
this.status = status; | ||
this.data = data; | ||
} | ||
} | ||
code: string; | ||
title: string; | ||
detail: string; | ||
}; | ||
|
||
/** | ||
* Structure of a JSON API response. | ||
|
@@ -37,9 +32,25 @@ export type JSONAPIObject<Properties extends object> = { | |
type: string; | ||
id: string; | ||
attributes: Properties; | ||
errors: JSONAPIError[]; | ||
}; | ||
}; | ||
|
||
export class APIError extends Error { | ||
/** The HTTP status code of the response. */ | ||
status: number; | ||
|
||
/** Error details returned by the API. */ | ||
error: JSONAPIError | undefined; | ||
|
||
constructor(status: number, error?: JSONAPIError) { | ||
super('API call failed'); | ||
|
||
this.status = status; | ||
this.error = error; | ||
} | ||
} | ||
|
||
/** | ||
* Make an API call to the backend. | ||
* | ||
|
@@ -57,7 +68,15 @@ export async function callAPI<T = unknown>(api: APIMethod): Promise<T> { | |
const resultData = await result.json().catch(() => null); | ||
|
||
if (result.status >= 400 && result.status < 600) { | ||
throw new APIError(result.status, resultData); | ||
let error; | ||
if ( | ||
resultData && | ||
Array.isArray(resultData.errors) && | ||
resultData.errors.length > 0 | ||
) { | ||
error = resultData.errors[0]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The JSON:API format allows for multiple errors, but we currently only report the first one on the frontend. I'm not aware of a scenario where we'd expect the backend to report multiple yet. |
||
} | ||
throw new APIError(result.status, error); | ||
} | ||
|
||
return resultData; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,38 +33,71 @@ describe('callAPI', () => { | |
assert.deepEqual(result, transcriptsAPIResponse); | ||
}); | ||
|
||
it('throws exception if result is not JSON', async () => { | ||
fakeFetch | ||
.withArgs(videoPlayerConfig.api.transcript.url) | ||
.resolves(new Response('<b>Oh no</b>', { status: 500 })); | ||
|
||
let error; | ||
try { | ||
await callAPI(videoPlayerConfig.api.transcript); | ||
} catch (e) { | ||
error = e; | ||
} | ||
|
||
assert.instanceOf(error, APIError); | ||
assert.equal(error.status, 500); | ||
assert.equal(error.data, null); | ||
assert.equal(error.message, 'API call failed'); | ||
}); | ||
context('when request fails', () => { | ||
it('throws exception if result is not JSON', async () => { | ||
fakeFetch | ||
.withArgs(videoPlayerConfig.api.transcript.url) | ||
.resolves(new Response('<b>Oh no</b>', { status: 500 })); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just confirming that we intend to invent a 500 here in these cases :). Granted this is not new behavior in these changes, so feel free to ignore me. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this is mean to simulate something like eg. a 5xx "Bad Gateway" error if the server failed to respond within the various timeouts that the proxies in front have. |
||
|
||
it('throws exception if API request fails', async () => { | ||
fakeFetch | ||
.withArgs(videoPlayerConfig.api.transcript.url) | ||
.resolves(jsonResponse(404)); | ||
|
||
let error; | ||
try { | ||
await callAPI(videoPlayerConfig.api.transcript); | ||
} catch (e) { | ||
error = e; | ||
} | ||
|
||
assert.instanceOf(error, APIError); | ||
assert.equal(error.status, 404); | ||
assert.equal(error.message, 'API call failed'); | ||
let error; | ||
try { | ||
await callAPI(videoPlayerConfig.api.transcript); | ||
} catch (e) { | ||
error = e; | ||
} | ||
|
||
assert.instanceOf(error, APIError); | ||
assert.equal(error.status, 500); | ||
assert.isUndefined(error.error); | ||
assert.equal(error.message, 'API call failed'); | ||
}); | ||
|
||
[{}, { errors: [] }].forEach(responseBody => { | ||
it('throws exception without `error` if `errors` field is missing or empty', async () => { | ||
fakeFetch | ||
.withArgs(videoPlayerConfig.api.transcript.url) | ||
.resolves(jsonResponse(404, responseBody)); | ||
|
||
let error; | ||
try { | ||
await callAPI(videoPlayerConfig.api.transcript); | ||
} catch (e) { | ||
error = e; | ||
} | ||
|
||
assert.instanceOf(error, APIError); | ||
assert.isUndefined(error.error); | ||
assert.equal(error.status, 404); | ||
assert.equal(error.message, 'API call failed'); | ||
}); | ||
}); | ||
|
||
it('throws exception with `error` if `errors` field is present', async () => { | ||
const responseBody = { | ||
errors: [ | ||
{ | ||
code: 'VideoNotFound', | ||
title: 'This video does not exist', | ||
detail: 'Video ID is invalid', | ||
}, | ||
], | ||
}; | ||
|
||
fakeFetch | ||
.withArgs(videoPlayerConfig.api.transcript.url) | ||
.resolves(jsonResponse(404, responseBody)); | ||
|
||
let error; | ||
try { | ||
await callAPI(videoPlayerConfig.api.transcript); | ||
} catch (e) { | ||
error = e; | ||
} | ||
|
||
assert.instanceOf(error, APIError); | ||
assert.deepEqual(error.error, responseBody.errors[0]); | ||
assert.equal(error.status, 404); | ||
assert.equal(error.message, 'API call failed'); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Styling is obvious not final here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, don't worry about that for now. I've got it.