Skip to content

Commit e03c05e

Browse files
authored
Better error message when private link enabled workspaces reject requests (#290)
## Changes Port of databricks/databricks-sdk-go#924 to the Java SDK. When a user tries to access a Private Link-enabled workspace configured with no public internet access from a different network than the VPC endpoint belongs to, the Private Link backend redirects the user to the login page, rather than outright rejecting the request. The login page, however, is not a JSON document and cannot be parsed by the SDK, resulting in this error message: ``` $ databricks current-user me Error: unexpected error handling request: invalid character '<' looking for beginning of value. This is likely a bug in the Databricks SDK for Go or the underlying REST API. Please report this issue with the following debugging information to the SDK issue tracker at https://github.com/databricks/databricks-sdk-go/issues. Request log: GET /login.html?error=private-link-validation-error:<WSID> > * Host: > * Accept: application/json > * Authorization: REDACTED > * Referer: https://adb-<WSID>.azuredatabricks.net/api/2.0/preview/scim/v2/Me > * User-Agent: cli/0.0.0-dev+5ed10bb8ccc1 databricks-sdk-go/0.39.0 go/1.22.2 os/darwin cmd/current-user_me auth/pat < HTTP/2.0 200 OK < * Cache-Control: no-cache, no-store, must-revalidate < * Content-Security-Policy: default-src *; font-src * data:; frame-src * blob:; img-src * blob: data:; media-src * data:; object-src 'none'; style-src * 'unsafe-inline'; worker-src * blob:; script-src 'self' 'unsafe-eval' 'unsafe-hashes' 'report-sample' https://*.databricks.com https://databricks.github.io/debug-bookmarklet/ https://widget.intercom.io https://js.intercomcdn.com https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js https://databricks-ui-assets.azureedge.net https://ui-serving-cdn-testing.azureedge.net https://uiserviceprodwestus-cdn-endpoint.azureedge.net https://databricks-ui-infra.s3.us-west-2.amazonaws.com 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-YOlue469P2BtTMZYUFLupA2aOUsgc6B/TDewH7/Qz7s=' 'sha256-Lh4yp7cr3YOJ3MOn6erNz3E3WI0JA20mWV+0RuuviFM=' 'sha256-0jMhpY6PB/BTRDLWtfcjdwiHOpE+6rFk3ohvY6fbuHU='; report-uri /ui-csp-reports; frame-ancestors *.vocareum.com *.docebosaas.com *.edx.org *.deloitte.com *.cloudlabs.ai *.databricks.com *.myteksi.net < * Content-Type: text/html; charset=utf-8 < * Date: Fri, 17 May 2024 07:47:38 GMT < * Server: databricks < * Set-Cookie: enable-armeria-workspace-server-for-ui-flags=false; Max-Age=1800; Expires=Fri, 17 May 2024 08:17:38 GMT; Secure; HTTPOnly; SameSite=Strict < * Strict-Transport-Security: max-age=31536000; includeSubDomains; preload < * X-Content-Type-Options: nosniff < * X-Ui-Svc: true < * X-Xss-Protection: 1; mode=block < <!doctype html> < <html> < <head> < <meta charset="utf-8"> < <meta http-equiv="Content-Language" content="en"> < <title>Databricks - Sign In</title> < <meta name="viewport" content="width=960"> < <link rel="icon" type="image/png" href="https://databricks-ui-assets.azureedge.net/favicon.ico"> < <meta http-equiv="content-type" content="text/html; charset=UTF8"> < <script id="__databricks_react_script"></script> < <script>window.__DATABRICKS_SAFE_FLAGS__={"databricks.infra.showErrorModalOnFetchError":true,"databricks.fe.infra.useReact18":true,"databricks.fe.infra.useReact18NewAPI":false,"databricks.fe.infra.fixConfigPrefetch":true},window.__DATABRICKS_CONFIG__={"publicPath":{"mlflow":"https://databricks-ui-assets.azureedge.net/","dbsql":"https://databricks-ui-assets.azureedge.net/","feature-store":"https://databricks-ui-assets.azureedge.net/","monolith":"https://databricks-ui-assets.azureedge.net/","jaws":"https://databricks-ui-assets.azureedge.net/"}}</script> < <link rel="icon" href="https://databricks-ui-assets.azureedge.net/favicon.ico"> < <script> < function setNoCdnAndReload() { < document.cookie = `x-databricks-cdn-inaccessible=true; path=/; max-age=86400`; < const metric = 'cdnFallbackOccurred'; < const browserUserAgent = navigator.userAgent; < const browserTabId = window.browserTabId; < const performanceEntry = performance.getEntriesByType('resource').filter(e => e.initiatorType === 'script').slice(-1)[0] < sessionStorage.setItem('databricks-cdn-fallback-telemetry-key', JSON.stringify({ tags: { browserUserAgent, browserTabId }, performanceEntry})); < window.location.reload(); < } < </script> < <script> < // Set a manual timeout for dropped packets to CDN < function loadScriptWithTimeout(src, timeout) { < return new Promise((resolve, reject) => { < const script = document.createElement('script'); < script.defer = true; < script.src = src; < script.onload = resolve; < script.onerror = reject; < document.head.appendChild(script); < setTimeout(() => { < reject(new Error('Script load timeout')); < }, timeout); < }); < } < loadScriptWithTimeout('https://databricks-ui-assets.azureedge.net/static/js/login/login.8a983ca2.js', 10000).catch(setNoCdnAndReload); < </script> < </head> < <body class="light-mode"> < <uses-legacy-bootstrap> < <div id="login-page"></div> < </uses-legacy-bootstrap> < </body> < </html> ``` To address this, I add one additional check in the error mapper logic to inspect whether the user was redirected to the login page with the private link validation error response code. If so, we return a synthetic error with error code `PRIVATE_LINK_VALIDATION_ERROR` that inherits from PermissionDenied and has a mock 403 status code. After this change, users will see an error message like this: ``` Exception in thread "main" com.databricks.sdk.core.error.PrivateLinkValidationError: The requested workspace has Azure Private Link enabled and is not accessible from the current network. Ensure that Azure Private Link is properly configured and that your device has access to the Azure Private Link endpoint. For more information, see https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link-standard#authentication-troubleshooting. at com.databricks.sdk.core.error.PrivateLinkInfo.createPrivateLinkValidationError(PrivateLinkInfo.java:63) at com.databricks.sdk.core.error.AbstractErrorMapper.apply(AbstractErrorMapper.java:46) at com.databricks.sdk.core.error.ApiErrors.getDatabricksError(ApiErrors.java:29) at com.databricks.sdk.core.ApiClient.executeInner(ApiClient.java:276) at com.databricks.sdk.core.ApiClient.getResponse(ApiClient.java:235) at com.databricks.sdk.core.ApiClient.execute(ApiClient.java:227) at com.databricks.sdk.core.ApiClient.GET(ApiClient.java:148) at com.databricks.sdk.service.compute.ClustersImpl.list(ClustersImpl.java:94) at com.databricks.sdk.support.Paginator.flipNextPage(Paginator.java:58) at com.databricks.sdk.support.Paginator.<init>(Paginator.java:51) at com.databricks.sdk.service.compute.ClustersAPI.list(ClustersAPI.java:295) at com.databricks.sdk.examples.ListClustersExample.main(ListClustersExample.java:11) ``` The error message is tuned to the specific cloud so that we can redirect users to the appropriate documentation, the cloud being inferred from the request URI. ## Tests Unit tests cover the private link error message mapping. To manually test this, I created a private link workspace in Azure, created an access token, restricted access to the workspace, then ran the `ListClustersExample` example using the host & token: ``` <SNIP> 14:41 [DEBUG] > GET /api/2.0/clusters/list < 200 OK < <!doctype html> < <html> < <head> < <meta charset="utf-8"> < <meta http-equiv="Content-Language" content="en"> < <title>Databricks - Sign In</title> < <meta name="viewport" content="width=960"> < <link rel="icon" type="image/png" href="https://databricks-ui-assets.azureedge.net/favicon.ico"> < <meta http-equiv="content-type" content="text/html; charset=UTF8"> < <script id="__databricks_react_script"></script> < <script>window.__DATABRICKS_SAFE_FLAGS__={"databricks.infra.showErrorModalOnFetchError":true,"databricks.fe.infra.useReact18":true,"databricks.fe.infra.useReact18NewAPI":false,"databricks.fe.infra.fixConfigPrefetch":true},window.__DATABRICKS_CONFIG__={"publicPath":{"mlflow":"https://databricks-ui-assets.azureedge.net/","dbsql":"https://databricks-ui-assets.azureedge.net/","feature-store":"https://databricks-ui-assets.azureedge.net/","monolith":"https://databricks-ui-assets.azureedge.net/","jaws":"https://databricks-ui-assets.azureedge.net/"}}</script> < <link rel="icon" href="https://databricks-ui-assets.... (1420 more bytes) Exception in thread "main" com.databricks.sdk.core.error.PrivateLinkValidationError: The requested workspace has Azure Private Link enabled and is not accessible from the current network. Ensure that Azure Private Link is properly configured and that your device has access to the Azure Private Link endpoint. For more information, see https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link-standard#authentication-troubleshooting. at com.databricks.sdk.core.error.PrivateLinkInfo.createPrivateLinkValidationError(PrivateLinkInfo.java:63) at com.databricks.sdk.core.error.AbstractErrorMapper.apply(AbstractErrorMapper.java:46) at com.databricks.sdk.core.error.ApiErrors.getDatabricksError(ApiErrors.java:29) at com.databricks.sdk.core.ApiClient.executeInner(ApiClient.java:276) at com.databricks.sdk.core.ApiClient.getResponse(ApiClient.java:235) at com.databricks.sdk.core.ApiClient.execute(ApiClient.java:227) at com.databricks.sdk.core.ApiClient.GET(ApiClient.java:148) at com.databricks.sdk.service.compute.ClustersImpl.list(ClustersImpl.java:94) at com.databricks.sdk.support.Paginator.flipNextPage(Paginator.java:58) at com.databricks.sdk.support.Paginator.<init>(Paginator.java:51) at com.databricks.sdk.service.compute.ClustersAPI.list(ClustersAPI.java:295) at com.databricks.sdk.examples.ListClustersExample.main(ListClustersExample.java:11) Disconnected from the target VM, address: '127.0.0.1:55559', transport: 'socket' Process finished with exit code 1 exit status 2 ```
1 parent b619676 commit e03c05e

18 files changed

+282
-53
lines changed

databricks-sdk-java/src/main/java/com/databricks/sdk/core/ApiClient.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.databricks.sdk.core;
22

33
import com.databricks.sdk.core.error.ApiErrors;
4+
import com.databricks.sdk.core.error.PrivateLinkInfo;
45
import com.databricks.sdk.core.http.HttpClient;
56
import com.databricks.sdk.core.http.Request;
67
import com.databricks.sdk.core.http.Response;
@@ -261,9 +262,6 @@ private Response executeInner(Request in, String path) {
261262
if (LOG.isDebugEnabled()) {
262263
LOG.debug(makeLogRecord(in, out));
263264
}
264-
if (out.getStatusCode() < 400) {
265-
return out;
266-
}
267265
} catch (IOException e) {
268266
err = e;
269267
LOG.debug("Request {} failed", in, e);
@@ -297,7 +295,10 @@ private Response executeInner(Request in, String path) {
297295
}
298296

299297
private boolean isRequestSuccessful(Response response, Exception e) {
300-
return e == null && response.getStatusCode() >= 200 && response.getStatusCode() < 300;
298+
return e == null
299+
&& response.getStatusCode() >= 200
300+
&& response.getStatusCode() < 300
301+
&& !PrivateLinkInfo.isPrivateLinkRedirect(response);
301302
}
302303

303304
public long getBackoffMillis(Response response, int attemptNumber) {

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,9 @@ public DatabricksConfig setUseSystemPropertiesHttp(Boolean useSystemPropertiesHt
531531
}
532532

533533
public boolean isAzure() {
534+
if (azureWorkspaceResourceId != null) {
535+
return true;
536+
}
534537
return this.getDatabricksEnvironment().getCloud() == Cloud.AZURE;
535538
}
536539

@@ -602,15 +605,7 @@ public DatabricksEnvironment getDatabricksEnvironment() {
602605
return this.databricksEnvironment;
603606
}
604607

605-
if (this.host != null) {
606-
for (DatabricksEnvironment env : DatabricksEnvironment.ALL_ENVIRONMENTS) {
607-
if (this.host.endsWith(env.getDnsZone())) {
608-
return env;
609-
}
610-
}
611-
}
612-
613-
if (this.azureWorkspaceResourceId != null) {
608+
if (this.host == null && this.azureWorkspaceResourceId != null) {
614609
String azureEnv = "PUBLIC";
615610
if (this.azureEnvironment != null) {
616611
azureEnv = this.azureEnvironment;
@@ -629,7 +624,7 @@ public DatabricksEnvironment getDatabricksEnvironment() {
629624
}
630625
}
631626

632-
return DatabricksEnvironment.DEFAULT_ENVIRONMENT;
627+
return DatabricksEnvironment.getEnvironmentFromHostname(this.host);
633628
}
634629

635630
public DatabricksConfig newWithWorkspaceHost(String host) {

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksEnvironment.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,16 @@ public String getDeploymentUrl(String name) {
7979
new DatabricksEnvironment(Cloud.GCP, ".dev.gcp.databricks.com"),
8080
new DatabricksEnvironment(Cloud.GCP, ".staging.gcp.databricks.com"),
8181
new DatabricksEnvironment(Cloud.GCP, ".gcp.databricks.com"));
82+
83+
public static DatabricksEnvironment getEnvironmentFromHostname(String hostname) {
84+
if (hostname == null) {
85+
return DEFAULT_ENVIRONMENT;
86+
}
87+
for (DatabricksEnvironment env : ALL_ENVIRONMENTS) {
88+
if (hostname.endsWith(env.getDnsZone())) {
89+
return env;
90+
}
91+
}
92+
return DEFAULT_ENVIRONMENT;
93+
}
8294
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/commons/CommonsHttpClient.java

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@
1212
import com.databricks.sdk.core.utils.ProxyUtils;
1313
import java.io.IOException;
1414
import java.io.InputStream;
15+
import java.net.MalformedURLException;
16+
import java.net.URI;
17+
import java.net.URISyntaxException;
18+
import java.net.URL;
1519
import java.nio.charset.StandardCharsets;
1620
import java.util.Arrays;
1721
import java.util.List;
1822
import java.util.Map;
1923
import java.util.stream.Collectors;
2024
import org.apache.commons.io.IOUtils;
2125
import org.apache.http.HttpEntity;
26+
import org.apache.http.HttpHost;
2227
import org.apache.http.NameValuePair;
2328
import org.apache.http.StatusLine;
2429
import org.apache.http.client.config.RequestConfig;
@@ -28,6 +33,8 @@
2833
import org.apache.http.impl.client.CloseableHttpClient;
2934
import org.apache.http.impl.client.HttpClientBuilder;
3035
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
36+
import org.apache.http.protocol.BasicHttpContext;
37+
import org.apache.http.protocol.HttpContext;
3138
import org.slf4j.Logger;
3239
import org.slf4j.LoggerFactory;
3340

@@ -90,11 +97,32 @@ public Response execute(Request in) throws IOException {
9097
request.getParams().setParameter("http.protocol.handle-redirects", false);
9198
}
9299
in.getHeaders().forEach(request::setHeader);
93-
CloseableHttpResponse response = hc.execute(request);
94-
return computeResponse(in, response);
100+
HttpContext context = new BasicHttpContext();
101+
CloseableHttpResponse response = hc.execute(request, context);
102+
return computeResponse(in, context, response);
95103
}
96104

97-
private Response computeResponse(Request in, CloseableHttpResponse response) throws IOException {
105+
private URL getTargetUrl(HttpContext context) {
106+
try {
107+
HttpHost targetHost = (HttpHost) context.getAttribute("http.target_host");
108+
HttpUriRequest request = (HttpUriRequest) context.getAttribute("http.request");
109+
URI uri =
110+
new URI(
111+
targetHost.getSchemeName(),
112+
null,
113+
targetHost.getHostName(),
114+
targetHost.getPort(),
115+
request.getURI().getPath(),
116+
request.getURI().getQuery(),
117+
request.getURI().getFragment());
118+
return uri.toURL();
119+
} catch (MalformedURLException | URISyntaxException e) {
120+
throw new DatabricksException("Unable to get target URL", e);
121+
}
122+
}
123+
124+
private Response computeResponse(Request in, HttpContext context, CloseableHttpResponse response)
125+
throws IOException {
98126
HttpEntity entity = response.getEntity();
99127
StatusLine statusLine = response.getStatusLine();
100128
Map<String, List<String>> hs =
@@ -103,9 +131,10 @@ private Response computeResponse(Request in, CloseableHttpResponse response) thr
103131
Collectors.groupingBy(
104132
NameValuePair::getName,
105133
Collectors.mapping(NameValuePair::getValue, Collectors.toList())));
134+
URL url = getTargetUrl(context);
106135
if (entity == null) {
107136
response.close();
108-
return new Response(in, statusLine.getStatusCode(), statusLine.getReasonPhrase(), hs);
137+
return new Response(in, url, statusLine.getStatusCode(), statusLine.getReasonPhrase(), hs);
109138
}
110139

111140
// The Databricks SDK is currently designed to treat all non-application/json responses as
@@ -133,12 +162,13 @@ private Response computeResponse(Request in, CloseableHttpResponse response) thr
133162
}
134163
});
135164
return new Response(
136-
in, statusLine.getStatusCode(), statusLine.getReasonPhrase(), hs, inputStream);
165+
in, url, statusLine.getStatusCode(), statusLine.getReasonPhrase(), hs, inputStream);
137166
}
138167

139168
try (InputStream inputStream = entity.getContent()) {
140169
String body = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
141-
return new Response(in, statusLine.getStatusCode(), statusLine.getReasonPhrase(), hs, body);
170+
return new Response(
171+
in, url, statusLine.getStatusCode(), statusLine.getReasonPhrase(), hs, body);
142172
} finally {
143173
response.close();
144174
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public DatabricksError apply(Response resp, ApiErrorBody errorBody) {
4242
if (statusCodeMapping.containsKey(code)) {
4343
return statusCodeMapping.get(code).create(errorCode, message, details);
4444
}
45+
if (PrivateLinkInfo.isPrivateLinkRedirect(resp)) {
46+
return PrivateLinkInfo.createPrivateLinkValidationError(resp);
47+
}
4548
return new DatabricksError(errorCode, message, code, details);
4649
}
4750

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ public static DatabricksError getDatabricksError(Response out, Exception error)
2323
return new DatabricksError("IO_ERROR", 523, error);
2424
} else if (out.getStatusCode() == 429) {
2525
return new DatabricksError("TOO_MANY_REQUESTS", "Current request has to be retried", 429);
26-
} else if (out.getStatusCode() >= 400) {
27-
return readErrorFromResponse(out);
28-
} else {
29-
// The request succeeded; do not retry.
30-
return new DatabricksError(out.getStatusCode());
3126
}
27+
28+
ApiErrorBody errorBody = readErrorFromResponse(out);
29+
return ERROR_MAPPER.apply(out, errorBody);
3230
}
3331

34-
private static DatabricksError readErrorFromResponse(Response response) {
32+
private static ApiErrorBody readErrorFromResponse(Response response) {
33+
// Private link error handling depends purely on the response URL.
34+
if (PrivateLinkInfo.isPrivateLinkRedirect(response)) {
35+
return new ApiErrorBody();
36+
}
3537
ApiErrorBody errorBody = parseApiError(response);
3638

3739
// Condense API v1.2 and SCIM error string and code into the message and errorCode fields of
@@ -52,7 +54,7 @@ private static DatabricksError readErrorFromResponse(Response response) {
5254
if (errorBody.getErrorDetails() == null) {
5355
errorBody.setErrorDetails(Collections.emptyList());
5456
}
55-
return ERROR_MAPPER.apply(response, errorBody);
57+
return errorBody;
5658
}
5759

5860
/**
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 com.databricks.sdk.core.DatabricksEnvironment;
4+
import com.databricks.sdk.core.http.Response;
5+
import com.databricks.sdk.core.utils.Cloud;
6+
import java.util.Collections;
7+
import java.util.HashMap;
8+
import java.util.Map;
9+
10+
public class PrivateLinkInfo {
11+
private final String serviceName;
12+
private final String endpointName;
13+
private final String referencePage;
14+
15+
static final Map<Cloud, PrivateLinkInfo> PRIVATE_LINK_INFOS = loadPrivateLinkInfos();
16+
17+
static Map<Cloud, PrivateLinkInfo> loadPrivateLinkInfos() {
18+
Map<Cloud, PrivateLinkInfo> privateLinkInfoMap = new HashMap<>();
19+
privateLinkInfoMap.put(
20+
Cloud.AWS,
21+
new PrivateLinkInfo(
22+
"AWS PrivateLink",
23+
"AWS VPC endpoint",
24+
"https://docs.databricks.com/en/security/network/classic/privatelink.html"));
25+
privateLinkInfoMap.put(
26+
Cloud.AZURE,
27+
new PrivateLinkInfo(
28+
"Azure Private Link",
29+
"Azure Private Link endpoint",
30+
"https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link-standard#authentication-troubleshooting"));
31+
privateLinkInfoMap.put(
32+
Cloud.GCP,
33+
new PrivateLinkInfo(
34+
"Private Service Connect",
35+
"GCP VPC endpoint",
36+
"https://docs.gcp.databricks.com/en/security/network/classic/private-service-connect.html"));
37+
return privateLinkInfoMap;
38+
}
39+
40+
public PrivateLinkInfo(String serviceName, String endpointName, String referencePage) {
41+
this.serviceName = serviceName;
42+
this.endpointName = endpointName;
43+
this.referencePage = referencePage;
44+
}
45+
46+
public String errorMessage() {
47+
return String.format(
48+
"The requested workspace has %s enabled and is not accessible from the current network. "
49+
+ "Ensure that %s is properly configured and that your device has access to the %s. "
50+
+ "For more information, see %s.",
51+
serviceName, serviceName, endpointName, referencePage);
52+
}
53+
54+
public static boolean isPrivateLinkRedirect(Response resp) {
55+
return resp.getUrl().getPath().equals("/login.html")
56+
&& resp.getUrl().getQuery().contains("error=private-link-validation-error");
57+
}
58+
59+
static PrivateLinkValidationError createPrivateLinkValidationError(Response resp) {
60+
DatabricksEnvironment env =
61+
DatabricksEnvironment.getEnvironmentFromHostname(resp.getUrl().getHost());
62+
PrivateLinkInfo info = PRIVATE_LINK_INFOS.get(env.getCloud());
63+
return new PrivateLinkValidationError(info.errorMessage(), Collections.emptyList());
64+
}
65+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.databricks.sdk.core.error;
2+
3+
import com.databricks.sdk.core.error.platform.PermissionDenied;
4+
import java.util.List;
5+
6+
public class PrivateLinkValidationError extends PermissionDenied {
7+
public PrivateLinkValidationError(String message, List<ErrorDetail> details) {
8+
super("PRIVATE_LINK_VALIDATION_ERROR", message, details);
9+
}
10+
}

0 commit comments

Comments
 (0)