Commit a8fa207e authored by Robert Knight's avatar Robert Knight

Add `fetchJSON` utility

Add a utility function that wraps `fetch` to throw more consistent and
useful exceptions if the request fails, making downstream handling
easier.

- Error messages thrown by failed `fetch` requests are unhelpful for
  downstream consumers. There is no specific Error subclass and the
  error messages vary depending on cause and browser.

- Parsing the JSON response from a request with `Response.json()` can
  fail if the request has a success status and the error messages also
  vary by browser.

The `fetchJSON` utility throws a `FetchError` error if either of the
above failures occurs or if the response code indicates a failure.
parent 9e971701
/**
* An error indicating a failed network request.
*
* Failures that this error can represent include:
*
* - Failures to send an HTTP request
* - Requests that returned non-2xx responses
* - Failures to parse the response in the expected format (eg. JSON)
*/
export class FetchError extends Error {
/**
* @param {Response|null} response - The response to the `fetch` request or
* `null` if the fetch failed
* @param {string} [reason] - Additional details about the error. This might
* include context of the network request or a server-provided error in
* the response.
*/
constructor(response, reason = '') {
let message = 'Network request failed';
if (response) {
message += ` (${response.status})`;
}
if (reason) {
message += `: ${reason}`;
}
super(message);
this.response = response;
this.reason = reason;
}
}
/**
* Execute a network request and return the parsed JSON response.
*
* Throws a `FetchError` if making the request fails or the request returns
* a non-2xx response.
*
* Returns `null` if the request returns a 204 (No Content) response.
*
* @param {string} url
* @param {RequestInit} [init] - Parameters for `fetch` request
*/
export async function fetchJSON(url, init) {
let response;
try {
response = await fetch(url, init);
} catch (err) {
// If the request fails for any reason, wrap the result in a `FetchError`.
// Different browsers use different error messages for `fetch` failures, so
// wrapping the error allows downstream clients to handle this uniformly.
throw new FetchError(null, err.message);
}
if (response.status === 204 /* No Content */) {
return null;
}
// Attempt to parse a JSON response. This may fail even if the status code
// indicates success.
let data;
try {
data = await response.json();
} catch (err) {
throw new FetchError(response, 'Failed to parse response');
}
// If the HTTP status indicates failure, attempt to extract a server-provided
// reason from the response, assuming certain conventions for the formatting
// of error responses.
if (!response.ok) {
throw new FetchError(response, data?.reason);
}
return data;
}
import { FetchError, fetchJSON } from '../fetch';
describe('sidebar/util/fetch', () => {
describe('fetchJSON', () => {
let fakeResponse;
beforeEach(() => {
fakeResponse = {
status: 200,
json: sinon.stub().resolves({}),
get ok() {
return this.status >= 200 && this.status <= 299;
},
};
sinon.stub(window, 'fetch').resolves(fakeResponse);
window.fetch.resolves(fakeResponse);
});
afterEach(() => {
window.fetch.restore();
});
it('fetches the requested URL', async () => {
const init = { method: 'GET' };
await fetchJSON('https://example.com', init);
assert.calledWith(window.fetch, 'https://example.com', init);
});
it('throws a FetchError if `fetch` fails', async () => {
window.fetch.rejects(new Error('Fetch failed'));
let err;
try {
await fetchJSON('https://example.com');
} catch (e) {
err = e;
}
assert.instanceOf(err, FetchError);
assert.include(err.message, 'Network request failed: Fetch failed');
});
it('returns null if the response succeeds with a 204 status', async () => {
fakeResponse.status = 204;
const result = await fetchJSON('https://example.com');
assert.strictEqual(result, null);
});
it('throws a FetchError if parsing JSON response fails', async () => {
fakeResponse.json.rejects(new Error('Oh no'));
let err;
try {
await fetchJSON('https://example.com');
} catch (e) {
err = e;
}
assert.instanceOf(err, FetchError);
assert.equal(
err.message,
'Network request failed (200): Failed to parse response'
);
});
it('throws a FetchError if the response has a non-2xx status code', async () => {
fakeResponse.status = 400;
fakeResponse.json.resolves({ reason: 'server error' });
let err;
try {
err = await fetchJSON('https://example.com');
} catch (e) {
err = e;
}
assert.instanceOf(err, FetchError);
assert.equal(err.message, 'Network request failed (400): server error');
});
it('returns the parsed JSON response if the request was successful', async () => {
fakeResponse.json.resolves({ foo: 'bar' });
const result = await fetchJSON('https://example.com');
assert.deepEqual(result, { foo: 'bar' });
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment