Skip to content

Commit 546bc44

Browse files
authored
feat(api): Add cause for graphql connection errors (#3183)
1 parent 8c9039f commit 546bc44

File tree

8 files changed

+597
-4
lines changed

8 files changed

+597
-4
lines changed

aws-api/api/aws-api.api

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ public final class com/amplifyframework/api/aws/EndpointType : java/lang/Enum {
108108
public static fun values ()[Lcom/amplifyframework/api/aws/EndpointType;
109109
}
110110

111+
public final class com/amplifyframework/api/aws/GraphQLResponseException : java/io/IOException {
112+
public fun <init> (Lorg/json/JSONObject;)V
113+
public fun getErrors ()Ljava/util/List;
114+
}
115+
116+
public final class com/amplifyframework/api/aws/GraphQLResponseException$GraphQLError {
117+
public fun getErrorType ()Ljava/lang/String;
118+
public fun getMessage ()Ljava/lang/String;
119+
public fun toString ()Ljava/lang/String;
120+
}
121+
111122
public final class com/amplifyframework/api/aws/LazyTypeDeserializersKt {
112123
public static final field ITEMS_KEY Ljava/lang/String;
113124
public static final field NEXT_TOKEN_KEY Ljava/lang/String;

aws-api/src/main/java/com/amplifyframework/api/aws/AppSyncGraphQLOperation.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
import com.amplifyframework.core.category.CategoryType;
3131
import com.amplifyframework.logging.Logger;
3232

33+
import org.json.JSONException;
34+
import org.json.JSONObject;
35+
3336
import java.io.IOException;
3437
import java.util.Objects;
3538
import java.util.concurrent.ExecutorService;
@@ -143,8 +146,17 @@ public void onResponse(@NonNull Call call, @NonNull Response response) {
143146
}
144147
}
145148
if (response.code() >= START_OF_CLIENT_ERROR_CODE && response.code() <= END_OF_CLIENT_ERROR_CODE) {
149+
IOException cause;
150+
try {
151+
JSONObject responseJson = new JSONObject(jsonResponse);
152+
cause = new GraphQLResponseException(responseJson);
153+
} catch (JSONException jsonException) {
154+
// Fall back to IOException if response cannot be parsed
155+
String errorMessage = "HTTP error occurred: " + response.code() + " " + jsonResponse;
156+
cause = new IOException(errorMessage, jsonException);
157+
}
146158
onFailure.accept(new ApiException
147-
.NonRetryableException("OkHttp client request failed.", "Irrecoverable error")
159+
.NonRetryableException("OkHttp client request failed.", cause, "Irrecoverable error")
148160
);
149161
return;
150162
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package com.amplifyframework.api.aws;
17+
18+
import androidx.annotation.NonNull;
19+
import androidx.annotation.Nullable;
20+
21+
import org.json.JSONArray;
22+
import org.json.JSONException;
23+
import org.json.JSONObject;
24+
25+
import java.io.IOException;
26+
import java.util.ArrayList;
27+
import java.util.Collections;
28+
import java.util.List;
29+
30+
/**
31+
* Exception representing a GraphQL error response from AppSync.
32+
* <p>
33+
* This exception is thrown when AppSync returns errors during connection establishment
34+
* for GraphQL queries or subscriptions (e.g., authentication failures, authorization errors).
35+
* <p>
36+
* Use {@link GraphQLError#getErrorType()} for programmatic error handling.
37+
* For information on existing error types, see
38+
* <a href="https://docs.aws.amazon.com/appsync/latest/APIReference/CommonErrors.html">
39+
* AWS AppSync Common Errors</a>.
40+
*
41+
* @see GraphQLError
42+
*/
43+
public final class GraphQLResponseException extends IOException {
44+
private static final long serialVersionUID = 1L;
45+
46+
private final List<GraphQLError> errors;
47+
48+
/**
49+
* Creates a GraphQLResponseException from a JSON response.
50+
* @param responseJson The JSON response containing the errors array
51+
* @throws JSONException if the JSON cannot be parsed or doesn't contain valid error structure
52+
*/
53+
public GraphQLResponseException(@NonNull JSONObject responseJson) throws JSONException {
54+
this(parseErrors(responseJson));
55+
}
56+
57+
private GraphQLResponseException(@NonNull List<GraphQLError> errors) {
58+
super(buildMessage(errors));
59+
this.errors = errors;
60+
}
61+
62+
/**
63+
* Gets the list of GraphQL errors from the response.
64+
* @return Unmodifiable list of GraphQL errors
65+
*/
66+
@NonNull
67+
public List<GraphQLError> getErrors() {
68+
return Collections.unmodifiableList(errors);
69+
}
70+
71+
private static String buildMessage(List<GraphQLError> errors) {
72+
return errors.stream()
73+
.map(GraphQLError::toString)
74+
.collect(java.util.stream.Collectors.joining("; "));
75+
}
76+
77+
private static List<GraphQLError> parseErrors(JSONObject responseJson) throws JSONException {
78+
if (!responseJson.has("errors")) {
79+
throw new JSONException("Response does not contain 'errors' field");
80+
}
81+
82+
JSONArray errorsArray = responseJson.getJSONArray("errors");
83+
if (errorsArray.length() == 0) {
84+
throw new JSONException("Errors array is empty");
85+
}
86+
87+
List<GraphQLError> errorList = new ArrayList<>();
88+
for (int i = 0; i < errorsArray.length(); i++) {
89+
JSONObject errorObj = errorsArray.getJSONObject(i);
90+
errorList.add(new GraphQLError(
91+
errorObj.optString("errorType", null),
92+
errorObj.optString("message", null)
93+
));
94+
}
95+
return errorList;
96+
}
97+
98+
/**
99+
* Represents a single GraphQL error from the errors array.
100+
* <p>
101+
* Each error contains:
102+
* <ul>
103+
* <li><b>errorType</b> - The error type, can be used to identify errors</li>
104+
* <li><b>message</b> - Human-readable error description</li>
105+
* </ul>
106+
*/
107+
public static final class GraphQLError {
108+
private final String errorType;
109+
private final String message;
110+
111+
GraphQLError(@Nullable String errorType, @Nullable String message) {
112+
this.errorType = errorType;
113+
this.message = message;
114+
}
115+
116+
/**
117+
* Gets the error type (AWS AppSync extension).
118+
* Can be used for programmatic error handling.
119+
* <p>
120+
* For information on existing error types, see
121+
* <a href="https://docs.aws.amazon.com/appsync/latest/APIReference/CommonErrors.html">
122+
* AWS AppSync Common Errors</a>.
123+
*
124+
* @return The error type, or null if not present
125+
*/
126+
@Nullable
127+
public String getErrorType() {
128+
return errorType;
129+
}
130+
131+
/**
132+
* Gets the error message.
133+
* @return The error message, or null if not present
134+
*/
135+
@Nullable
136+
public String getMessage() {
137+
return message;
138+
}
139+
140+
@Override
141+
public String toString() {
142+
return String.format("%s: %s", errorType, message);
143+
}
144+
}
145+
}

aws-api/src/main/java/com/amplifyframework/api/aws/SubscriptionEndpoint.java

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.json.JSONException;
3737
import org.json.JSONObject;
3838

39+
import java.io.IOException;
3940
import java.lang.reflect.Type;
4041
import java.net.MalformedURLException;
4142
import java.net.URL;
@@ -151,7 +152,8 @@ <T> void requestSubscription(
151152
if (pendingSubscriptionIds.remove(subscriptionId)) {
152153
// The subscription was pending, so we need to emit an error.
153154
onSubscriptionError.accept(
154-
new ApiException(connection.getFailureReason(), AmplifyException.TODO_RECOVERY_SUGGESTION));
155+
new ApiException(connection.getFailureReason(), connection.getFailureCause(),
156+
AmplifyException.TODO_RECOVERY_SUGGESTION));
155157
return;
156158
}
157159
}
@@ -523,6 +525,7 @@ public int hashCode() {
523525
final class AmplifyWebSocketListener extends WebSocketListener {
524526
private final CountDownLatch connectionResponse;
525527
private final AtomicReference<EndpointStatus> endpointStatus;
528+
private Throwable connectionFailureCause;
526529

527530
AmplifyWebSocketListener() {
528531
this(new CountDownLatch(1));
@@ -556,6 +559,7 @@ public void onClosing(@NonNull WebSocket webSocket, int code, @NonNull String re
556559
public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable failure, Response response) {
557560
LOG.warn("Websocket connection failed.", failure);
558561
endpointStatus.set(EndpointStatus.CONNECTION_FAILED);
562+
connectionFailureCause = failure;
559563
webSocket.cancel();
560564
// This will free up any pending subscriptions that haven't been established yet.
561565
connectionResponse.countDown();
@@ -581,11 +585,11 @@ public Connection waitForConnectionReady() {
581585
}
582586
} catch (InterruptedException exception) {
583587
LOG.warn("Thread interrupted waiting for connection acknowledgement");
584-
return new Connection("Thread interrupted waiting for connection acknowledgement");
588+
return new Connection("Thread interrupted waiting for connection acknowledgement", exception);
585589
}
586590
LOG.debug("Current endpoint status: " + endpointStatus.get());
587591
if (EndpointStatus.CONNECTION_FAILED.equals(endpointStatus.get())) {
588-
return new Connection("Connection failed.");
592+
return new Connection("Connection failed.", connectionFailureCause);
589593
}
590594
return new Connection();
591595
}
@@ -628,6 +632,19 @@ private void processJsonMessage(WebSocket webSocket, String message) throws ApiE
628632
case CONNECTION_ERROR:
629633
endpointStatus.set(EndpointStatus.CONNECTION_FAILED);
630634
LOG.warn("Websocket listener received a CONNECTION_ERROR event. " + message);
635+
636+
// Convert error payload to GraphQLResponseException
637+
try {
638+
if (jsonMessage.has("payload")) {
639+
JSONObject payload = jsonMessage.getJSONObject("payload");
640+
connectionFailureCause = new GraphQLResponseException(payload);
641+
}
642+
} catch (JSONException exception) {
643+
LOG.warn("Failed to parse CONNECTION_ERROR payload as GraphQL error");
644+
// Fall back to simple IOException with JSONException as cause
645+
connectionFailureCause = new IOException(message, exception);
646+
}
647+
631648
connectionResponse.countDown();
632649
break;
633650
case SUBSCRIPTION_ACK:
@@ -664,19 +681,30 @@ private void processJsonMessage(WebSocket webSocket, String message) throws ApiE
664681

665682
static final class Connection {
666683
private final String failureReason;
684+
private final Throwable failureCause;
667685

668686
Connection() {
669687
this.failureReason = null;
688+
this.failureCause = null;
670689
}
671690

672691
Connection(String failureReason) {
692+
this(failureReason, null);
693+
}
694+
695+
Connection(String failureReason, Throwable failureCause) {
673696
this.failureReason = failureReason;
697+
this.failureCause = failureCause;
674698
}
675699

676700
public String getFailureReason() {
677701
return failureReason;
678702
}
679703

704+
public Throwable getFailureCause() {
705+
return failureCause;
706+
}
707+
680708
public boolean hasFailure() {
681709
return failureReason != null;
682710
}

0 commit comments

Comments
 (0)