Skip to content
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

When Non-OK Response Received from fetch, throw an error including the response #1205

Merged
merged 10 commits into from
Jan 25, 2017
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ David Glasser <glasser@meteor.com>
Dominic Watson <intellix@gmail.com>
Dhaivat Pandya <dhaivat@meteor.com>
Dhaivat Pandya <dhaivatpandya@gmail.com>
Doug Swain <pseudoramble@gmail.com>
Google Inc.
Ian Grayson <ian133@gmail.com>
Ian MacLeod <ian@nevir.net>
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Expect active development and potentially significant breaking changes in the `0
- expose partial in ObservableQuery#currentResult [PR #1097](https://github.com/apollostack/apollo-client/pull/1097)
- Calculate `loading` from `networkStatus`. [PR #1202](https://github.com/apollostack/apollo-client/pull/1202)
- Fix typings error with `strictNullChecks` [PR #1188](https://github.com/apollostack/apollo-client/pull/1188)
- Add IResponse to NetworkErrors [PR #1199](https://github.com/apollostack/apollo-client/issues/1199)
- Gracefully handle `null` GraphQL errors. [PR #1208](https://github.com/apollostack/apollo-client/pull/1208)
- *Breaking:* Remove undocumented `resultBehaviors` feature. [PR #1173](https://github.com/apollostack/apollo-client/pull/1173)

Expand Down
26 changes: 26 additions & 0 deletions src/errors/HttpNetworkError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export interface Response {
status: number;
statusText?: string;
}

export default class HttpNetworkError extends Error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this integrate with ApolloError? Could we just add request and response to ApolloError network errors? Should we change the type of networkError here to HttpNetworkError?

I’d like to see a better story around HTTP network errors like this one, and the errors already handled by Apollo Client. I’m also kind of uncomfortable adding a new error class without a clear reason why.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, does this PR make all HTTP network errors HttpNetworkError? If not then we might need a new name as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be the value of the networkError provided on ApolloError instances. Callers to the networkInterface take its errors and replace them as networkError. Since I wasn't positive that all networkErrors could actually be specifically related to HTTP issues, I did not want to make the error more specific. It does seem like a concrete type for network-related errors would be helpful. Maybe NetworkError would be a more suitable name though.

I can take a look at providing the response on a plain Error instance. Is it OK to attach the response like this?

const networkError = new Error(`Network request failed with status ${resp.status} ${resp.statusText}`);
networkError.response = resp;
throw networkError;

Or is there another way to approach this?

Copy link
Contributor

@calebmer calebmer Jan 23, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we attach the response to any error thrown in our network interfaces. So in the following locations here: https://github.com/pseudoramble/apollo-client/blob/e4a325ab5a7471706759b723b0fed58c3c72829d/src/transport/batchedNetworkInterface.ts#L105 and https://github.com/pseudoramble/apollo-client/blob/e4a325ab5a7471706759b723b0fed58c3c72829d/src/transport/batchedNetworkInterface.ts#L109

…we would change:

reject(error)

…to:

error.response = response
reject(error)

…and for the promise chain here: https://github.com/pseudoramble/apollo-client/blob/e4a325ab5a7471706759b723b0fed58c3c72829d/src/transport/networkInterface.ts#L178-L206

…we would add a catch which looks like:

promise.catch(error => {
  error.response = response
  throw error
})

…that just mutates the error and re-throws it for the next promise handler.

Not only would this change expose the same information as a new error subclass, but it would also allow users to get the response on any network error that may be thrown. This could have some nice unforeseen benefits 👍

I’m not worried about type safety for errors because most asynchronous types in TypeScript give errors the type of any. This includes the standard types for Promise and some of the more popular types for Observable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. Makes sense to try and catch any sort of general errors we may see here and skip on a new error at this point. I'll work on removing the new error tonight.

I'm confused about how I would access the response in these more specific catch's though. I don't believe I would have access to the response once the promise was rejected. I could attempt to save the response temporarily and attach it later like this:

let savedResponse = null;
return this.applyMiddlewares(...)
...then(({ response }) => {
  savedResponse = response;
})
...catch(err => {
  (err as any).response = savedResponse;
  throw err;
});

Was this what you were thinking of, or am I missing something? I'll keep investigating to try and get what you're looking for, but I'm not quite seeing it yet.

public readonly response: Response;
public readonly request: any;
public readonly message: string;

constructor({
response,
request = {},
message,
}: {
response: Response,
request?: any,
message?: string,
}) {
const defaultMessage = `Network request failed with status ${response.status} - "${response.statusText}"`;
super(message || defaultMessage);

this.response = response;
this.request = request;
}
}
13 changes: 11 additions & 2 deletions src/transport/batchedNetworkInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
QueryBatcher,
} from './batching';

import HttpNetworkError from '../errors/HttpNetworkError';

import { assign } from '../util/assign';

// An implementation of the network interface that operates over HTTP and batches
Expand Down Expand Up @@ -64,12 +66,19 @@ export class HTTPBatchedNetworkInterface extends HTTPFetchNetworkInterface {
Promise.all(middlewarePromises).then((requestsAndOptions: RequestAndOptions[]) => {
return this.batchedFetchFromRemoteEndpoint(requestsAndOptions)
.then(result => {
const httpResponse = result as IResponse;

if (!httpResponse.ok) {
throw new HttpNetworkError({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make a more descriptive error message here? Like: Network request failed with status 404 "not found".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. HttpNetworkError generates this message by default. If we remove HttpNetworkError I'll move it back onto each place where it's thrown.

request: requestsAndOptions,
response: httpResponse,
});
}

// XXX can we be stricter with the type here?
return result.json() as any;
})
.then(responses => {


if (typeof responses.map !== 'function') {
throw new Error('BatchingNetworkInterface: server response is not an array');
}
Expand Down
15 changes: 14 additions & 1 deletion src/transport/networkInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { print } from 'graphql-tag/printer';
import { MiddlewareInterface } from './middleware';
import { AfterwareInterface } from './afterware';

import HttpNetworkError from '../errors/HttpNetworkError';

/**
* This is an interface that describes an GraphQL document to be sent
* to the server.
Expand Down Expand Up @@ -181,7 +183,18 @@ export class HTTPFetchNetworkInterface implements NetworkInterface {
response: response as IResponse,
options,
}))
.then(({ response }) => (response as IResponse).json())
.then(({ response }) => {
const httpResponse = response as IResponse;

if (!httpResponse.ok) {
throw new HttpNetworkError({
request,
response: httpResponse,
});
}

return httpResponse.json();
})
.then((payload: ExecutionResult) => {
if (!payload.hasOwnProperty('data') && !payload.hasOwnProperty('errors')) {
throw new Error(
Expand Down
19 changes: 19 additions & 0 deletions test/batchedNetworkInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@ describe('HTTPBatchedNetworkInterface', () => {
});
});

it('should throw an HttpNetworkError when a non-200 response is received', (done) => {
const fakeForbiddenResponse = createMockedIResponse([], { status: 401, statusText: 'Unauthorized'});
const fetchFunc = () => Promise.resolve(fakeForbiddenResponse);

assertRoundtrip({
requestResultPairs: [{
request: { query: authorQuery },
result: authorResult,
}],
fetchFunc,
}).then(() => {
done(new Error('An HttpNetworkError should have been thrown'));
}).catch(err => {
assert.strictEqual(err.response, fakeForbiddenResponse, 'Incorrect response provided');
assert.equal(err.message, 'Network request failed with status 401 - "Unauthorized"', 'Incorrect message generated');
done();
});
});

it('should return errors thrown by middleware', (done) => {
const err = new Error('Error of some kind thrown by middleware.');
const errorMiddleware: MiddlewareInterface = {
Expand Down
31 changes: 31 additions & 0 deletions test/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { assert } from 'chai';
import { ApolloError } from '../src/errors/ApolloError';
import HttpNetworkError from '../src/errors/HttpNetworkError';

import { createMockedIResponse } from './mocks/mockFetch';

describe('ApolloError', () => {
it('should construct itself correctly', () => {
Expand Down Expand Up @@ -79,3 +82,31 @@ describe('ApolloError', () => {
assert(apolloError.stack, 'Does not contain a stack trace.');
});
});

describe('HttpNetworkError', () => {
it('should provide the given response back to the user', () => {
const response = createMockedIResponse({}, { status: 401, statusText: 'Unauthorized' });

const err = new HttpNetworkError({ response });

assert.deepEqual(err.response, response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this strict equal and not deep-equal?

});

it('should provide default values for the request and message', () => {
const response = createMockedIResponse({}, { status: 403, statusText: 'Forbidden' });
const err = new HttpNetworkError({ response });

assert.isOk(err.message);
assert.isObject(err.request);
});

it('should accept a request and message if provided', () => {
const response = createMockedIResponse({}, { status: 403, statusText: 'Forbidden' });
const request = { name: 'Sample Request' };
const message = 'a test message';
const err = new HttpNetworkError({ response, request, message });

assert.equal(err.message, message);
assert.deepEqual(err.request, request);
});
});
11 changes: 10 additions & 1 deletion test/mocks/mockFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import 'whatwg-fetch';
// structure to the MockedNetworkInterface.

export interface MockedIResponse {
ok: boolean;
status: number;
statusText?: string;
json(): Promise<JSON>;
}

Expand All @@ -14,8 +17,14 @@ export interface MockedFetchResponse {
delay?: number;
}

export function createMockedIResponse(result: Object): MockedIResponse {
export function createMockedIResponse(result: Object, options?: any): MockedIResponse {
const status = options && options.status || 200;
const statusText = options && options.statusText || undefined;

return {
ok: status === 200,
status,
statusText,
json() {
return Promise.resolve(result);
},
Expand Down
40 changes: 33 additions & 7 deletions test/networkInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ describe('network interface', () => {
const swapiUrl = 'http://graphql-swapi.test/';
const missingUrl = 'http://does-not-exist.test/';

const unauthorizedUrl = 'http://unauthorized.test/';
const serviceUnavailableUrl = 'http://service-unavailable.test/';

const simpleQueryWithNoVars = gql`
query people {
allPeople(first: 1) {
Expand Down Expand Up @@ -129,6 +132,9 @@ describe('network interface', () => {
fetchMock.post(missingUrl, () => {
throw new Error('Network error');
});

fetchMock.post(unauthorizedUrl, 403);
fetchMock.post(serviceUnavailableUrl, 503);
});

after(() => {
Expand Down Expand Up @@ -428,6 +434,13 @@ describe('network interface', () => {
});

describe('making a request', () => {
// this is a stub for the end user client api
const doomedToFail = {
query: simpleQueryWithNoVars,
variables: {},
debugName: 'People Query',
};

it('should fetch remote data', () => {
const swapi = createNetworkInterface({ uri: swapiUrl });

Expand All @@ -447,15 +460,28 @@ describe('network interface', () => {
it('should throw on a network error', () => {
const nowhere = createNetworkInterface({ uri: missingUrl });

// this is a stub for the end user client api
const doomedToFail = {
query: simpleQueryWithNoVars,
variables: {},
debugName: 'People Query',
};

return assert.isRejected(nowhere.query(doomedToFail));
});

it('should throw an HttpNetworkError when forbidden', () => {
const unauthorizedInterface = createNetworkInterface({ uri: unauthorizedUrl });

return unauthorizedInterface.query(doomedToFail).catch(err => {
assert.isOk(err.response);
assert.equal(err.response.status, 403);
assert.equal(err.message, 'Network request failed with status 403 - "Forbidden"');
});
});

it('should throw an HttpNetworkError when service is unavailable', () => {
const unauthorizedInterface = createNetworkInterface({ uri: serviceUnavailableUrl });

return unauthorizedInterface.query(doomedToFail).catch(err => {
assert.isOk(err.response);
assert.equal(err.response.status, 503);
assert.equal(err.message, 'Network request failed with status 503 - "Service Unavailable"');
});
});
});
});

Expand Down