-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Test: Missing subscription field should not be an "internal error" #868
Conversation
4a8575d
to
43c0c39
Compare
Per the spec, @robzhu my thought is that in MapSourceToResponseEvent we may need a step which determines if each event represents an error or data, and if an error then describes what to do. |
path: [ 'importantEmail' ], | ||
} | ||
] | ||
// Should data be set here if the root field is a nullable type? |
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.
Let me make sure I'm understanding the issue you're highlighting. In the first test case, you expect a response consistent with the an error encountered during query execution. In the second case, we don't actually get to the execution step because the source stream has thrown an error, and we don't actually have a root value for the execution step?
Why not? I imagine the consumer of the response stream is in a better position to determine what to do with the error. |
Looking at what you wrote, I completely agree and this is the behavior I would expect from the GraphQL execution engine, because this event source errors should be treated just like any other GraphQL error, and handle it the same way, just like the GraphQL engine does for queries. With the current implementation, if an error is emitted from the Currently, we are using I think that in order to catch this error correctly, we need to extend return mapAsyncIterator(
subscription.subscription,
payload => execute(
schema,
document,
payload,
contextValue,
variableValues,
operationName,
fieldResolver
),
(error: Error) => {
return {
value: {
errors: [
new GraphQLError(error.message, fieldNodes), // What is the correct way to build this error?
],
},
done: false,
};
}
); And to use this error handler inside next() {
return iterator.next()
.then(mapResult)
.catch(mapError);
}, This way we make sure that the error is caught and handled, and emitted as another value of the The only thing is that we need somehow to expose the |
Possibly! I think behavior here is definitely up for discussion. What would a consumer do with this error? Say concretely for a case where a Redis stream creates an error and a websockets consumer encounters it? My thought was that most of the time a consumer like websockets would prefer to not know about errors and just treat it like the last payload in the sequence (side note: does the websocket subscriptions impl have a clear way of communicating to a client that a sequence has completed?) @Urigo - I don't think we need to derive that information from |
We won't have location and path handy unless we attempt to resolve the selection, but since the source stream yielded an error instead of an event, we don't have a root value, so resolving might not yield anything useful.
In the Redis/Websocket scenario, I imagine there would be a piece of code that lives between the responseStream and dispatching the message over the client connection. For example: const subscription = subscribe(...);
try {
for await (const responseEvent of subscription) {
const payload = responseEvent.value;
// don't send the client any errors
if (!payload.errors) {
webSocket.send(payload);
}
}
} catch (e) {
webSocket.send(PROTOCOL_ERROR);
webSocket.close();
} If such a piece of code exists, we can side-step the question of how to deal with these sorts of errors in the spec/reference, while providing more flexibility for error handling behavior. If an asyncIterator throws, does its contract enforce whether it can or cannot throw again? |
Good question. I don't think so. I think it can catch errors thrown internally: async function* valueAfterThrow() {
try {
throw new Error('shucks');
} finally {
yield 'value';
}
} But the |
@robzhu I think your example here is pretty compelling. To make sure I understand, this suggests that beyond GraphQL's yielded errors, the transport is responsible for additional messages "complete" and "error" which are not in the form of graphql responses. |
Perhaps there's just some parity to figure out with execution. Calling the Maybe the difference here is if the error is an error within the confines of GraphQL execution (if the query is syntactically incorrect or invalid, or if execution fails) then it returns in a GraphQL payload, but if the error occurs outside of these then it's a transport issue? |
This reminds me of another missing piece of the public API. Right now we export both |
Yep, and I would take it further. Before a payload is dispatched to the client, the server must handle four cases for the subscription responseStream:
It should be possible to vary the logic to handle all four of these cases on a per-application basis. For example, in the case of a responseStream error, I may want to have some sort of retry logic, or in the case of a valid response I might want deduping/encoding. Since sourceStream is user-provided, I think the most intuitive behavior is to have errors pass through. In other words, I would rewrite the second test case to validate that responseStream throws any errors it encounters from sourceStream. |
My previous response is predicated on this. Basically, I'm wondering if we can treat an AsyncIterator as a state machine, where upon encountering the first error, the iterator is then in the "faulted" state and will neither yield additional events nor throw more errors. |
Have you guys looked at the error handing in RxJava 2? David Karnok put a lot of thinking into handling next/error/complete and what to do about errors that can't be captured. The core description is in the wiki, but there are a bunch of (closed) issues with people talking about what happened when they upgraded and things started breaking (especially in android). Might help spark some more ideas for you. |
This is a safe assumption for Async Generators. The spec proposal literally describes async generators it via a state machine where However Async Iterators are a set which includes Async Generators, and there's no such rule that a rejected Promise must be the last in the sequence. In fact, the spec mentions a few things that should happen (but aren't enforced) and this didn't even make the list (https://tc39.github.io/proposal-async-iteration/#sec-asynciterator-interface) Here's an example:
Here, wtfStream will alternate between yielding resolved values and yielding rejected ones. (Perhaps it shouldn't, our implementation of More evidence for rejecting expecting to be the last in a sequence is |
This adds proper behavior when the mapping function throws an error (As discussed in #868).
This adds proper behavior when the mapping function throws an error (As discussed in #868).
43c0c39
to
8288f7c
Compare
Updated with new test cases. @robzhu let me know what you think of this behavior. These tests still fail, but the fix should be much easier. |
This looks great. Any more thoughts on whether we should accompany this with an update to the spec? |
Coming back to this after GraphQL EU travel :) Some new thoughts on this - I think both of my previous approaches were correct, and both incorrect. Depends on context of the error. I think we can split errors into two categories: internal errors and GraphQL user errors. For example, if a user provides a GraphQL document which causes an issue then the error should be returned as described by the spec (matching that of For updates to the spec, we should define these kinds of errors and explain what to do with them (like "return an event stream yielding a single event containing |
8288f7c
to
11a5bb5
Compare
Ok, I just updated this with more tests, utility functions, and some real semantic changes. I'd appreciate a thorough review. |
d3cb007
to
a0887fa
Compare
a0887fa
to
0c57202
Compare
51dae61
to
3acc234
Compare
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.
Thanks for highlighting these error-handling cases. I think is definitely something we need to be more thorough about. I see a few concerns all bunched together under "error handling":
- The source stream returning/throwing errors
- The resolver returning/throwing errors
- Parsing/creation of subscription throwing errors
Perhaps it makes sense to tackle these individually if you think the concerns are separable. Otherwise, we should try and put together a table of expected behavior.
const { subscription } = createSubscription(pubsub, emailSchema, ast); | ||
|
||
const payload = await subscription.next(); | ||
expect(payload).to.deep.equal({ done: true, value: undefined }); |
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.
This doesn't feel right to me. I would expect this to behave the same as when we try to execute an invalid query. Maybe I'm missing something here? How do we get the error in this case?
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.
Validation will produce an error, however once validated a document should be operable according to the algorithm in the spec.
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.
Alternatively, we could change the spec to make this an assertion instead
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.
So we won't actually get to this step because subscription { unknownField }
would fail validation and return an informative error?
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.
In typical validate-then-operate usage, most definitely. Attempting to use an undefined field is a validation error.
However we need to handle these corner cases nonetheless. In the case that validation was thwarted in some way. Perhaps a doc was validated before being persisted, and then a breaking change was made to a schema which caused the doc to become invalid - but future runs of it have skipped validation due to being persisted.
This is the sort of thing that's rare, but needs definition.
}); | ||
}); | ||
|
||
function emailSchemaWithSubscribeFn(subscribeFn) { |
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.
Nit: this is a weird place for this function. Perhaps move it to the top of the file or before the block of tests that use it?
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.
Ya
src/subscription/subscribe.js
Outdated
'This subscription is not defined by the schema.' | ||
); | ||
if (!fieldDef) { | ||
return emptyAsyncIterator(); |
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.
In the doc block above:
If an error is encountered during creation, an Error is thrown.
Why shouldn't we treat a missing fieldDef as an error?
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.
I think this should mirror the behavior of execution of an undefined field:
await execute(schema, parse('{ unknownField }'))
{ "data": {} }
That is, an event stream from a non existent source is the empty stream.
After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through.
After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through.
After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through. However errors encountered during GraphQL execution on a source event should be considered "field" or "query" errors and be represented within that Response.
After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through. However errors encountered during GraphQL execution on a source event should be considered "field" or "query" errors and be represented within that Response.
After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through. However errors encountered during GraphQL execution on a source event should be considered "field" or "query" errors and be represented within that Response.
After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through. However errors encountered during GraphQL execution on a source event should be considered "field" or "query" errors and be represented within that Response.
…cutionResult (#918) * Report or Reject when encountering Errors. Currently the `subscribe()` function throws Errors, however this is awkward when used along with async functions which would expect a rejected iteration to represent failure. Also GraphQLErrors should be reported back to the client since they represent client-provided errors. Updates test cases to represent this new behavior. Includes a new utility `asyncIteratorReject`, and extends the behavior of `mapAsyncIterator` to help implement this. * Subscriptions: Respond with error when failing to create source If a subscribe resolve function throws or returns an error, that typically indicates an issue to be returned to the requesting client. This coerces errors into located GraphQLErrors so they are correctly reported. * Subscriptions: Test source errors and execution errors. After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through. However errors encountered during GraphQL execution on a source event should be considered "field" or "query" errors and be represented within that Response. * Update subscribe signature to return a promise of AsyncIterator/Error/ExecutionResult * Throw errors instead of returning them from Subscribe() * Lint, refactor error mapper and add comments * Report or Reject when encountering Errors. Currently the `subscribe()` function throws Errors, however this is awkward when used along with async functions which would expect a rejected iteration to represent failure. Also GraphQLErrors should be reported back to the client since they represent client-provided errors. Updates test cases to represent this new behavior. Includes a new utility `asyncIteratorReject`, and extends the behavior of `mapAsyncIterator` to help implement this. * Subscriptions: Respond with error when failing to create source If a subscribe resolve function throws or returns an error, that typically indicates an issue to be returned to the requesting client. This coerces errors into located GraphQLErrors so they are correctly reported. * Subscriptions: Test source errors and execution errors. After discussion in #868, decided that errors emitted from a source event stream should be considered "internal" errors and pass through. However errors encountered during GraphQL execution on a source event should be considered "field" or "query" errors and be represented within that Response. * Update subscribe signature to return a promise of AsyncIterator/Error/ExecutionResult * Throw errors instead of returning them from Subscribe() * Lint, refactor error mapper and add comments * Update test case assertions for stream errors to be more precise * Add test case for wrong variable types * Fix lint error (extra spaces) * Fix multi-line doc block format * Minor edits * Trim trailing whitespace
A remaining issue with subscriptions is attempting to subscribe to a missing field. The existing implementation creates an internal error which is an improper blame since there is no possibility to fix it internally - it is an issue with the query. For consistency with the same scenario for query/mutation operations, this simply returns nothing. I interpret the equivalent of "undefined" for a subscribe operation as an empty iteration. Note: this will require spec changes, however spec changes are necessary either way to resolve the ambiguity of this exact scenario.
3acc234
to
2c5a399
Compare
All the tests from this PR have since been added or refined in separate commits |
Added a test to illustrate a broken issue and potentially underspecified in spec:
If a source event stream emits an error instead of an event, then that error is passing up through the whole stack and throwing at the consumer of the response event stream. That's very likely not what we want. I have a proposal in this test case for what should happen in that case, similar to what would happen if an error occurred during during the second step of executing an event from the source stream.
cc @Urigo @robzhu