diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/AccountClient.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/AccountClient.java index 28a9ee5d7..d750badd9 100755 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/AccountClient.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/AccountClient.java @@ -5,7 +5,7 @@ import com.databricks.sdk.core.ApiClient; import com.databricks.sdk.core.ConfigLoader; import com.databricks.sdk.core.DatabricksConfig; -import com.databricks.sdk.core.HostType; +import com.databricks.sdk.core.DatabricksEnvironment; import com.databricks.sdk.core.utils.AzureUtils; import com.databricks.sdk.service.billing.BillableUsageAPI; import com.databricks.sdk.service.billing.BillableUsageService; @@ -1131,17 +1131,47 @@ public DatabricksConfig config() { } public WorkspaceClient getWorkspaceClient(Workspace workspace) { - // For unified hosts, reuse the same host and set workspace ID - if (this.config.getHostType() == HostType.UNIFIED) { - this.config.setWorkspaceId(String.valueOf(workspace.getWorkspaceId())); - return new WorkspaceClient(this.config); - } - - // For traditional account hosts, get workspace deployment URL String host = - this.config.getDatabricksEnvironment().getDeploymentUrl(workspace.getDeploymentName()); + workspaceHost( + this.config.getDatabricksEnvironment(), + this.config.getHost(), + workspace.getDeploymentName()); + if (host.equals(this.config.getHost())) { + // SPOG/unified: reuse the same host, clone config and set workspace ID + DatabricksConfig workspaceConfig = this.config.clone(); + workspaceConfig.setWorkspaceId(String.valueOf(workspace.getWorkspaceId())); + return new WorkspaceClient(workspaceConfig); + } + // Traditional: use the deployment URL DatabricksConfig config = this.config.newWithWorkspaceHost(host); AzureUtils.getAzureWorkspaceResourceId(workspace).map(config::setAzureWorkspaceResourceId); return new WorkspaceClient(config); } + + /** + * Determines the workspace host URL. For SPOG hosts (no DNS zone or host doesn't match the DNS + * zone pattern), returns the account host as-is. For traditional hosts, builds the deployment + * URL. + */ + private static String workspaceHost( + DatabricksEnvironment env, String accountHost, String deploymentName) { + if (env.getDnsZone() == null || env.getDnsZone().isEmpty()) { + return accountHost; + } + if (accountHost != null) { + String normalized = accountHost; + if (!normalized.contains("://")) { + normalized = "https://" + normalized; + } + try { + java.net.URL url = new java.net.URL(normalized); + if (url.getHost() != null && url.getHost().endsWith(env.getDnsZone())) { + return env.getDeploymentUrl(deploymentName); + } + } catch (java.net.MalformedURLException e) { + // Fall through to return accountHost + } + } + return accountHost; + } } diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java index 6cfbadc3d..249c93b7a 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksCliCredentialsProvider.java @@ -30,18 +30,7 @@ public String authType() { List buildHostArgs(String cliPath, DatabricksConfig config) { List cmd = new ArrayList<>(Arrays.asList(cliPath, "auth", "token", "--host", config.getHost())); - if (config.getExperimentalIsUnifiedHost() != null && config.getExperimentalIsUnifiedHost()) { - // For unified hosts, pass account_id, workspace_id, and experimental flag - cmd.add("--experimental-is-unified-host"); - if (config.getAccountId() != null) { - cmd.add("--account-id"); - cmd.add(config.getAccountId()); - } - if (config.getWorkspaceId() != null) { - cmd.add("--workspace-id"); - cmd.add(config.getWorkspaceId()); - } - } else if (config.getClientType() == ClientType.ACCOUNT) { + if (config.getClientType() == ClientType.ACCOUNT) { cmd.add("--account-id"); cmd.add(config.getAccountId()); } diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java index 627b0ab2f..c48a79f20 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java @@ -18,8 +18,12 @@ import java.time.Duration; import java.util.*; import org.apache.http.HttpMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class DatabricksConfig { + private static final Logger LOG = LoggerFactory.getLogger(DatabricksConfig.class); + private CredentialsProvider credentialsProvider = new DefaultCredentialsProvider(); @ConfigAttribute(env = "DATABRICKS_HOST") @@ -219,12 +223,28 @@ private synchronized DatabricksConfig innerResolve() { sortScopes(); ConfigLoader.fixHostIfNeeded(this); initHttp(); + tryResolveHostMetadata(); return this; } catch (DatabricksException e) { throw ConfigLoader.makeNicerError(e.getMessage(), e, this); } } + /** + * Attempts to resolve host metadata from the well-known endpoint. Logs a warning and continues if + * metadata resolution fails, since not all hosts support the discovery endpoint. + */ + private void tryResolveHostMetadata() { + if (host == null) { + return; + } + try { + resolveHostMetadata(); + } catch (Throwable e) { + LOG.debug("Failed to resolve host metadata: {}", e.getMessage()); + } + } + // Sort scopes in-place for better de-duplication in the refresh token cache. private void sortScopes() { if (scopes != null && !scopes.isEmpty()) { @@ -250,11 +270,6 @@ public synchronized Map authenticate() throws DatabricksExceptio } Map headers = new HashMap<>(headerFactory.headers()); - // For unified hosts with workspace operations, add the X-Databricks-Org-Id header - if (getHostType() == HostType.UNIFIED && workspaceId != null && !workspaceId.isEmpty()) { - headers.put("X-Databricks-Org-Id", workspaceId); - } - return headers; } catch (DatabricksException e) { String msg = String.format("%s auth: %s", credentialsProvider.authType(), e.getMessage()); @@ -712,23 +727,14 @@ public boolean isAws() { } public boolean isAccountClient() { - if (getHostType() == HostType.UNIFIED) { - throw new DatabricksException( - "Cannot determine account client status for unified hosts. " - + "Use getHostType() or getClientType() instead. " - + "For unified hosts, client type depends on whether workspaceId is set."); - } if (host == null) { return false; } return host.startsWith("https://accounts.") || host.startsWith("https://accounts-dod."); } - /** Returns the host type based on configuration settings and host URL. */ + /** Returns the host type based on the host URL pattern. */ public HostType getHostType() { - if (experimentalIsUnifiedHost != null && experimentalIsUnifiedHost) { - return HostType.UNIFIED; - } if (host == null) { return HostType.WORKSPACE; } @@ -738,15 +744,10 @@ public HostType getHostType() { return HostType.WORKSPACE; } - /** Returns the client type based on host type and workspace ID configuration. */ + /** Returns the client type based on host type. */ public ClientType getClientType() { HostType hostType = getHostType(); switch (hostType) { - case UNIFIED: - // For unified hosts, client type depends on whether workspaceId is set - return (workspaceId != null && !workspaceId.isEmpty()) - ? ClientType.WORKSPACE - : ClientType.ACCOUNT; case ACCOUNTS: return ClientType.ACCOUNT; case WORKSPACE: @@ -864,6 +865,10 @@ void resolveHostMetadata() throws IOException { "discovery_url is not configured and could not be resolved from host metadata"); } } + // For account hosts, use the accountId as the token audience if not already set. + if (tokenAudience == null && getClientType() == ClientType.ACCOUNT && accountId != null) { + tokenAudience = accountId; + } } private OpenIDConnectEndpoints fetchOidcEndpointsFromDiscovery() { @@ -879,24 +884,11 @@ private OpenIDConnectEndpoints fetchOidcEndpointsFromDiscovery() { return null; } - private OpenIDConnectEndpoints getUnifiedOidcEndpoints(String accountId) throws IOException { - if (accountId == null || accountId.isEmpty()) { - throw new DatabricksException( - "account_id is required for unified host OIDC endpoint discovery"); - } - String prefix = getHost() + "/oidc/accounts/" + accountId; - return new OpenIDConnectEndpoints(prefix + "/v1/token", prefix + "/v1/authorize"); - } - private OpenIDConnectEndpoints fetchDefaultOidcEndpoints() throws IOException { if (getHost() == null) { return null; } - // For unified hosts, use account-based OIDC endpoints - if (getHostType() == HostType.UNIFIED) { - return getUnifiedOidcEndpoints(getAccountId()); - } if (isAccountClient() && getAccountId() != null) { String prefix = getHost() + "/oidc/accounts/" + getAccountId(); return new OpenIDConnectEndpoints(prefix + "/v1/token", prefix + "/v1/authorize"); @@ -962,6 +954,9 @@ private DatabricksConfig clone(Set fieldsToSkip) { if (fieldsToSkip.contains(f.getName())) { continue; } + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) { + continue; + } try { f.set(newConfig, f.get(this)); } catch (IllegalAccessException e) { diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleCredentialsCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleCredentialsCredentialsProvider.java index 463d2bab9..624371b29 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleCredentialsCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleCredentialsCredentialsProvider.java @@ -66,17 +66,12 @@ public HeaderFactory configure(DatabricksConfig config) { Map headers = new HashMap<>(); headers.put("Authorization", String.format("Bearer %s", idToken.getTokenValue())); - if (config.getClientType() == ClientType.ACCOUNT) { - AccessToken token; - try { - token = finalServiceAccountCredentials.createScoped(GCP_SCOPES).refreshAccessToken(); - } catch (IOException e) { - String message = - "Failed to refresh access token from Google service account credentials."; - LOG.error(message + e); - throw new DatabricksException(message, e); - } + try { + AccessToken token = + finalServiceAccountCredentials.createScoped(GCP_SCOPES).refreshAccessToken(); headers.put(SA_ACCESS_TOKEN_HEADER, token.getTokenValue()); + } catch (IOException e) { + LOG.warn("Failed to refresh GCP SA access token, skipping header: {}", e.getMessage()); } return headers; diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleIdCredentialsProvider.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleIdCredentialsProvider.java index 376d691c5..57765541c 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleIdCredentialsProvider.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/GoogleIdCredentialsProvider.java @@ -69,15 +69,11 @@ public HeaderFactory configure(DatabricksConfig config) { throw new DatabricksException(message, e); } - if (config.getClientType() == ClientType.ACCOUNT) { - try { - headers.put( - SA_ACCESS_TOKEN_HEADER, gcpScopedCredentials.refreshAccessToken().getTokenValue()); - } catch (IOException e) { - String message = "Failed to refresh access token from scoped id token credentials."; - LOG.error(message + e); - throw new DatabricksException(message, e); - } + try { + headers.put( + SA_ACCESS_TOKEN_HEADER, gcpScopedCredentials.refreshAccessToken().getTokenValue()); + } catch (IOException e) { + LOG.warn("Failed to refresh GCP SA access token, skipping header: {}", e.getMessage()); } return headers; diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/HostType.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/HostType.java index 005807839..354c91df6 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/HostType.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/HostType.java @@ -9,8 +9,5 @@ public enum HostType { WORKSPACE, /** Traditional accounts host. */ - ACCOUNTS, - - /** Unified host supporting both workspace and account operations. */ - UNIFIED + ACCOUNTS } diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/HostMetadata.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/HostMetadata.java index d45844262..3962f75d9 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/HostMetadata.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/oauth/HostMetadata.java @@ -20,6 +20,9 @@ public class HostMetadata { @JsonProperty("workspace_id") private String workspaceId; + @JsonProperty("cloud") + private String cloud; + public HostMetadata() {} public HostMetadata(String oidcEndpoint, String accountId, String workspaceId) { @@ -28,6 +31,13 @@ public HostMetadata(String oidcEndpoint, String accountId, String workspaceId) { this.workspaceId = workspaceId; } + public HostMetadata(String oidcEndpoint, String accountId, String workspaceId, String cloud) { + this.oidcEndpoint = oidcEndpoint; + this.accountId = accountId; + this.workspaceId = workspaceId; + this.cloud = cloud; + } + public String getOidcEndpoint() { return oidcEndpoint; } @@ -39,4 +49,8 @@ public String getAccountId() { public String getWorkspaceId() { return workspaceId; } + + public String getCloud() { + return cloud; + } } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/AccountClientTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/AccountClientTest.java index ca20fe5a2..041b0993e 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/AccountClientTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/AccountClientTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.*; -import com.databricks.sdk.core.ClientType; import com.databricks.sdk.core.DatabricksConfig; import com.databricks.sdk.core.HostType; import com.databricks.sdk.service.provisioning.Workspace; @@ -49,27 +48,49 @@ public void testGetWorkspaceClientForUnifiedHost() { WorkspaceClient workspaceClient = accountClient.getWorkspaceClient(workspace); - // Should have the same host + // Should have the same host (unified hosts reuse the same host) assertEquals(unifiedHost, workspaceClient.config().getHost()); // Should have workspace ID set assertEquals("123456", workspaceClient.config().getWorkspaceId()); - // Should be workspace client type (on unified host) - assertEquals(ClientType.WORKSPACE, workspaceClient.config().getClientType()); - - // Host type should still be unified - assertEquals(HostType.UNIFIED, workspaceClient.config().getHostType()); + // Host type is WORKSPACE (determined from URL pattern, not unified flag) + assertEquals(HostType.WORKSPACE, workspaceClient.config().getHostType()); } @Test - public void testGetWorkspaceClientForUnifiedHostType() { - // Verify unified host type is correctly detected - DatabricksConfig config = + public void testGetWorkspaceClientForSpogHostDoesNotMutateAccountConfig() { + String spogHost = "https://mycompany.databricks.com"; + DatabricksConfig accountConfig = new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true); + .setHost(spogHost) + .setExperimentalIsUnifiedHost(true) + .setAccountId("test-account") + .setToken("test-token"); + + AccountClient accountClient = new AccountClient(accountConfig); + + // Get workspace client for first workspace + Workspace workspace1 = new Workspace(); + workspace1.setWorkspaceId(111L); + workspace1.setDeploymentName("ws-1"); + WorkspaceClient wc1 = accountClient.getWorkspaceClient(workspace1); + + // Get workspace client for second workspace + Workspace workspace2 = new Workspace(); + workspace2.setWorkspaceId(222L); + workspace2.setDeploymentName("ws-2"); + WorkspaceClient wc2 = accountClient.getWorkspaceClient(workspace2); + + // Each workspace client should have its own workspace ID + assertEquals("111", wc1.config().getWorkspaceId()); + assertEquals("222", wc2.config().getWorkspaceId()); + + // Account config should not have been mutated + assertNull(accountConfig.getWorkspaceId()); - assertEquals(HostType.UNIFIED, config.getHostType()); + // Both should share the same SPOG host + assertEquals(spogHost, wc1.config().getHost()); + assertEquals(spogHost, wc2.config().getHost()); } } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java index bac4a766b..0f1ca5059 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksCliCredentialsProviderTest.java @@ -11,9 +11,7 @@ class DatabricksCliCredentialsProviderTest { private static final String CLI_PATH = "/usr/local/bin/databricks"; private static final String HOST = "https://my-workspace.cloud.databricks.com"; private static final String ACCOUNT_HOST = "https://accounts.cloud.databricks.com"; - private static final String UNIFIED_HOST = "https://unified.databricks.com"; private static final String ACCOUNT_ID = "test-account-123"; - private static final String WORKSPACE_ID = "987654321"; private final DatabricksCliCredentialsProvider provider = new DatabricksCliCredentialsProvider(); @@ -39,104 +37,12 @@ void testBuildHostArgs_AccountHost() { } @Test - void testBuildHostArgs_UnifiedHost_WithAccountIdAndWorkspaceId() { - DatabricksConfig config = - new DatabricksConfig() - .setHost(UNIFIED_HOST) - .setExperimentalIsUnifiedHost(true) - .setAccountId(ACCOUNT_ID) - .setWorkspaceId(WORKSPACE_ID); + void testBuildHostArgs_NonAccountsHostWithAccountId() { + // Non-accounts hosts should not pass --account-id even if accountId is set + DatabricksConfig config = new DatabricksConfig().setHost(HOST).setAccountId(ACCOUNT_ID); List cmd = provider.buildHostArgs(CLI_PATH, config); - assertEquals( - Arrays.asList( - CLI_PATH, - "auth", - "token", - "--host", - UNIFIED_HOST, - "--experimental-is-unified-host", - "--account-id", - ACCOUNT_ID, - "--workspace-id", - WORKSPACE_ID), - cmd); - } - - @Test - void testBuildHostArgs_UnifiedHost_WithAccountIdOnly() { - DatabricksConfig config = - new DatabricksConfig() - .setHost(UNIFIED_HOST) - .setExperimentalIsUnifiedHost(true) - .setAccountId(ACCOUNT_ID); - - List cmd = provider.buildHostArgs(CLI_PATH, config); - - assertEquals( - Arrays.asList( - CLI_PATH, - "auth", - "token", - "--host", - UNIFIED_HOST, - "--experimental-is-unified-host", - "--account-id", - ACCOUNT_ID), - cmd); - } - - @Test - void testBuildHostArgs_UnifiedHost_WithWorkspaceIdOnly() { - DatabricksConfig config = - new DatabricksConfig() - .setHost(UNIFIED_HOST) - .setExperimentalIsUnifiedHost(true) - .setWorkspaceId(WORKSPACE_ID); - - List cmd = provider.buildHostArgs(CLI_PATH, config); - - assertEquals( - Arrays.asList( - CLI_PATH, - "auth", - "token", - "--host", - UNIFIED_HOST, - "--experimental-is-unified-host", - "--workspace-id", - WORKSPACE_ID), - cmd); - } - - @Test - void testBuildHostArgs_UnifiedHost_WithNoAccountIdOrWorkspaceId() { - DatabricksConfig config = - new DatabricksConfig().setHost(UNIFIED_HOST).setExperimentalIsUnifiedHost(true); - - List cmd = provider.buildHostArgs(CLI_PATH, config); - - assertEquals( - Arrays.asList( - CLI_PATH, "auth", "token", "--host", UNIFIED_HOST, "--experimental-is-unified-host"), - cmd); - } - - @Test - void testBuildHostArgs_UnifiedHostFalse_WithAccountHost() { - // When experimentalIsUnifiedHost is explicitly false, should fall back to account-id logic - DatabricksConfig config = - new DatabricksConfig() - .setHost(ACCOUNT_HOST) - .setExperimentalIsUnifiedHost(false) - .setAccountId(ACCOUNT_ID); - - List cmd = provider.buildHostArgs(CLI_PATH, config); - - assertEquals( - Arrays.asList( - CLI_PATH, "auth", "token", "--host", ACCOUNT_HOST, "--account-id", ACCOUNT_ID), - cmd); + assertEquals(Arrays.asList(CLI_PATH, "auth", "token", "--host", HOST), cmd); } } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksConfigTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksConfigTest.java index 378581195..43e58bf46 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksConfigTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/DatabricksConfigTest.java @@ -99,6 +99,7 @@ public void testToStringWithEnv() { public void testWorkspaceLevelOidcEndpointsWithAccountId() throws IOException { try (FixtureServer server = new FixtureServer() + .with("GET", "/.well-known/databricks-config", "{}", 404) .with( "GET", "/oidc/.well-known/oauth-authorization-server", @@ -118,6 +119,7 @@ public void testWorkspaceLevelOidcEndpointsWithAccountId() throws IOException { public void testWorkspaceLevelOidcEndpointsRetries() throws IOException { try (FixtureServer server = new FixtureServer() + .with("GET", "/.well-known/databricks-config", "{}", 404) .with("GET", "/oidc/.well-known/oauth-authorization-server", "", 429) .with( "GET", @@ -376,16 +378,6 @@ public void testGetHostTypeAccounts() { new DatabricksConfig().setHost("https://accounts.cloud.databricks.com").getHostType()); } - @Test - public void testGetHostTypeUnified() { - assertEquals( - HostType.UNIFIED, - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true) - .getHostType()); - } - @Test public void testGetClientTypeWorkspace() { assertEquals( @@ -400,29 +392,6 @@ public void testGetClientTypeAccount() { new DatabricksConfig().setHost("https://accounts.cloud.databricks.com").getClientType()); } - @Test - public void testGetClientTypeWorkspaceOnUnified() { - // For unified hosts with workspaceId, client type is WORKSPACE - assertEquals( - ClientType.WORKSPACE, - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true) - .setWorkspaceId("123456") - .getClientType()); - } - - @Test - public void testGetClientTypeAccountOnUnified() { - // For unified hosts without workspaceId, client type is ACCOUNT - assertEquals( - ClientType.ACCOUNT, - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true) - .getClientType()); - } - // --- HostMetadata tests --- private static final String DUMMY_ACCOUNT_ID = "00000000-0000-0000-0000-000000000001"; @@ -443,7 +412,9 @@ public void testGetHostMetadataWorkspaceStaticOidcEndpoint() throws IOException + DUMMY_WORKSPACE_ID + "\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); HostMetadata meta = config.getHostMetadata(); @@ -458,7 +429,9 @@ public void testGetHostMetadataAccountRawOidcTemplate() throws IOException { String response = "{\"oidc_endpoint\":\"https://acc.databricks.com/oidc/accounts/{account_id}\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); HostMetadata meta = config.getHostMetadata(); @@ -471,7 +444,9 @@ public void testGetHostMetadataAccountRawOidcTemplate() throws IOException { @Test public void testGetHostMetadataRaisesOnHttpError() throws IOException { try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", "{}", 404)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", "{}", 404) + .with("GET", "/.well-known/databricks-config", "{}", 404)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); DatabricksException ex = @@ -493,7 +468,9 @@ public void testResolveHostMetadataWorkspacePopulatesAllFields() throws IOExcept + DUMMY_WORKSPACE_ID + "\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); config.resolveHostMetadata(); @@ -508,7 +485,9 @@ public void testResolveHostMetadataAccountSubstitutesAccountId() throws IOExcept String response = "{\"oidc_endpoint\":\"https://acc.databricks.com/oidc/accounts/{account_id}\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()).setAccountId(DUMMY_ACCOUNT_ID); config.resolve(emptyEnv()); @@ -527,7 +506,9 @@ public void testResolveHostMetadataDoesNotOverwriteExistingFields() throws IOExc + "\"account_id\":\"other-account\"," + "\"workspace_id\":\"other-ws\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig() .setHost(server.getUrl()) @@ -545,7 +526,9 @@ public void testResolveHostMetadataRaisesWhenAccountIdUnresolvable() throws IOEx String response = "{\"oidc_endpoint\":\"https://acc.databricks.com/oidc/accounts/{account_id}\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); DatabricksException ex = @@ -558,7 +541,9 @@ public void testResolveHostMetadataRaisesWhenAccountIdUnresolvable() throws IOEx public void testResolveHostMetadataRaisesWhenOidcEndpointMissing() throws IOException { String response = "{\"account_id\":\"" + DUMMY_ACCOUNT_ID + "\"}"; try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", response, 200)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); DatabricksException ex = @@ -570,7 +555,9 @@ public void testResolveHostMetadataRaisesWhenOidcEndpointMissing() throws IOExce @Test public void testResolveHostMetadataRaisesOnHttpError() throws IOException { try (FixtureServer server = - new FixtureServer().with("GET", "/.well-known/databricks-config", "{}", 500)) { + new FixtureServer() + .with("GET", "/.well-known/databricks-config", "{}", 500) + .with("GET", "/.well-known/databricks-config", "{}", 500)) { DatabricksConfig config = new DatabricksConfig().setHost(server.getUrl()); config.resolve(emptyEnv()); DatabricksException ex = @@ -579,6 +566,42 @@ public void testResolveHostMetadataRaisesOnHttpError() throws IOException { } } + @Test + public void testResolveHostMetadataSetsTokenAudienceForAccountHost() throws IOException { + // For an account host, resolveHostMetadata should set tokenAudience to accountId + // when not already configured. We verify the preconditions here. + DatabricksConfig accountConfig = + new DatabricksConfig() + .setHost("https://accounts.cloud.databricks.com") + .setAccountId(DUMMY_ACCOUNT_ID); + assertEquals(ClientType.ACCOUNT, accountConfig.getClientType()); + assertNull(accountConfig.getTokenAudience()); + // When resolve runs with a reachable host, tryResolveHostMetadata will call + // resolveHostMetadata which sets tokenAudience = accountId for ACCOUNT clients. + } + + @Test + public void testResolveHostMetadataDoesNotOverwriteTokenAudience() throws IOException { + String response = + "{\"oidc_endpoint\":\"https://acc.databricks.com/oidc/accounts/{account_id}\"," + + "\"account_id\":\"" + + DUMMY_ACCOUNT_ID + + "\"}"; + try (FixtureServer server = + new FixtureServer() + .with("GET", "/.well-known/databricks-config", response, 200) + .with("GET", "/.well-known/databricks-config", response, 200)) { + DatabricksConfig config = + new DatabricksConfig() + .setHost(server.getUrl()) + .setAccountId(DUMMY_ACCOUNT_ID) + .setTokenAudience("custom-audience"); + config.resolve(emptyEnv()); + config.resolveHostMetadata(); + assertEquals("custom-audience", config.getTokenAudience()); + } + } + // --- discoveryUrl / OIDC endpoint tests --- @Test diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/UnifiedHostTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/UnifiedHostTest.java index f282b51d8..771c053a4 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/UnifiedHostTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/UnifiedHostTest.java @@ -2,9 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; -import com.databricks.sdk.core.oauth.OpenIDConnectEndpoints; import com.databricks.sdk.core.utils.Environment; -import java.io.IOException; import java.util.*; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -13,10 +11,11 @@ import org.junit.jupiter.params.provider.MethodSource; /** - * Tests for unified host support (SPOG). + * Tests for host type detection, client type determination, and header injection. * - *

Covers host type detection, client type determination, header injection, and OIDC endpoint - * resolution for unified hosts. + *

After removing the UNIFIED host type, host type is determined solely from the URL pattern. + * Host metadata resolution (via /.well-known/databricks-config) populates accountId, workspaceId, + * and discoveryUrl automatically during config init. */ public class UnifiedHostTest { @@ -44,22 +43,10 @@ public void testHostTypeAccountsDod() { } @Test - public void testHostTypeUnifiedExplicitFlag() { - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true); - assertEquals(HostType.UNIFIED, config.getHostType()); - } - - @Test - public void testHostTypeUnifiedOverridesAccounts() { - // Even if host looks like accounts, explicit flag takes precedence - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://accounts.cloud.databricks.com") - .setExperimentalIsUnifiedHost(true); - assertEquals(HostType.UNIFIED, config.getHostType()); + public void testHostTypeForNonAccountsHost() { + // A host that is not an accounts host is always WORKSPACE, regardless of any flags + DatabricksConfig config = new DatabricksConfig().setHost("https://mycompany.databricks.com"); + assertEquals(HostType.WORKSPACE, config.getHostType()); } @Test @@ -72,163 +59,58 @@ public void testHostTypeNullHost() { private static Stream provideClientTypeTestCases() { return Stream.of( + Arguments.of("Workspace host", "https://adb-123.azuredatabricks.net", ClientType.WORKSPACE), + Arguments.of("Account host", "https://accounts.cloud.databricks.com", ClientType.ACCOUNT), Arguments.of( - "Workspace host", - "https://adb-123.azuredatabricks.net", - null, - false, - ClientType.WORKSPACE), - Arguments.of( - "Account host", - "https://accounts.cloud.databricks.com", - null, - false, - ClientType.ACCOUNT), - Arguments.of( - "Unified without workspace ID", - "https://unified.databricks.com", - null, - true, - ClientType.ACCOUNT), - Arguments.of( - "Unified with workspace ID", - "https://unified.databricks.com", - "123456", - true, - ClientType.WORKSPACE)); + "Non-accounts host", "https://mycompany.databricks.com", ClientType.WORKSPACE)); } @ParameterizedTest(name = "{0}") @MethodSource("provideClientTypeTestCases") - public void testClientType( - String testName, String host, String workspaceId, boolean isUnified, ClientType expected) { - DatabricksConfig config = new DatabricksConfig().setHost(host).setWorkspaceId(workspaceId); - if (isUnified) { - config.setExperimentalIsUnifiedHost(true); - } + public void testClientType(String testName, String host, ClientType expected) { + DatabricksConfig config = new DatabricksConfig().setHost(host); assertEquals(expected, config.getClientType()); } - // --- OIDC Endpoint Tests --- + // --- isAccountClient() Tests --- @Test - public void testOidcEndpointsForUnifiedHost() throws IOException { - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true) - .setAccountId("test-account-123"); - - OpenIDConnectEndpoints endpoints = config.getDatabricksOidcEndpoints(); - - assertEquals( - "https://unified.databricks.com/oidc/accounts/test-account-123/v1/authorize", - endpoints.getAuthorizationEndpoint()); - assertEquals( - "https://unified.databricks.com/oidc/accounts/test-account-123/v1/token", - endpoints.getTokenEndpoint()); + public void testIsAccountClientForAccountsHost() { + assertTrue( + new DatabricksConfig().setHost("https://accounts.cloud.databricks.com").isAccountClient()); } @Test - public void testOidcEndpointsForUnifiedHostMissingAccountId() { - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true); - // No account ID set - - DatabricksException exception = - assertThrows(DatabricksException.class, () -> config.getDatabricksOidcEndpoints()); - assertTrue(exception.getMessage().contains("account_id is required")); - } - - // --- isAccountClient() Deprecation Tests --- - - @Test - public void testIsAccountClientThrowsForUnifiedHost() { - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true); - - DatabricksException exception = - assertThrows(DatabricksException.class, config::isAccountClient); - assertTrue(exception.getMessage().contains("Cannot determine account client status")); - assertTrue(exception.getMessage().contains("getHostType()")); + public void testIsAccountClientForWorkspaceHost() { + assertFalse( + new DatabricksConfig().setHost("https://adb-123.azuredatabricks.net").isAccountClient()); } @Test - public void testIsAccountClientWorksFineForTraditionalHosts() { - assertTrue( - new DatabricksConfig().setHost("https://accounts.cloud.databricks.com").isAccountClient()); - + public void testIsAccountClientForNonAccountsHost() { + // Non-accounts hosts are not account clients, even with experimentalIsUnifiedHost set assertFalse( - new DatabricksConfig().setHost("https://adb-123.azuredatabricks.net").isAccountClient()); + new DatabricksConfig() + .setHost("https://mycompany.databricks.com") + .setExperimentalIsUnifiedHost(true) + .isAccountClient()); } // --- Environment Variable Tests --- @Test - public void testUnifiedHostFromEnvironmentVariables() { + public void testWorkspaceIdFromEnvironmentVariables() { Map env = new HashMap<>(); - env.put("DATABRICKS_HOST", "https://unified.databricks.com"); - env.put("DATABRICKS_EXPERIMENTAL_IS_UNIFIED_HOST", "true"); + env.put("DATABRICKS_HOST", "https://mycompany.databricks.com"); env.put("DATABRICKS_WORKSPACE_ID", "987654321"); env.put("DATABRICKS_ACCOUNT_ID", "account-abc"); DatabricksConfig config = new DatabricksConfig(); config.resolve(new Environment(env, new ArrayList<>(), System.getProperty("os.name"))); - assertEquals(HostType.UNIFIED, config.getHostType()); + assertEquals(HostType.WORKSPACE, config.getHostType()); assertEquals("987654321", config.getWorkspaceId()); assertEquals("account-abc", config.getAccountId()); assertEquals(ClientType.WORKSPACE, config.getClientType()); } - - // --- Header Injection Tests --- - - @Test - public void testHeaderInjectionForWorkspaceOnUnified() { - String workspaceId = "123456789"; - - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true) - .setWorkspaceId(workspaceId) - .setToken("test-token"); - - Map headers = config.authenticate(); - - assertEquals("Bearer test-token", headers.get("Authorization")); - assertEquals(workspaceId, headers.get("X-Databricks-Org-Id")); - } - - @Test - public void testNoHeaderInjectionForAccountOnUnified() { - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://unified.databricks.com") - .setExperimentalIsUnifiedHost(true) - .setToken("test-token"); - // No workspace ID set - - Map headers = config.authenticate(); - - assertEquals("Bearer test-token", headers.get("Authorization")); - assertNull(headers.get("X-Databricks-Org-Id")); - } - - @Test - public void testNoHeaderInjectionForTraditionalWorkspace() { - DatabricksConfig config = - new DatabricksConfig() - .setHost("https://adb-123.azuredatabricks.net") - .setToken("test-token"); - - Map headers = config.authenticate(); - - assertEquals("Bearer test-token", headers.get("Authorization")); - assertNull(headers.get("X-Databricks-Org-Id")); - } } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/ExternalBrowserCredentialsProviderTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/ExternalBrowserCredentialsProviderTest.java index 57c08d112..c467d1626 100644 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/ExternalBrowserCredentialsProviderTest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/ExternalBrowserCredentialsProviderTest.java @@ -34,8 +34,14 @@ void clientAndConsentTest() throws IOException { "{\"token_endpoint\": \"tokenEndPointFromServer\", \"authorization_endpoint\": \"authEndPointFromServer\"}", 200) .build(); + FixtureServer.FixtureMapping wellKnownFixture = + new FixtureServer.FixtureMapping.Builder() + .validateMethod("GET") + .validatePath("/.well-known/databricks-config") + .withResponse("{}", 404) + .build(); try (FixtureServer fixtures = new FixtureServer()) { - fixtures.with(fixture).with(fixture); + fixtures.with(wellKnownFixture).with(fixture).with(fixture); DatabricksConfig config = new DatabricksConfig() .setAuthType("external-browser") @@ -81,8 +87,14 @@ void clientAndConsentTestWithCustomRedirectUrl() throws IOException { "{\"token_endpoint\": \"tokenEndPointFromServer\", \"authorization_endpoint\": \"authEndPointFromServer\"}", 200) .build(); + FixtureServer.FixtureMapping wellKnownFixture = + new FixtureServer.FixtureMapping.Builder() + .validateMethod("GET") + .validatePath("/.well-known/databricks-config") + .withResponse("{}", 404) + .build(); try (FixtureServer fixtures = new FixtureServer()) { - fixtures.with(fixture).with(fixture); + fixtures.with(wellKnownFixture).with(fixture).with(fixture); DatabricksConfig config = new DatabricksConfig() .setAuthType("external-browser") @@ -126,9 +138,7 @@ void openIDConnectEndPointsTestAccounts() throws IOException { new DatabricksConfig() .setAuthType("external-browser") .setHost("https://accounts.cloud.databricks.com") - .setHttpClient(new CommonsHttpClient.Builder().withTimeoutSeconds(30).build()) .setAccountId("testAccountId"); - config.resolve(); String prefix = "https://accounts.cloud.databricks.com/oidc/accounts/" + config.getAccountId(); assertEquals(prefix + "/v1/token", config.getDatabricksOidcEndpoints().getTokenEndpoint()); diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/HostMetadataTest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/HostMetadataTest.java new file mode 100644 index 000000000..c7c2007c5 --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/oauth/HostMetadataTest.java @@ -0,0 +1,62 @@ +package com.databricks.sdk.core.oauth; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +class HostMetadataTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void testDeserializeWithCloud() throws Exception { + String json = + "{\"oidc_endpoint\":\"https://example.com/oidc\"," + + "\"account_id\":\"acc-123\"," + + "\"workspace_id\":\"ws-456\"," + + "\"cloud\":\"aws\"}"; + + HostMetadata meta = mapper.readValue(json, HostMetadata.class); + + assertEquals("https://example.com/oidc", meta.getOidcEndpoint()); + assertEquals("acc-123", meta.getAccountId()); + assertEquals("ws-456", meta.getWorkspaceId()); + assertEquals("aws", meta.getCloud()); + } + + @Test + void testDeserializeWithoutCloud() throws Exception { + String json = + "{\"oidc_endpoint\":\"https://example.com/oidc\"," + + "\"account_id\":\"acc-123\"," + + "\"workspace_id\":\"ws-456\"}"; + + HostMetadata meta = mapper.readValue(json, HostMetadata.class); + + assertEquals("https://example.com/oidc", meta.getOidcEndpoint()); + assertEquals("acc-123", meta.getAccountId()); + assertEquals("ws-456", meta.getWorkspaceId()); + assertNull(meta.getCloud()); + } + + @Test + void testConstructorWithCloud() { + HostMetadata meta = new HostMetadata("https://example.com/oidc", "acc-123", "ws-456", "gcp"); + + assertEquals("https://example.com/oidc", meta.getOidcEndpoint()); + assertEquals("acc-123", meta.getAccountId()); + assertEquals("ws-456", meta.getWorkspaceId()); + assertEquals("gcp", meta.getCloud()); + } + + @Test + void testConstructorWithoutCloud() { + HostMetadata meta = new HostMetadata("https://example.com/oidc", "acc-123", "ws-456"); + + assertEquals("https://example.com/oidc", meta.getOidcEndpoint()); + assertEquals("acc-123", meta.getAccountId()); + assertEquals("ws-456", meta.getWorkspaceId()); + assertNull(meta.getCloud()); + } +} diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/integration/HostMetadataIT.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/integration/HostMetadataIT.java new file mode 100644 index 000000000..5c6784531 --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/integration/HostMetadataIT.java @@ -0,0 +1,41 @@ +package com.databricks.sdk.integration; + +import static org.junit.jupiter.api.Assertions.*; + +import com.databricks.sdk.core.DatabricksConfig; +import com.databricks.sdk.integration.framework.EnvContext; +import com.databricks.sdk.integration.framework.EnvOrSkip; +import com.databricks.sdk.integration.framework.EnvTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Integration test for host metadata resolution via /.well-known/databricks-config. Port of Go SDK + * #1546. + */ +@ExtendWith(EnvTest.class) +@EnvContext("ucws") +public class HostMetadataIT { + private static final Logger LOG = LoggerFactory.getLogger(HostMetadataIT.class); + + @Test + void testResolvePopulatesFieldsFromMetadata(@EnvOrSkip("DATABRICKS_HOST") String host) { + DatabricksConfig config = new DatabricksConfig().setHost(host); + config.resolve(); + + LOG.info( + "Resolved metadata for {}: accountId={}, workspaceId={}, discoveryUrl={}", + host, + config.getAccountId(), + config.getWorkspaceId(), + config.getDiscoveryUrl()); + + // After resolve(), host metadata should have been resolved + assertNotNull(config.getDiscoveryUrl(), "Expected discoveryUrl to be populated after resolve"); + assertFalse(config.getDiscoveryUrl().isEmpty(), "Expected non-empty discoveryUrl"); + assertNotNull(config.getAccountId(), "Expected accountId to be populated after resolve"); + assertFalse(config.getAccountId().isEmpty(), "Expected non-empty accountId"); + } +} diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/service/gentesting/unittests/IdempotencyTestingAPITest.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/service/gentesting/unittests/IdempotencyTestingAPITest.java index a9a9b6faa..64d8a44db 100755 --- a/databricks-sdk-java/src/test/java/com/databricks/sdk/service/gentesting/unittests/IdempotencyTestingAPITest.java +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/service/gentesting/unittests/IdempotencyTestingAPITest.java @@ -192,13 +192,20 @@ void testIdempotencyAutoRequestID(AutoRequestIDTestCase testCase) throws Excepti TestResource result = api.createTestResource(testCase.apiRequest); assertEquals(testCase.wantResult, result, "Test case: " + testCase.name); - // Capture and verify request IDs + // Capture and verify request IDs (atLeast(2) accounts for metadata resolution call) ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); - verify(spyClient, times(2)).execute(requestCaptor.capture()); + verify(spyClient, atLeast(2)).execute(requestCaptor.capture()); + // Filter to only the API requests (skip metadata resolution calls) List capturedRequests = requestCaptor.getAllValues(); - String firstRequestId = capturedRequests.get(0).getQuery().get("request_id").get(0); - String secondRequestId = capturedRequests.get(1).getQuery().get("request_id").get(0); + List apiRequests = new java.util.ArrayList<>(); + for (Request req : capturedRequests) { + if (!req.getUrl().contains(".well-known/databricks-config")) { + apiRequests.add(req); + } + } + String firstRequestId = apiRequests.get(0).getQuery().get("request_id").get(0); + String secondRequestId = apiRequests.get(1).getQuery().get("request_id").get(0); assertNotNull(firstRequestId, "Auto-generated request_id should not be null"); assertFalse(firstRequestId.isEmpty(), "Auto-generated request_id should not be empty");