Skip to content

Commit 70ba8a8

Browse files
Fix error type for non-JSON error responses (#758)
## Summary When the server returns a plain-text error response (e.g. `"Invalid Token"` with HTTP 403), the SDK throws `Unknown` instead of `PermissionDenied`. The root cause: `parseUnknownError` derives an error code by splitting `response.getStatus()` on space, expecting `"403 Forbidden"`. But `CommonsHttpClient` passes `statusLine.getReasonPhrase()`, which is just `"Forbidden"`. The split produces one element, so error code defaults to `"UNKNOWN"`, which matches `ErrorMapper`'s error code mapping and short-circuits the status code mapping that would have correctly produced `PermissionDenied`. The fix removes the error code derivation entirely. Leaving `errorCode` null lets `AbstractErrorMapper.apply` skip the error code mapping and fall through to the status code mapping (403 -> `PermissionDenied`, 401 -> `Unauthenticated`, etc.). The error message now uses the raw response body instead of appending Jackson parse exception details. **Behavioral change**: non-JSON error responses now produce typed exceptions based on HTTP status code instead of always producing `Unknown`. The error message no longer contains Jackson deserialization internals. ## Test plan - [x] New `PlainTextErrorTest` covers: plain-text 403/401/404, HTML `<pre>` extraction, empty body, null body - [x] Full test suite passes (`mvn test -pl databricks-sdk-java`)
1 parent 89297b1 commit 70ba8a8

File tree

3 files changed

+71
-14
lines changed

3 files changed

+71
-14
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Added automatic detection of AI coding agents (Antigravity, Claude Code, Cline, Codex, Copilot CLI, Cursor, Gemini CLI, OpenCode) in the user-agent string. The SDK now appends `agent/<name>` to HTTP request headers when running inside a known AI agent environment.
77

88
### Bug Fixes
9+
* Fixed non-JSON error responses (e.g. plain-text "Invalid Token" with HTTP 403) producing `Unknown` instead of the correct typed exception (`PermissionDenied`, `Unauthenticated`, etc.). The error message no longer contains Jackson deserialization internals.
910
* Added `X-Databricks-Org-Id` header to deprecated workspace SCIM APIs (Groups, ServicePrincipals, Users) for SPOG host compatibility.
1011
* Fixed Databricks CLI authentication to detect when the cached token's scopes don't match the SDK's configured scopes. Previously, a scope mismatch was silently ignored, causing requests to use wrong permissions. The SDK now raises an error with instructions to re-authenticate.
1112

databricks-sdk-java/src/main/java/com/databricks/sdk/core/error/ApiErrors.java

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -116,27 +116,18 @@ private static Optional<ApiErrorBody> parseApiError(Response response) {
116116
try {
117117
return Optional.of(MAPPER.readValue(body, ApiErrorBody.class));
118118
} catch (IOException e) {
119-
return Optional.of(parseUnknownError(response, body, e));
119+
return Optional.of(parseUnknownError(body));
120120
}
121121
}
122122

123-
private static ApiErrorBody parseUnknownError(Response response, String body, IOException err) {
123+
private static ApiErrorBody parseUnknownError(String body) {
124124
ApiErrorBody errorBody = new ApiErrorBody();
125-
String[] statusParts = response.getStatus().split(" ", 2);
126-
if (statusParts.length < 2) {
127-
errorBody.setErrorCode("UNKNOWN");
128-
} else {
129-
String errorCode = statusParts[1].replaceAll("^[ .]+|[ .]+$", "");
130-
errorBody.setErrorCode(errorCode.replaceAll(" ", "_").toUpperCase());
131-
}
132-
125+
errorBody.setErrorCode(""); // non-null to avoid NPE
133126
Matcher messageMatcher = HTML_ERROR_REGEX.matcher(body);
134127
if (messageMatcher.find()) {
135-
errorBody.setMessage(messageMatcher.group(1).replaceAll("^[ .]+|[ .]+$", ""));
128+
errorBody.setMessage(messageMatcher.group(1));
136129
} else {
137-
errorBody.setMessage(
138-
String.format(
139-
"Response from server (%s) %s: %s", response.getStatus(), body, err.getMessage()));
130+
errorBody.setMessage(body);
140131
}
141132
return errorBody;
142133
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.databricks.sdk.core.error;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import com.databricks.sdk.core.DatabricksError;
6+
import com.databricks.sdk.core.error.platform.*;
7+
import com.databricks.sdk.core.http.Request;
8+
import com.databricks.sdk.core.http.Response;
9+
import java.util.Collections;
10+
import org.junit.jupiter.api.Test;
11+
12+
class PlainTextErrorTest {
13+
14+
@Test
15+
void plainTextForbiddenReturnsPermissionDenied() {
16+
DatabricksError error = getError(403, "Forbidden", "Invalid Token");
17+
assertInstanceOf(PermissionDenied.class, error);
18+
assertEquals("Invalid Token", error.getMessage());
19+
}
20+
21+
@Test
22+
void plainTextUnauthorizedReturnsUnauthenticated() {
23+
DatabricksError error = getError(401, "Unauthorized", "Bad credentials");
24+
assertInstanceOf(Unauthenticated.class, error);
25+
assertEquals("Bad credentials", error.getMessage());
26+
}
27+
28+
@Test
29+
void plainTextNotFoundReturnsNotFound() {
30+
DatabricksError error = getError(404, "Not Found", "no such endpoint");
31+
assertInstanceOf(NotFound.class, error);
32+
assertEquals("no such endpoint", error.getMessage());
33+
}
34+
35+
@Test
36+
void htmlErrorExtractsPreContent() {
37+
String html = "<html><body><pre>some error message</pre></body></html>";
38+
DatabricksError error = getError(403, "Forbidden", html);
39+
assertInstanceOf(PermissionDenied.class, error);
40+
assertEquals("some error message", error.getMessage());
41+
}
42+
43+
@Test
44+
void emptyBodyFallsBackToStatusCode() {
45+
Request request = new Request("GET", "https://example.com/api/2.0/clusters/get");
46+
Response response = new Response(request, 403, "Forbidden", Collections.emptyMap(), "");
47+
DatabricksError error = ApiErrors.getDatabricksError(response);
48+
assertInstanceOf(PermissionDenied.class, error);
49+
}
50+
51+
@Test
52+
void nullBodyFallsBackToStatusCode() {
53+
Request request = new Request("GET", "https://example.com/api/2.0/clusters/get");
54+
Response response =
55+
new Response(request, 403, "Forbidden", Collections.emptyMap(), (String) null);
56+
DatabricksError error = ApiErrors.getDatabricksError(response);
57+
assertInstanceOf(PermissionDenied.class, error);
58+
}
59+
60+
private static DatabricksError getError(int statusCode, String status, String body) {
61+
Request request = new Request("GET", "https://example.com/api/2.0/clusters/get");
62+
Response response = new Response(request, statusCode, status, Collections.emptyMap(), body);
63+
return ApiErrors.getDatabricksError(response);
64+
}
65+
}

0 commit comments

Comments
 (0)