Skip to content

Commit 21bc97f

Browse files
author
mrzhao
committed
feat: add structured GraphQL exception handling with GHGraphQLException
Introduce GHGraphQLException and GHGraphQLError to provide structured access to GraphQL error responses, including type, path, locations, and extensions fields per the GraphQL spec. This enables callers to programmatically inspect errors rather than parsing exception messages. Modeled after octokit/graphql.js's GraphqlResponseError pattern.
1 parent 3622553 commit 21bc97f

9 files changed

Lines changed: 477 additions & 31 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package org.kohsuke.github;
2+
3+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
4+
import org.kohsuke.github.internal.graphql.response.GHGraphQLError;
5+
6+
import java.util.Collections;
7+
import java.util.List;
8+
9+
import javax.annotation.CheckForNull;
10+
import javax.annotation.Nonnull;
11+
12+
/**
13+
* Thrown when a GitHub GraphQL request returns a successful HTTP response whose body contains a non-empty
14+
* {@code errors} array.
15+
*
16+
* <p>
17+
* This exception preserves the structured error list so callers can branch on the error {@link GHGraphQLError#getType()
18+
* type}, {@link GHGraphQLError#getPath() path}, and other fields. Partial response data is exposed through
19+
* {@link #getResponseData()} and the originating query through {@link #getQuery()} to ease debugging.
20+
* </p>
21+
*
22+
* <p>
23+
* HTTP-level failures (4xx/5xx) do not surface as this exception; they continue to be reported as {@link HttpException}
24+
* or its subclasses, since the response cannot be parsed as a GraphQL payload.
25+
* </p>
26+
*
27+
* @see <a href="https://spec.graphql.org/October2021/#sec-Errors">GraphQL spec — Errors</a>
28+
*/
29+
public class GHGraphQLException extends GHIOException {
30+
31+
private static final long serialVersionUID = 1L;
32+
33+
/**
34+
* Structured GraphQL errors. Marked transient because {@link GHGraphQLError} carries Jackson-populated
35+
* {@code Object} fields that may not be {@link java.io.Serializable}.
36+
*/
37+
@Nonnull
38+
@SuppressFBWarnings(value = "SE_BAD_FIELD",
39+
justification = "GHGraphQLError carries Jackson-populated Object fields; exception serialization is "
40+
+ "best-effort and not part of the public contract")
41+
private final transient List<GHGraphQLError> errors;
42+
43+
/** Original GraphQL query, when available; useful for debugging. */
44+
private final String query;
45+
46+
/** Partial response data, transient because the payload type is unconstrained. */
47+
private final transient Object responseData;
48+
49+
/**
50+
* Instantiates a new GraphQL exception.
51+
*
52+
* @param message
53+
* human-readable summary suitable for log output
54+
* @param errors
55+
* the structured GraphQL errors returned by the server, never {@code null}
56+
* @param responseData
57+
* the partial {@code data} payload, or {@code null} if the server returned none
58+
* @param query
59+
* the GraphQL query string sent in the originating request, or {@code null} if unknown
60+
*/
61+
@SuppressFBWarnings(value = { "EI_EXPOSE_REP2" }, justification = "errors list is wrapped unmodifiable")
62+
public GHGraphQLException(@Nonnull String message,
63+
@Nonnull List<GHGraphQLError> errors,
64+
@CheckForNull Object responseData,
65+
@CheckForNull String query) {
66+
super(message);
67+
this.errors = Collections.unmodifiableList(errors);
68+
this.responseData = responseData;
69+
this.query = query;
70+
}
71+
72+
/**
73+
* Get the structured error entries returned by the GraphQL endpoint. The list is unmodifiable. Returns an empty
74+
* list after this exception has been deserialized across a JVM boundary, since the structured errors are not
75+
* preserved through serialization.
76+
*
77+
* @return the GraphQL errors
78+
*/
79+
@Nonnull
80+
public List<GHGraphQLError> getErrors() {
81+
return errors == null ? Collections.emptyList() : errors;
82+
}
83+
84+
/**
85+
* Get the GraphQL query that produced this response, when available.
86+
*
87+
* @return the query string, or {@code null}
88+
*/
89+
@CheckForNull
90+
public String getQuery() {
91+
return query;
92+
}
93+
94+
/**
95+
* Get the partial response data returned alongside the errors, if any. GraphQL allows servers to return both
96+
* {@code data} and {@code errors} when a request only partially fails. Returns {@code null} when the server
97+
* returned no data or after deserialization, e.g. across the network.
98+
*
99+
* @return the partial response data, or {@code null}
100+
*/
101+
@CheckForNull
102+
public Object getResponseData() {
103+
return responseData;
104+
}
105+
}

src/main/java/org/kohsuke/github/Requester.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,21 @@ public <T> T fetch(@Nonnull Class<T> type) throws IOException {
102102
* @param type
103103
* the type
104104
* @return an instance of {@code GHGraphQLResponse<T>}
105+
* @throws GHGraphQLException
106+
* if the server returns a successful HTTP response whose body contains a non-empty {@code errors}
107+
* array.
105108
* @throws IOException
106109
* if the server returns 4xx/5xx responses.
107110
*/
108111
public <T extends GHGraphQLResponse<S>, S> S fetchGraphQL(@Nonnull Class<T> type) throws IOException {
109112
T response = fetch(type);
110113

111114
if (!response.isSuccessful()) {
112-
throw new IOException("GraphQL request failed by:" + response.getErrorMessages());
115+
String message = response.buildErrorSummary("Request failed due to following response errors");
116+
throw new GHGraphQLException(message,
117+
response.getErrors(),
118+
response.getDataUnchecked(),
119+
extractGraphQLQuery());
113120
}
114121

115122
return response.getData();
@@ -201,4 +208,19 @@ public <R> PagedIterable<R> toIterable(Class<R[]> type, Consumer<R> itemInitiali
201208
return new GitHubPageContentsIterable<>(client, build(), type, itemInitializer);
202209

203210
}
211+
212+
/**
213+
* Best-effort lookup of the {@code query} parameter set by {@link GitHub#createGraphQLRequest(String)}. Returns
214+
* {@code null} if the parameter is missing, e.g. when the request was assembled by hand.
215+
*
216+
* @return the query string sent in this request, or {@code null}
217+
*/
218+
private String extractGraphQLQuery() {
219+
for (GitHubRequest.Entry entry : build().args()) {
220+
if ("query".equals(entry.key) && entry.value instanceof String) {
221+
return (String) entry.value;
222+
}
223+
}
224+
return null;
225+
}
204226
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package org.kohsuke.github.internal.graphql.response;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
5+
6+
import java.util.Collections;
7+
import java.util.List;
8+
import java.util.Map;
9+
10+
import javax.annotation.CheckForNull;
11+
12+
/**
13+
* A single error entry returned by a GitHub GraphQL response.
14+
*
15+
* <p>
16+
* Per the GraphQL specification, only {@code message} is guaranteed to be present. Other fields ({@code type},
17+
* {@code path}, {@code locations}, {@code extensions}) are optional and may be {@code null} depending on the error.
18+
* Unknown fields are ignored to remain forward-compatible with future server-side additions.
19+
* </p>
20+
*
21+
* @see <a href="https://spec.graphql.org/October2021/#sec-Errors">GraphQL spec — Errors</a>
22+
* @see <a href="https://docs.github.com/en/graphql/reference/objects#error">GitHub GraphQL — Error</a>
23+
*/
24+
@JsonIgnoreProperties(ignoreUnknown = true)
25+
@SuppressFBWarnings(value = { "UWF_UNWRITTEN_FIELD", "UWF_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR" },
26+
justification = "Populated via Jackson deserialization")
27+
public class GHGraphQLError {
28+
29+
/**
30+
* Source location of an error inside the GraphQL document.
31+
*/
32+
@JsonIgnoreProperties(ignoreUnknown = true)
33+
@SuppressFBWarnings(value = { "UWF_UNWRITTEN_FIELD", "UWF_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR" },
34+
justification = "Populated via Jackson deserialization")
35+
public static class Location {
36+
37+
private int column;
38+
39+
private int line;
40+
41+
/**
42+
* Default constructor used by Jackson.
43+
*/
44+
public Location() {
45+
}
46+
47+
/**
48+
* Get the column index of the location, starting at 1.
49+
*
50+
* @return the column index
51+
*/
52+
public int getColumn() {
53+
return column;
54+
}
55+
56+
/**
57+
* Get the line index of the location, starting at 1.
58+
*
59+
* @return the line index
60+
*/
61+
public int getLine() {
62+
return line;
63+
}
64+
}
65+
66+
private Map<String, Object> extensions;
67+
68+
private List<Location> locations;
69+
70+
private String message;
71+
72+
private List<Object> path;
73+
74+
private String type;
75+
76+
/**
77+
* Default constructor used by Jackson.
78+
*/
79+
public GHGraphQLError() {
80+
}
81+
82+
/**
83+
* Get the extensions object as defined by the GraphQL spec. GitHub may include custom keys here, e.g. error code or
84+
* documentation URL. May be {@code null} if not provided.
85+
*
86+
* @return the extensions map, or {@code null}
87+
*/
88+
@CheckForNull
89+
public Map<String, Object> getExtensions() {
90+
return extensions == null ? null : Collections.unmodifiableMap(extensions);
91+
}
92+
93+
/**
94+
* Get the source locations associated with this error. Each entry points to a line/column in the GraphQL document.
95+
* May be {@code null} if not provided.
96+
*
97+
* @return the list of locations, or {@code null}
98+
*/
99+
@CheckForNull
100+
public List<Location> getLocations() {
101+
return locations == null ? null : Collections.unmodifiableList(locations);
102+
}
103+
104+
/**
105+
* Get the human-readable error message. Per the GraphQL spec this field is always present.
106+
*
107+
* @return the message
108+
*/
109+
public String getMessage() {
110+
return message;
111+
}
112+
113+
/**
114+
* Get the response path that failed. Each segment is either a {@link String} field name or an {@link Integer} list
115+
* index, per the GraphQL spec. May be {@code null} if not provided.
116+
*
117+
* @return the path elements, or {@code null}
118+
*/
119+
@CheckForNull
120+
public List<Object> getPath() {
121+
return path == null ? null : Collections.unmodifiableList(path);
122+
}
123+
124+
/**
125+
* Get the error type as classified by the server. GitHub commonly uses values such as {@code FORBIDDEN},
126+
* {@code NOT_FOUND}, {@code RATE_LIMITED}, or {@code UNPROCESSABLE}. This is a GitHub extension to the GraphQL spec
127+
* and may be {@code null}.
128+
*
129+
* @return the error type, or {@code null}
130+
*/
131+
@CheckForNull
132+
public String getType() {
133+
return type;
134+
}
135+
}

0 commit comments

Comments
 (0)