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

Make protocol errors available in the error link #12281

Merged
merged 28 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
445017e
Allow error link to handle protocol errors
jerelmiller Jan 17, 2025
7b4338c
Add test to ensure retry can happen after protocol errors
jerelmiller Jan 17, 2025
e51ef3e
Add changeset
jerelmiller Jan 17, 2025
034dd47
Add protocolErrors to docs
jerelmiller Jan 17, 2025
e54cfda
add tests for incremental subscription & `@defer` with errorLink
phryneas Jan 17, 2025
6bc3346
Add comments for each error type
jerelmiller Jan 21, 2025
23f4d7e
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jan 21, 2025
1fbf96c
Remove string from union on ApolloPayloadResult
jerelmiller Jan 21, 2025
7549224
Add changeset for change to payload result
jerelmiller Jan 21, 2025
5df6452
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jan 21, 2025
11f22f2
Pass TData and TExtensions in ApolloPayloadResult
jerelmiller Jan 21, 2025
5ce2c00
Clean up Prettier, Size-limit, and Api-Extractor
jerelmiller Jan 21, 2025
17b7ed2
Update changesets
jerelmiller Jan 21, 2025
d5d4a77
Update docs
jerelmiller Jan 21, 2025
e16cd54
Update class-based test to use mock subscription helpers
jerelmiller Jan 21, 2025
266c642
Create new streams for each request and better handle retries
jerelmiller Jan 22, 2025
cb5894f
Add process queue to allow for synchronous enqueue
jerelmiller Jan 22, 2025
bb99ec4
Remove unneeded check
jerelmiller Jan 22, 2025
fefd26e
Use stream http link to test retry
jerelmiller Jan 22, 2025
d5f2469
Rename enqueueErrorResult with enqueueProtocolErrors
jerelmiller Jan 22, 2025
b1e73a2
Add enqueueHeartbeat
jerelmiller Jan 22, 2025
40cd6f0
Refactor initial chunk to use enqueueHeartbeat
jerelmiller Jan 22, 2025
27d2eac
Rename enqueueProtocolErrorChunk to enqueueErrorChunk in defer mock
jerelmiller Jan 22, 2025
c7dde9e
Remove alias
jerelmiller Jan 22, 2025
fe6281c
Minor refactor to avoid multiple ifs
jerelmiller Jan 22, 2025
4775b82
Minor tweak to avoid repeating same type
jerelmiller Jan 22, 2025
fc04917
Merge branch 'main' into jerel/error-protocol-errors
jerelmiller Jan 22, 2025
d2f62ad
Fix lint issue due to upgrade of eslint
jerelmiller Jan 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/giant-peas-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@apollo/client": patch
---

Make `protocolErrors` from multipart subscriptions available to the error link with the `protocolErrors` property.

```js
const errorLink = onError(({ protocolErrors }) => {
if (protocolErrors) {
console.log(protocolErrors);
}
});
```
11 changes: 10 additions & 1 deletion docs/source/api/link/apollo-link-error.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ Use the `onError` link to perform custom logic when a [GraphQL or network error]
import { onError } from "@apollo/client/link/error";

// Log any GraphQL errors or network error that occurred
const errorLink = onError(({ graphQLErrors, networkError }) => {
const errorLink = onError(({ graphQLErrors, networkError, protocolErrors }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
)
);

if (protocolErrors) {
protocolErrors.forEach(({ message, extensions }) => {
console.log(
`[Protocol error]: Message: ${message}, Extensions: ${JSON.stringify(extensions)}`
);
});
}

if (networkError) console.log(`[Network error]: ${networkError}`);
});
```
Expand Down
172 changes: 172 additions & 0 deletions src/link/error/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ServerError, throwServerError } from "../../utils/throwServerError";
import { Observable } from "../../../utilities/observables/Observable";
import { onError, ErrorLink } from "../";
import { ObservableStream } from "../../../testing/internal";
import { PROTOCOL_ERRORS_SYMBOL } from "../../../errors";

describe("error handling", () => {
it("has an easy way to handle GraphQL errors", async () => {
Expand Down Expand Up @@ -71,6 +72,66 @@ describe("error handling", () => {
expect(called).toBe(true);
});

it("handles protocol errors", async () => {
expect.assertions(3);
const query = gql`
query Foo {
foo {
bar
}
}
`;

const errorLink = onError(({ operation, protocolErrors }) => {
expect(operation.operationName).toBe("Foo");
expect(protocolErrors).toEqual([
{
message: "cannot read message from websocket",
extensions: [{ code: "WEBSOCKET_MESSAGE_ERROR" }],
},
]);
});

const mockLink = new ApolloLink((_operation) => {
return new Observable((observer) => {
observer.next({
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
},
],
},
});
});
});

const link = errorLink.concat(mockLink);
const stream = new ObservableStream(execute(link, { query }));

await expect(stream).toEmitValue({
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
},
],
},
});
});

it("captures errors within links", async () => {
const query = gql`
query Foo {
Expand Down Expand Up @@ -356,6 +417,66 @@ describe("error handling with class", () => {
expect(called).toBe(true);
});

it("handles protocol errors", async () => {
expect.assertions(3);
const query = gql`
query Foo {
foo {
bar
}
}
`;

const errorLink = new ErrorLink(({ operation, protocolErrors }) => {
expect(operation.operationName).toBe("Foo");
expect(protocolErrors).toEqual([
{
message: "cannot read message from websocket",
extensions: [{ code: "WEBSOCKET_MESSAGE_ERROR" }],
},
]);
});

const mockLink = new ApolloLink((_operation) => {
Copy link
Member

Choose a reason for hiding this comment

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

This makes a lot of assumptions about what the HttpLink will do. I'll see if I can rewrite the test to use an actual HttpLink

return new Observable((observer) => {
observer.next({
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
},
],
},
});
});
});

const link = errorLink.concat(mockLink);
const stream = new ObservableStream(execute(link, { query }));

await expect(stream).toEmitValue({
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: "cannot read message from websocket",
extensions: [
{
code: "WEBSOCKET_MESSAGE_ERROR",
},
],
},
],
},
});
});

it("captures errors within links", async () => {
const query = gql`
{
Expand Down Expand Up @@ -465,6 +586,18 @@ describe("support for request retrying", () => {
message: "some other error",
};

const PROTOCOL_ERROR = {
data: null,
extensions: {
[PROTOCOL_ERRORS_SYMBOL]: [
{
message: "cannot read message from websocket",
extensions: [{ code: "WEBSOCKET_MESSAGE_ERROR" }],
},
],
},
};

it("returns the retried request when forward(operation) is called", async () => {
let errorHandlerCalled = false;

Expand Down Expand Up @@ -550,6 +683,45 @@ describe("support for request retrying", () => {
await expect(stream).toComplete();
});

it("supports retrying when the initial request had protocol errors", async () => {
let errorHandlerCalled = false;

let timesCalled = 0;
const mockHttpLink = new ApolloLink((operation) => {
return new Observable((observer) => {
// simulate the first request being an error
if (timesCalled === 0) {
timesCalled++;
observer.next(PROTOCOL_ERROR);
observer.complete();
} else {
observer.next(GOOD_RESPONSE);
observer.complete();
}
});
});

const errorLink = new ErrorLink(
({ protocolErrors, operation, forward }) => {
if (protocolErrors) {
errorHandlerCalled = true;
expect(protocolErrors).toEqual(
PROTOCOL_ERROR.extensions[PROTOCOL_ERRORS_SYMBOL]
);
return forward(operation);
}
}
);

const link = errorLink.concat(mockHttpLink);

const stream = new ObservableStream(execute(link, { query: QUERY }));

await expect(stream).toEmitValue(GOOD_RESPONSE);
expect(errorHandlerCalled).toBe(true);
await expect(stream).toComplete();
});

it("returns errors from retried requests", async () => {
let errorHandlerCalled = false;

Expand Down
38 changes: 29 additions & 9 deletions src/link/error/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { FormattedExecutionResult, GraphQLFormattedError } from "graphql";
import type {
FormattedExecutionResult,
GraphQLErrorExtensions,
GraphQLFormattedError,
} from "graphql";

import {
graphQLResultHasProtocolErrors,
PROTOCOL_ERRORS_SYMBOL,
} from "../../errors/index.js";
import type { NetworkError } from "../../errors/index.js";
import { Observable } from "../../utilities/index.js";
import type { Operation, FetchResult, NextLink } from "../core/index.js";
Expand All @@ -8,6 +16,10 @@ import { ApolloLink } from "../core/index.js";
export interface ErrorResponse {
graphQLErrors?: ReadonlyArray<GraphQLFormattedError>;
networkError?: NetworkError;
protocolErrors?: ReadonlyArray<{
message: string;
Copy link
Member

Choose a reason for hiding this comment

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

At this point, these errors become confusing without explanation or context.
What about some comments?

  /**
   * Errors returned in the `errors` property of the GraphQL response.
   */
  graphQLErrors?: ReadonlyArray<GraphQLFormattedError>;
  /**
   * Errors thrown during making a network request.
   * This is usually an error thrown during a `fetch` call or an error while
   * parsing the response from the network.
   */
  networkError?: NetworkError;
  /**
   * Protocol errors.
   * This type of error occurs in follow-up responses of HTTP multipart
   * responses, e.g. if you are using `@defer` or Subscriptions via HTTP multipart.
   */

Copy link
Member

@phryneas phryneas Jan 17, 2025

Choose a reason for hiding this comment

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

I know we call those "protocol errors" internally, but looking at when these actually occur (only on subsequent chunks of http multipart requests), could we maybe call this incrementalErrors instead?

Suggested change
protocolErrors?: ReadonlyArray<{
incrementalErrors?: ReadonlyArray<{

Copy link
Member Author

Choose a reason for hiding this comment

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

I named this protocolErrors to match the same property used in ApolloError where these specific types of errors are stored.

These errors aren't actually incremental errors but rather the transport-level errors sent from the router that occur when it can't communicate with the subgraph (i.e. the errors field next to payload, but not the errors field inside the payload property, which are just graphQLErrors).

extensions?: GraphQLErrorExtensions[];
}>;
response?: FormattedExecutionResult;
Comment on lines +31 to +34
Copy link
Member

Choose a reason for hiding this comment

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

This could also just be ReadonlyArray<GraphQLFormattedError>, or we adjust ApolloPayloadResult to have a type matching this.

Copy link
Member Author

Choose a reason for hiding this comment

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

I used the type defined in ApolloError for this. I figured they should match.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, maybe it might also make sense to just reference ApolloPayloadResult['errors'] to create a strong connection showing where this comes from?

operation: Operation;
forward: NextLink;
Expand Down Expand Up @@ -42,16 +54,24 @@ export function onError(errorHandler: ErrorHandler): ApolloLink {
operation,
forward,
});
} else if (graphQLResultHasProtocolErrors(result)) {
retriedResult = errorHandler({
protocolErrors: result.extensions[PROTOCOL_ERRORS_SYMBOL],
response: result,
operation,
forward,
});
}

if (retriedResult) {
retriedSub = retriedResult.subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
return;
}
if (retriedResult) {
retriedSub = retriedResult.subscribe({
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
});
return;
}

observer.next(result);
},
error: (networkError) => {
Expand Down
Loading