Skip to content

Commit a29d132

Browse files
edridudidudiedriclaude
authored
Expose a typed error on Result (#210)
* Expose a typed error on Result Step 2 of #6. Koios is served by PostgREST, which reports failures as a JSON object: {"code":"42703","details":null,"hint":null, "message":"column record.no_such_column does not exist"} Callers only ever saw that as an opaque String on Result.response and had to parse it themselves. Adds KoiosError (code, message, details, hint) and a Result.error field populated when a failing response carries a body of that shape. The existing response String is untouched, so no current caller changes behaviour - Result.error is simply null where it used to be unavailable. Bodies that are not JSON of that shape, such as a plain-text 500, leave error null rather than guessing. Only the two sites where the body genuinely comes from the server parse it; the synthetic failures built locally do not pretend to carry a server error. Verified against live mainnet: a real 42703 parses to KoiosError(code=42703, message=column record.no_such_column does not exist, details=null, hint=null). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix javadoc references to Lombok-generated getters KoiosError referenced Result#getError() and Result#getResponse(), which Lombok generates at compile time and javadoc therefore cannot resolve: KoiosError.java:14: error: reference not found That failed the CodeQL build, which runs the full javadoc. References the fields instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: dudiedri <dudi.edri@ox.security> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent fd27042 commit a29d132

4 files changed

Lines changed: 121 additions & 4 deletions

File tree

‎src/main/java/rest/koios/client/backend/api/base/BaseService.java‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
@Getter
3636
public class BaseService {
3737

38+
private static final ObjectMapper ERROR_MAPPER = new ObjectMapper()
39+
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
40+
3841
private final Retrofit retrofit;
3942
private int retriesCount = 5;
4043
private boolean retryOnTimeout = true;
@@ -141,13 +144,35 @@ protected <T> Result<T> processResponseGetOne(Call<List<T>> call) throws ApiExce
141144
return (Result<T>) Result.builder().successful(false).response("Response Body is Invalid").code(500).build();
142145
}
143146
} else {
144-
return (Result<T>) Result.builder().successful(false).response(Objects.requireNonNull(response.errorBody()).string()).code(response.code()).build();
147+
String errorBody = Objects.requireNonNull(response.errorBody()).string();
148+
return (Result<T>) Result.builder().successful(false).response(errorBody).code(response.code()).error(parseError(errorBody)).build();
145149
}
146150
} catch (IOException e) {
147151
throw new ApiException(e.getMessage(), e);
148152
}
149153
}
150154

155+
/**
156+
* Parses a JSON error body returned by Koios into a {@link KoiosError}.
157+
*
158+
* @param body raw response body, which may be plain text rather than JSON
159+
* @return parsed error, or null if the body is blank, is not JSON, or carries none of the
160+
* expected fields
161+
*/
162+
private static KoiosError parseError(String body) {
163+
if (body == null || body.trim().isEmpty()) {
164+
return null;
165+
}
166+
try {
167+
KoiosError error = ERROR_MAPPER.readValue(body, KoiosError.class);
168+
boolean empty = error.getCode() == null && error.getMessage() == null
169+
&& error.getDetails() == null && error.getHint() == null;
170+
return empty ? null : error;
171+
} catch (IOException e) {
172+
return null;
173+
}
174+
}
175+
151176
protected <T> Result<T> badRequestResult(String responseText) {
152177
return (Result<T>) Result.builder().successful(false).response(responseText).code(400).build();
153178
}
@@ -163,10 +188,12 @@ protected <T> Result<T> badRequestResult(String responseText) {
163188
protected <T> Result<T> processResponse(Call<?> call) throws ApiException {
164189
try {
165190
Response<T> response = (Response<T>) execute(call);
166-
if (response.isSuccessful())
191+
if (response.isSuccessful()) {
167192
return (Result<T>) Result.builder().successful(true).response(response.toString()).value(response.body()).code(response.code()).build();
168-
else
169-
return (Result<T>) Result.builder().successful(false).response(Objects.requireNonNull(response.errorBody()).string()).code(response.code()).build();
193+
} else {
194+
String errorBody = Objects.requireNonNull(response.errorBody()).string();
195+
return (Result<T>) Result.builder().successful(false).response(errorBody).code(response.code()).error(parseError(errorBody)).build();
196+
}
170197
} catch (IOException e) {
171198
throw new ApiException(e.getMessage(), e);
172199
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package rest.koios.client.backend.api.base;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import lombok.*;
5+
6+
/**
7+
* Structured error returned by a Koios instance.
8+
* <p>
9+
* Koios is served by PostgREST, which reports failures as a JSON object rather than plain text:
10+
* <pre>
11+
* {"code":"42703","details":null,"hint":null,"message":"column pgrst_call.ticker does not exist"}
12+
* </pre>
13+
* When a failing response carries such a body it is parsed into this object and exposed as the
14+
* {@code error} field of {@link Result}. Responses whose body is not JSON of this shape leave it
15+
* null; the raw body is always available from the {@code response} field either way.
16+
*/
17+
@Getter
18+
@Setter
19+
@ToString
20+
@NoArgsConstructor
21+
@AllArgsConstructor
22+
@EqualsAndHashCode
23+
@JsonIgnoreProperties(ignoreUnknown = true)
24+
public class KoiosError {
25+
26+
/**
27+
* Error code as reported by the server, e.g. a PostgreSQL SQLSTATE such as "42703"
28+
* (null if not applicable)
29+
*/
30+
private String code;
31+
32+
/**
33+
* Human readable description of the failure (null if not applicable)
34+
*/
35+
private String message;
36+
37+
/**
38+
* Additional detail about the failure (null if not applicable)
39+
*/
40+
private String details;
41+
42+
/**
43+
* Suggested way to resolve the failure (null if not applicable)
44+
*/
45+
private String hint;
46+
}

‎src/main/java/rest/koios/client/backend/api/base/Result.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,11 @@ public class Result<T> {
1717
String response;
1818
int code;
1919
T value;
20+
21+
/**
22+
* Structured error, when the failing response carried a JSON error body.
23+
* Null on success, and null when the body was not JSON of that shape.
24+
*/
25+
KoiosError error;
2026
}
2127

‎src/test/java/rest/koios/client/backend/api/base/BaseServiceErrorHandlingTest.java‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,35 @@ void successfulResponseIsReturnedTest() throws ApiException {
4949
Assertions.assertEquals("Conway", result.getValue().getEra());
5050
}
5151

52+
@Test
53+
void successCarriesNoErrorTest() throws ApiException {
54+
server.enqueue(new MockResponse()
55+
.setResponseCode(200)
56+
.setHeader("Content-Type", "application/json")
57+
.setBody("[{\"hash\":\"abc\",\"epoch_no\":655,\"era\":\"Conway\"}]"));
58+
59+
Result<Tip> result = backendService.getNetworkService().getChainTip();
60+
61+
Assertions.assertTrue(result.isSuccessful());
62+
Assertions.assertNull(result.getError());
63+
}
64+
65+
@Test
66+
void partialErrorBodyIsParsedTest() throws ApiException {
67+
server.enqueue(new MockResponse()
68+
.setResponseCode(400)
69+
.setHeader("Content-Type", "application/json")
70+
.setBody("{\"message\":\"request body too large\",\"hint\":\"send fewer ids\"}"));
71+
72+
Result<Tip> result = backendService.getNetworkService().getChainTip();
73+
74+
KoiosError error = result.getError();
75+
Assertions.assertNotNull(error);
76+
Assertions.assertEquals("request body too large", error.getMessage());
77+
Assertions.assertEquals("send fewer ids", error.getHint());
78+
Assertions.assertNull(error.getCode());
79+
}
80+
5281
@Test
5382
void serverErrorBodyIsSurfacedTest() throws ApiException {
5483
String postgrestError = "{\"code\":\"42703\",\"details\":null,\"hint\":null," +
@@ -64,6 +93,13 @@ void serverErrorBodyIsSurfacedTest() throws ApiException {
6493
Assertions.assertEquals(400, result.getCode());
6594
Assertions.assertEquals(postgrestError, result.getResponse());
6695
Assertions.assertNull(result.getValue());
96+
// the same body, parsed
97+
KoiosError error = result.getError();
98+
Assertions.assertNotNull(error);
99+
Assertions.assertEquals("42703", error.getCode());
100+
Assertions.assertEquals("column pgrst_call.ticker does not exist", error.getMessage());
101+
Assertions.assertNull(error.getDetails());
102+
Assertions.assertNull(error.getHint());
67103
}
68104

69105
@Test
@@ -75,6 +111,8 @@ void internalServerErrorIsSurfacedTest() throws ApiException {
75111
Assertions.assertFalse(result.isSuccessful());
76112
Assertions.assertEquals(500, result.getCode());
77113
Assertions.assertEquals("upstream failure", result.getResponse());
114+
// a plain-text body is not a structured error; the raw text is still available
115+
Assertions.assertNull(result.getError());
78116
}
79117

80118
@Test

0 commit comments

Comments
 (0)