diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index e7017db7a..9138bcd6c 100755 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -6,6 +6,7 @@ * 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/` to HTTP request headers when running inside a known AI agent environment. ### Bug Fixes +* 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. * Added `X-Databricks-Org-Id` header to deprecated workspace SCIM APIs (Groups, ServicePrincipals, Users) for SPOG host compatibility. * 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. diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/error/ApiErrors.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/error/ApiErrors.java index bbef61d8c..9a1c60b65 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/error/ApiErrors.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/error/ApiErrors.java @@ -116,27 +116,18 @@ private static Optional parseApiError(Response response) { try { return Optional.of(MAPPER.readValue(body, ApiErrorBody.class)); } catch (IOException e) { - return Optional.of(parseUnknownError(response, body, e)); + return Optional.of(parseUnknownError(body)); } } - private static ApiErrorBody parseUnknownError(Response response, String body, IOException err) { + private static ApiErrorBody parseUnknownError(String body) { ApiErrorBody errorBody = new ApiErrorBody(); - String[] statusParts = response.getStatus().split(" ", 2); - if (statusParts.length < 2) { - errorBody.setErrorCode("UNKNOWN"); - } else { - String errorCode = statusParts[1].replaceAll("^[ .]+|[ .]+$", ""); - errorBody.setErrorCode(errorCode.replaceAll(" ", "_").toUpperCase()); - } - + errorBody.setErrorCode(""); // non-null to avoid NPE Matcher messageMatcher = HTML_ERROR_REGEX.matcher(body); if (messageMatcher.find()) { - errorBody.setMessage(messageMatcher.group(1).replaceAll("^[ .]+|[ .]+$", "")); + errorBody.setMessage(messageMatcher.group(1)); } else { - errorBody.setMessage( - String.format( - "Response from server (%s) %s: %s", response.getStatus(), body, err.getMessage())); + errorBody.setMessage(body); } return errorBody; } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/error/PlainTextErrorTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/error/PlainTextErrorTest.java new file mode 100644 index 000000000..97aebce9c --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/error/PlainTextErrorTest.java @@ -0,0 +1,65 @@ +package com.databricks.sdk.core.error; + +import static org.junit.jupiter.api.Assertions.*; + +import com.databricks.sdk.core.DatabricksError; +import com.databricks.sdk.core.error.platform.*; +import com.databricks.sdk.core.http.Request; +import com.databricks.sdk.core.http.Response; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class PlainTextErrorTest { + + @Test + void plainTextForbiddenReturnsPermissionDenied() { + DatabricksError error = getError(403, "Forbidden", "Invalid Token"); + assertInstanceOf(PermissionDenied.class, error); + assertEquals("Invalid Token", error.getMessage()); + } + + @Test + void plainTextUnauthorizedReturnsUnauthenticated() { + DatabricksError error = getError(401, "Unauthorized", "Bad credentials"); + assertInstanceOf(Unauthenticated.class, error); + assertEquals("Bad credentials", error.getMessage()); + } + + @Test + void plainTextNotFoundReturnsNotFound() { + DatabricksError error = getError(404, "Not Found", "no such endpoint"); + assertInstanceOf(NotFound.class, error); + assertEquals("no such endpoint", error.getMessage()); + } + + @Test + void htmlErrorExtractsPreContent() { + String html = "
some error message
"; + DatabricksError error = getError(403, "Forbidden", html); + assertInstanceOf(PermissionDenied.class, error); + assertEquals("some error message", error.getMessage()); + } + + @Test + void emptyBodyFallsBackToStatusCode() { + Request request = new Request("GET", "https://example.com/api/2.0/clusters/get"); + Response response = new Response(request, 403, "Forbidden", Collections.emptyMap(), ""); + DatabricksError error = ApiErrors.getDatabricksError(response); + assertInstanceOf(PermissionDenied.class, error); + } + + @Test + void nullBodyFallsBackToStatusCode() { + Request request = new Request("GET", "https://example.com/api/2.0/clusters/get"); + Response response = + new Response(request, 403, "Forbidden", Collections.emptyMap(), (String) null); + DatabricksError error = ApiErrors.getDatabricksError(response); + assertInstanceOf(PermissionDenied.class, error); + } + + private static DatabricksError getError(int statusCode, String status, String body) { + Request request = new Request("GET", "https://example.com/api/2.0/clusters/get"); + Response response = new Response(request, statusCode, status, Collections.emptyMap(), body); + return ApiErrors.getDatabricksError(response); + } +}