Skip to content

Commit 5f911e1

Browse files
authored
feat!: support partial responses (#610)
Adds support for proper error handling and partial responses as defined by the spec, cf. https://spec.graphql.org/September2025/#sec-Errors KGraphQL now distinguishes between _request_ and _execution_ errors, and the latter now result in a response that contains both, `data` and `errors`. Execution errors result in a value of `null` for nullable fields, and will bubble up to their parent node for non-nullable fields. Additionally, resolvers can now raise execution errors while still returning data. If data _and_ errors are present in the response, the `errors` key is serialized first to make it more apparent that errors are present. Behavior for request errors is unchanged, and `wrapErrors` configuration is still supported for now - but likely subject to change in the future. Resolves #114 BREAKING CHANGE: relying on exceptions being thrown from query execution may no longer work as before. It is advised to specify `wrapErrors = false` and report an issue with the respective use case.
1 parent a10e181 commit 5f911e1

42 files changed

Lines changed: 1058 additions & 558 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 148 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,179 @@
11
# Error Handling
22

3-
Error handling is currently implemented in a basic way only, and does for example not support multiple errors or partial responses.
3+
When `wrapErrors` is `true` (which is the default), exceptions encountered during execution of a request will be wrapped, and are returned as part of a well-formed response.
44

5-
## Basics
5+
## Error Format
66

7-
When an exception occurs, request execution is aborted and results in an error response depending on the type of exception.
7+
Every error has an entry with the key `message` that contains a human-readable description of the problem.
88

9-
Exceptions that extend `GraphQLError` via either `RequestError` or `ExecutionError` will be mapped to a response that contains
10-
an `errors` key with optional `locations` and `path` keys detailing where it occurred.
11-
Sub classes can also provide arbitrary `extensions`, by default an error `type` will be added:
9+
If an error can be associated to a point in the GraphQL document, it will contain an entry with the key `locations` that lists all lines and columns this error is referring to.
10+
11+
If an error can be associated to a particular field, it will contain an entry with the key `path`, containing all segments up to the field where it occurred. This helps clients to distinguish genuine `null` responses from missing values due to errors.
12+
13+
Additionally, every error can contain an entry with the key `extensions` that is a map of server-specific additions outside of the schema. All built-in errors will be mapped with a `type` extension according to their class.
14+
15+
Depending on the type of error, the response may also contain a `data` key and partial response data.
16+
17+
## Request Errors
18+
19+
_Request errors_ are errors that result in no response data. They are typically raised before execution begins, and may be caused by parsing errors in the request document, invalid syntax or invalid input values for variables.
20+
21+
Request errors are typically the fault of the requesting client.
22+
23+
If a request error is raised, the response will only contain an `errors` key with corresponding details.
1224

1325
=== "Example"
1426
```json
1527
{
16-
"errors": [
17-
{
18-
"message": "Property 'nonexisting' on 'MyType' does not exist",
19-
"locations": [
20-
{
21-
"line": 3,
22-
"column": 1
23-
}
24-
],
25-
"extensions": {
26-
"type": "GRAPHQL_VALIDATION_FAILED"
27-
}
28-
}
29-
]
28+
"errors": [
29+
{
30+
"message": "Missing selection set on property 'film' of type 'Film'",
31+
"extensions": {
32+
"type": "GRAPHQL_VALIDATION_FAILED"
33+
}
34+
}
35+
]
3036
}
3137
```
3238

33-
All built-in exceptions extend `GraphQLError`.
39+
## Execution Errors
3440

35-
## Exceptions From Resolvers
41+
_Execution errors_ (previously called _field errors_ in the spec) are errors raised during execution of a particular field, and result in partial response data. They may be caused by coercion failures or internal errors during function invocation.
3642

37-
What happens with exceptions from resolvers depends on the [schema configuration](configuration.md).
43+
Execution errors are typically the fault of the GraphQL server.
3844

39-
### wrapErrors = true
45+
When an execution error occurs, it is added to the list of errors in the response, and the value of its field is coerced to `null`. If that is a valid value for the field, execution continues with the next sibling. Otherwise, when the field is a non-null type, the error is propagated to the parent field, until a nullable type or the root type is reached.
4046

41-
With `wrapErrors = true` (which is the default), exceptions are wrapped as `ExecutionException`, which is a `GraphQLError`:
47+
Execution errors can lead to partial responses, where some fields can still return proper data. To make partial responses more easily identifiable, the `errors` key will be serialized as first entry in the response JSON.
4248

43-
=== "Example"
44-
```kotlin
45-
KGraphQL.schema {
46-
configure {
47-
wrapErrors = true
49+
Given the [Star Wars schema](../Tutorials/starwars.md), if fetching one of the friends' names fails in the following operation, the response might contain a friend without a name:
50+
51+
=== "SDL"
52+
```graphql
53+
type Hero {
54+
friends: [Hero]!
55+
id: ID!
56+
name: String
57+
}
58+
59+
type Query {
60+
hero: Hero!
61+
}
62+
```
63+
=== "Request"
64+
```graphql
65+
{
66+
hero {
67+
name
68+
heroFriends: friends {
69+
id
70+
name
4871
}
49-
query("throwError") {
50-
resolver<String> {
51-
throw IllegalArgumentException("Illegal argument")
72+
}
73+
}
74+
```
75+
=== "Response"
76+
```json
77+
{
78+
"errors": [
79+
{
80+
"message": "Name for character with ID 1002 could not be fetched.",
81+
"locations": [{ "line": 6, "column": 7 }],
82+
"path": ["hero", "heroFriends", 1, "name"]
83+
}
84+
],
85+
"data": {
86+
"hero": {
87+
"name": "R2-D2",
88+
"heroFriends": [
89+
{
90+
"id": "1000",
91+
"name": "Luke Skywalker"
92+
},
93+
{
94+
"id": "1002",
95+
"name": null
96+
},
97+
{
98+
"id": "1003",
99+
"name": "Leia Organa"
52100
}
101+
]
53102
}
103+
}
54104
}
55105
```
56106

57-
=== "Response (HTTP 200)"
107+
If the field `name` was declared as non-null, the whole list entry would become `null` instead. However, the error itself would still be the same:
108+
109+
=== "SDL"
110+
```graphql
111+
type Hero {
112+
friends: [Hero]!
113+
id: ID!
114+
name: String!
115+
}
116+
117+
type Query {
118+
hero: Hero!
119+
}
120+
```
121+
=== "Request"
122+
```graphql
123+
{
124+
hero {
125+
name
126+
heroFriends: friends {
127+
id
128+
name
129+
}
130+
}
131+
}
132+
```
133+
=== "Response"
58134
```json
59135
{
60-
"errors": [
136+
"errors": [
137+
{
138+
"message": "Name for character with ID 1002 could not be fetched.",
139+
"locations": [{ "line": 6, "column": 7 }],
140+
"path": ["hero", "heroFriends", 1, "name"]
141+
}
142+
],
143+
"data": {
144+
"hero": {
145+
"name": "R2-D2",
146+
"heroFriends": [
61147
{
62-
"message": "Illegal argument",
63-
"locations": [
64-
{
65-
"line": 2,
66-
"column": 1
67-
}
68-
],
69-
"path": [
70-
"throwError"
71-
],
72-
"extensions": {
73-
"type": "INTERNAL_SERVER_ERROR"
74-
}
148+
"id": "1000",
149+
"name": "Luke Skywalker"
150+
},
151+
null,
152+
{
153+
"id": "1003",
154+
"name": "Leia Organa"
75155
}
76-
]
156+
]
157+
}
158+
}
77159
}
78160
```
79161

80-
### wrapErrors = false
162+
## Raising Errors From Resolvers
163+
164+
In addition to returning (partial) data, resolvers can also add execution errors to the response via `Context.raiseError`:
165+
166+
=== "Example"
167+
```kotlin
168+
query("items") {
169+
resolver { node: Execution.Node, ctx: Context ->
170+
ctx.raiseError(MissingItemError("Cannot get item 'missing'", node))
171+
listOf(Item("Existing 1"), Item("Existing 2"))
172+
}
173+
}
174+
```
175+
176+
## wrapErrors = false
81177

82178
With `wrapErrors = false`, exceptions are re-thrown:
83179

@@ -94,7 +190,6 @@ With `wrapErrors = false`, exceptions are re-thrown:
94190
}
95191
}
96192
```
97-
98193
=== "Response (HTTP 500)"
99194
```html
100195
<html>
@@ -121,8 +216,9 @@ Those re-thrown exceptions could then be handled with the [`StatusPages` Ktor pl
121216
}
122217
}
123218
```
124-
125219
=== "Response (HTTP 400)"
126220
```text
127221
Invalid input: java.lang.IllegalArgumentException: Illegal argument
128222
```
223+
224+
Because thrown exceptions are re-thrown, `wrapErrors = false` will not produce partial responses from thrown exceptions, but resolvers can still return partial responses by calling `Context.raiseError`.

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
3434
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
3535
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
3636
kotest = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
37+
kotest-json = { module = "io.kotest:kotest-assertions-json", version.ref = "kotest" }
3738
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit-jupiter" }
3839
junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit-jupiter" }
3940
kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark" }

kgraphql-ktor-stitched/api/kgraphql-ktor-stitched.api

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
public final class com/apurebase/kgraphql/stitched/RemoteExecutionException : com/apurebase/kgraphql/ExecutionError {
2-
public fun <init> (Ljava/lang/String;Lcom/apurebase/kgraphql/schema/execution/Execution$Remote;)V
3-
}
4-
51
public final class com/apurebase/kgraphql/stitched/StitchedGraphQL {
62
public static final field Feature Lcom/apurebase/kgraphql/stitched/StitchedGraphQL$Feature;
73
public fun <init> (Lcom/apurebase/kgraphql/schema/Schema;)V

kgraphql-ktor-stitched/src/main/kotlin/com/apurebase/kgraphql/stitched/RemoteExecutionException.kt

Lines changed: 0 additions & 17 deletions
This file was deleted.

0 commit comments

Comments
 (0)