From 1608153208e70b4ed5aaa1dd556ad97fb2b8f9fe Mon Sep 17 00:00:00 2001 From: sesky4 Date: Thu, 30 Jul 2026 16:33:37 +0800 Subject: [PATCH] feat: endpoint failover --- README.md | 25 +- pom.xml | 8 +- .../common/AbstractClient.java | 2336 +++++++++-------- .../tencentcloudapi/common/AbstractModel.java | 548 ++-- .../common/CircuitBreaker.java | 11 +- .../tencentcloudapi/common/Credential.java | 392 +-- .../common/EndpointFailoverInterceptor.java | 323 +++ .../common/JsonResponseErrModel.java | 82 +- .../common/JsonResponseModel.java | 54 +- .../common/RequestBuilder.java | 715 +++++ .../common/SSEResponseModel.java | 10 +- .../java/com/tencentcloudapi/common/Sign.java | 324 +-- .../exception/TencentCloudSDKException.java | 154 +- .../common/http/HttpConnection.java | 311 +-- .../common/profile/ClientProfile.java | 457 ++-- .../common/profile/HttpProfile.java | 832 +++--- .../provider/DefaultCredentialsProvider.java | 12 +- .../provider/ProfileCredentialsProvider.java | 4 +- .../common/AbstractClientProxyAuthTest.java | 120 + .../common/CircuitBreakerUnitTest.java | 412 +++ .../common/CommonRequestTest.java | 169 ++ .../EndpointFailoverInterceptorTest.java | 1611 ++++++++++++ .../common/RequestBuilderTest.java | 384 +++ .../common/http/HttpConnectionTest.java | 304 +++ 24 files changed, 6920 insertions(+), 2678 deletions(-) create mode 100644 src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java create mode 100644 src/main/java/com/tencentcloudapi/common/RequestBuilder.java create mode 100644 src/test/java/com/tencentcloudapi/common/AbstractClientProxyAuthTest.java create mode 100644 src/test/java/com/tencentcloudapi/common/CircuitBreakerUnitTest.java create mode 100644 src/test/java/com/tencentcloudapi/common/CommonRequestTest.java create mode 100644 src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java create mode 100644 src/test/java/com/tencentcloudapi/common/RequestBuilderTest.java create mode 100644 src/test/java/com/tencentcloudapi/common/http/HttpConnectionTest.java diff --git a/README.md b/README.md index 1ca77ea333..40054c178c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 欢迎使用腾讯云开发者工具套件(SDK)3.0,SDK3.0是云 API3.0 平台的配套工具。 为方便 JAVA 开发者调试和接入腾讯云产品 API,这里向您介绍适用于 Java 的腾讯云开发工具包,并提供首次使用开发工具包的简单示例。让您快速获取腾讯云 Java SDK 并开始调用。 # 依赖环境 -1. 依赖环境:JDK 7 版本及以上。 +1. 依赖环境:JDK 8 版本及以上。 2. 从 腾讯云控制台 开通相应产品。 3. 获取 SecretID、SecretKey 以及调用地址(endpoint),endpoint 一般形式为\*.tencentcloudapi.com,如CVM 的调用地址为 cvm.tencentcloudapi.com,具体参考各产品说明。 @@ -546,6 +546,29 @@ cpf.getHttpProfile().setHttpClient(...); 请注意,从 3.0.x 版本升级到 3.1.x 版本有兼容性问题,对于 Integer 字段的使用修改为了 Long 类型,需要重新编译项目。 +## OkHttp 升级 + +为规避 OkHttp 3.x 版本存在的安全风险,从版本 3.2.0 起,OkHttp 依赖由 3.12.13 升级至 4.12.0。 + +### 回退至 OkHttp 3.12.13 + +若升级后与项目存在冲突,可在应用 `pom.xml` 中显式声明以下依赖进行回退。依据 Maven “路径最近优先”的依赖仲裁规则,应用声明的版本将覆盖 SDK 传递引入的 4.12.0: + +```xml + + com.squareup.okhttp3 + okhttp + 3.12.13 + + + com.squareup.okhttp3 + logging-interceptor + 3.12.13 + +``` + +> **注意**:回退操作将重新引入旧版 OkHttp 已修复的安全风险,仅可用于故障应急与问题定位,问题排除后应尽快恢复使用 OkHttp 4。 + ## 证书问题 证书问题通常是客户端环境配置错误导致的。SDK 没有对证书进行操作,依赖的是 Java 运行环境本身的处理。出现证书问题后,可以使用`-Djavax.net.debug=ssl`开启详细日志辅助判断。 diff --git a/pom.xml b/pom.xml index fb6ff19d3b..9d8a69c0e0 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ com.squareup.okhttp3 okhttp - 3.12.13 + 4.12.0 com.google.code.gson @@ -27,7 +27,7 @@ com.squareup.okhttp3 logging-interceptor - 3.12.13 + 4.12.0 org.apache.commons @@ -63,8 +63,8 @@ maven-compiler-plugin 2.3.2 - 1.7 - 1.7 + 1.8 + 1.8 UTF-8 diff --git a/src/main/java/com/tencentcloudapi/common/AbstractClient.java b/src/main/java/com/tencentcloudapi/common/AbstractClient.java index 3f2e544278..83ba77be6e 100644 --- a/src/main/java/com/tencentcloudapi/common/AbstractClient.java +++ b/src/main/java/com/tencentcloudapi/common/AbstractClient.java @@ -1,1104 +1,1232 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonSyntaxException; -import com.google.gson.reflect.TypeToken; -import com.tencentcloudapi.common.exception.TencentCloudSDKException; -import com.tencentcloudapi.common.http.HttpConnection; -import com.tencentcloudapi.common.profile.ClientProfile; -import com.tencentcloudapi.common.profile.HttpProfile; -import okhttp3.*; -import okhttp3.Headers.Builder; - -import javax.crypto.Mac; -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Type; -import java.net.InetSocketAddress; -import java.net.Proxy; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.sql.Date; -import java.text.SimpleDateFormat; -import java.util.*; - -/** - * AbstractClient provides the basic functionalities for interacting with Tencent Cloud services. - * It handles request signing, sending, and response processing. - */ -public abstract class AbstractClient { - - public static final int HTTP_RSP_OK = 200; - public static final String SDK_VERSION = "SDK_JAVA_3.1.1516"; - public Gson gson; - - // User's security credentials (SecretId, SecretKey, Token). - private Credential credential; - - // Client configuration (e.g., timeout, endpoint). - private ClientProfile profile; - - // API endpoint URL. - private String endpoint; - - // Service name (e.g., "cvm"). - private String service; - - // Region (e.g., "ap-guangzhou"). - private String region; - - // API request path (usually "/"). - private String path; - - // SDK version string. - private String sdkVersion; - - // API version string. - private String apiVersion; - - // Logger for debugging and information. - private TCLog log; - - // Handles HTTP connections. - private HttpConnection httpConnection; - - // Circuit breaker for handling region failures. - private CircuitBreaker regionBreaker; - - /** - * Constructor for AbstractClient with default client profile. - * - * @param endpoint API endpoint URL. - * @param version API version. - * @param credential User credentials. - * @param region Region. - */ - public AbstractClient(String endpoint, String version, Credential credential, String region) { - this(endpoint, version, credential, region, new ClientProfile()); - } - - /** - * Constructor for AbstractClient with a custom client profile. - * - * @param endpoint API endpoint URL. - * @param version API version. - * @param credential User credentials. - * @param region Region. - * @param profile Client configuration profile. - */ - public AbstractClient( - String endpoint, - String version, - Credential credential, - String region, - ClientProfile profile) { - this.credential = credential; - this.profile = profile; - this.endpoint = endpoint; - this.service = endpoint.split("\\.")[0]; - this.region = region; - this.path = "/"; - this.sdkVersion = AbstractClient.SDK_VERSION; - this.apiVersion = version; - this.gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); - this.log = new TCLog(getClass().getName(), profile.isDebug()); - this.httpConnection = new HttpConnection( - this.profile.getHttpProfile().getConnTimeout(), - this.profile.getHttpProfile().getReadTimeout(), - this.profile.getHttpProfile().getWriteTimeout() - ); - this.httpConnection.addInterceptors(this.log); - this.trySetProxy(this.httpConnection); - this.trySetSSLSocketFactory(this.httpConnection); - this.trySetRegionBreaker(); - this.trySetHostnameVerifier(this.httpConnection); - this.trySetHttpClient(); - warmup(); - } - - /** - * Gets the region. - * - * @return The region. - */ - public String getRegion() { - return this.region; - } - - /** - * Sets the region. - * - * @param region The region to set. - */ - public void setRegion(String region) { - this.region = region; - } - - /** - * Gets the client profile. - * - * @return The client profile. - */ - public ClientProfile getClientProfile() { - return this.profile; - } - - /** - * Sets the client profile. - * - * @param profile The client profile to set. - */ - public void setClientProfile(ClientProfile profile) { - this.profile = profile; - } - - /** - * Gets the credential. - * - * @return The credential. - */ - public Credential getCredential() { - return this.credential; - } - - /** - * Sets the credential. - * - * @param credential The credential to set. - */ - public void setCredential(Credential credential) { - this.credential = credential; - } - - /** - * Calls an API action with JSON payload using the TC3-HMAC-SHA256 signature. - * Ignores the request method and signature method defined in the profile. - * - * @param action Name of the API action. - * @param jsonPayload JSON string containing the request parameters. - * @return Raw response from the API. - * @throws TencentCloudSDKException If an error occurs during the API call. - */ - public String call(String action, String jsonPayload) throws TencentCloudSDKException { - Credential credSnapshot = this.credential.getSnapshot(); - HashMap headers = this.getHeaders(credSnapshot); - headers.put("X-TC-Action", action); - headers.put("Content-Type", "application/json; charset=utf-8"); - byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8); - String authorization = this.getAuthorization(headers, requestPayload, credSnapshot); - headers.put("Authorization", authorization); - String url = this.profile.getHttpProfile().getProtocol() + this.getEndpoint() + this.path; - return this.getResponseBody(url, headers, requestPayload); - } - - /** - * Calls an API action with binary payload using the TC3-HMAC-SHA256 signature. - * Ignores the request method and signature method defined in the profile. - * - * @param action Name of the API action. - * @param headers HTTP headers to include in the request. - * @param body Binary payload (octet-stream). - * @return Raw response from the API. - * @throws TencentCloudSDKException If an error occurs during the API call. - */ - public String callOctetStream(String action, HashMap headers, byte[] body) - throws TencentCloudSDKException { - Credential credSnapshot = this.credential.getSnapshot(); - headers.putAll(this.getHeaders(credSnapshot)); - headers.put("X-TC-Action", action); - headers.put("Content-Type", "application/octet-stream; charset=utf-8"); - String authorization = this.getAuthorization(headers, body, credSnapshot); - headers.put("Authorization", authorization); - String url = this.profile.getHttpProfile().getProtocol() + this.getEndpoint() + this.path; - return this.getResponseBody(url, headers, body); - } - - /** - * Generates common HTTP headers for Tencent Cloud API requests. - * - * @return A HashMap containing the headers. - */ - private HashMap getHeaders(Credential credSnapshot) { - HashMap headers = new HashMap(); - String timestamp = String.valueOf(System.currentTimeMillis() / 1000); - headers.put("X-TC-Timestamp", timestamp); - headers.put("X-TC-Version", this.apiVersion); - headers.put("X-TC-Region", this.getRegion()); - headers.put("X-TC-RequestClient", SDK_VERSION); - headers.put("Host", this.getEndpoint()); - String token = credSnapshot.getToken(); - if (token != null && !token.isEmpty()) { - headers.put("X-TC-Token", token); - } - if (this.profile.isUnsignedPayload()) { - headers.put("X-TC-Content-SHA256", "UNSIGNED-PAYLOAD"); - } - if (null != this.profile.getLanguage()) { - headers.put("X-TC-Language", this.profile.getLanguage().getValue()); - } - return headers; - } - - /** - * Generates the authorization header for TC3-HMAC-SHA256 signature. - * - * @param headers HTTP headers. - * @param body Request payload. - * @return The authorization header string. - * @throws TencentCloudSDKException If an error occurs during signature generation. - */ - private String getAuthorization(HashMap headers, byte[] body, Credential credSnapshot) - throws TencentCloudSDKException { - String endpoint = this.getEndpoint(); - // always use post tc3-hmac-sha256 signature process - // okhttp always set charset even we don't specify it, - // to ensure signature be correct, we have to set it here as well. - String contentType = headers.get("Content-Type"); - byte[] requestPayload = body; - String canonicalUri = "/"; - String canonicalQueryString = ""; - String canonicalHeaders = "content-type:" + contentType + "\nhost:" + endpoint + "\n"; - String signedHeaders = "content-type;host"; - - String hashedRequestPayload = ""; - if (this.profile.isUnsignedPayload()) { - hashedRequestPayload = Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8)); - } else { - hashedRequestPayload = Sign.sha256Hex(requestPayload); - } - String canonicalRequest = - HttpProfile.REQ_POST - + "\n" - + canonicalUri - + "\n" - + canonicalQueryString - + "\n" - + canonicalHeaders - + "\n" - + signedHeaders - + "\n" - + hashedRequestPayload; - - String timestamp = headers.get("X-TC-Timestamp"); - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); - sdf.setTimeZone(TimeZone.getTimeZone("UTC")); - String date = sdf.format(new Date(Long.valueOf(timestamp + "000"))); - String service = endpoint.split("\\.")[0]; - String credentialScope = date + "/" + service + "/" + "tc3_request"; - String hashedCanonicalRequest = - Sign.sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8)); - String stringToSign = - "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; - - String secretId = credSnapshot.getSecretId(); - String secretKey = credSnapshot.getSecretKey(); - byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); - byte[] secretService = Sign.hmac256(secretDate, service); - byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); - String signature = - DatatypeConverter.printHexBinary(Sign.hmac256(secretSigning, stringToSign)).toLowerCase(); - return "TC3-HMAC-SHA256 " - + "Credential=" - + secretId - + "/" - + credentialScope - + ", " - + "SignedHeaders=" - + signedHeaders - + ", " - + "Signature=" - + signature; - } - - /** - * Sends the HTTP request and retrieves the response body. - * - * @param url The request URL. - * @param headers HTTP headers. - * @param body Request payload. - * @return The response body as a string. - * @throws TencentCloudSDKException If an error occurs during the request or response processing. - */ - private String getResponseBody(String url, HashMap headers, byte[] body) - throws TencentCloudSDKException { - Builder hb = new Headers.Builder(); - for (String key : headers.keySet()) { - hb.add(key, headers.get(key)); - } - Response resp = null; - try { - resp = this.httpConnection.postRequest(url, body, hb.build()); - } catch (IOException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage(), e); - } - if (resp.code() != AbstractClient.HTTP_RSP_OK) { - String msg = "response code is " + resp.code() + ", not 200"; - log.info(msg); - throw new TencentCloudSDKException(msg, "", "ServerSideError"); - } - String respbody = null; - try { - respbody = resp.body().string(); - } catch (IOException e) { - String msg = - "Cannot transfer response body to string, because Content-Length is too large, or Content-Length " + - "and stream length disagree."; - log.info(msg); - throw new TencentCloudSDKException(msg, e); - } - JsonResponseModel errResp = null; - try { - Type errType = new TypeToken>() { - }.getType(); - errResp = gson.fromJson(respbody, errType); - } catch (JsonSyntaxException e) { - String msg = "json is not a valid representation for an object of type"; - log.info(msg); - throw new TencentCloudSDKException(msg, e); - } - if (errResp.response.error != null) { - throw new TencentCloudSDKException( - errResp.response.error.message, errResp.response.requestId, errResp.response.error.code); - } - return respbody; - } - - private void trySetProxy(HttpConnection conn) { - String host = this.profile.getHttpProfile().getProxyHost(); - int port = this.profile.getHttpProfile().getProxyPort(); - - if (host == null || host.isEmpty()) { - return; - } - Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); - conn.setProxy(proxy); - - final String username = this.profile.getHttpProfile().getProxyUsername(); - final String password = this.profile.getHttpProfile().getProxyPassword(); - if (username == null || username.isEmpty()) { - return; - } - conn.setProxyAuthenticator( - new Authenticator() { - @Override - public Request authenticate(Route route, Response response) throws IOException { - String credential = Credentials.basic(username, password); - return response - .request() - .newBuilder() - .header("Proxy-Authorization", credential) - .build(); - } - }); - } - - private void trySetSSLSocketFactory(HttpConnection conn) { - SSLSocketFactory sslSocketFactory = this.profile.getHttpProfile().getSslSocketFactory(); - X509TrustManager trustManager = this.profile.getHttpProfile().getX509TrustManager(); - if (sslSocketFactory != null) { - if (trustManager != null) { - this.httpConnection.setSSLSocketFactory(sslSocketFactory, trustManager); - } else { - this.httpConnection.setSSLSocketFactory(sslSocketFactory); - } - } - } - - private void trySetHostnameVerifier(HttpConnection conn) { - HostnameVerifier hostnameVerifier = this.profile.getHttpProfile().getHostnameVerifier(); - if (hostnameVerifier != null) { - this.httpConnection.setHostnameVerifier(hostnameVerifier); - } - } - - private void trySetRegionBreaker() { - String ep = profile.getBackupEndpoint(); - if (ep != null && !ep.isEmpty()) { - this.regionBreaker = new CircuitBreaker(); - } - } - - private void trySetHttpClient() { - Object httpClient = profile.getHttpProfile().getHttpClient(); - if (httpClient != null) { - this.httpConnection.setHttpClient(httpClient); - } - } - - /** - * Executes an API request and returns the raw string response. - * Handles circuit breaking for region failover. - * - * @param request The request object containing API parameters. - * @param actionName The name of the API action to be called. - * @return The raw string response from the API. - * @throws TencentCloudSDKException If an error occurs during the API call. - */ - protected String internalRequest(AbstractModel request, String actionName) - throws TencentCloudSDKException { - - CircuitBreaker.Token breakerToken = null; - // Attempt to acquire a token from the circuit breaker. - // If the circuit is open, use the backup endpoint. - if (regionBreaker != null) { - breakerToken = regionBreaker.allow(); - if (!breakerToken.allowed) { - endpoint = service + "." + profile.getBackupEndpoint(); - } - } - - Response okRsp; - try { - // Execute the raw API request. - okRsp = internalRequestRaw(request, actionName); - } catch (IOException e) { - // Network failure: report to circuit breaker and throw exception. - if (breakerToken != null) { - breakerToken.report(false); - } - throw new TencentCloudSDKException("", e); - } - - String strResp; - try { - // Extract the response body as a string. - strResp = okRsp.body().string(); - } catch (IOException e) { - String msg = "Cannot transfer response body to string, because Content-Length is too large, or " + - "Content-Length and stream length disagree."; - log.info(msg); - throw new TencentCloudSDKException(msg, e); - } - - JsonResponseModel errResp; - try { - // Deserialize the response to check for errors. - Type errType = new TypeToken>() { - }.getType(); - errResp = gson.fromJson(strResp, errType); - } catch (JsonSyntaxException e) { - // Invalid JSON response: log and throw exception. - String msg = "json is not a valid representation for an object of type"; - log.info(msg); - throw new TencentCloudSDKException(msg, e); - } - - // Check for API errors in the response. - if (errResp.response.error != null) { - if (breakerToken != null) { - // Report the success/failure of the request to the circuit breaker. - JsonResponseErrModel error = errResp.response; - // Consider a region "OK" if we get a valid requestId and no InternalError. - boolean regionOk = error.requestId != null - && !error.requestId.isEmpty() - && error.error.code != null - && !error.error.code.equals("InternalError"); - breakerToken.report(regionOk); - } - throw new TencentCloudSDKException( - errResp.response.error.message, - errResp.response.requestId, - errResp.response.error.code); - } - - return strResp; - } - - /** - * Executes an API request and returns the deserialized response object. - * Handles circuit breaking for region failover. - * - * @param request The request object containing API parameters. - * @param actionName The name of the API action to be called. - * @param typeOfT The class of the response object to deserialize to. - * @param The type of the response object. - * @return The deserialized response object. - * @throws TencentCloudSDKException If an error occurs during the API call. - */ - protected T internalRequest(AbstractModel request, String actionName, Class typeOfT) - throws TencentCloudSDKException { - CircuitBreaker.Token breakerToken = null; - // Attempt to acquire a token from the circuit breaker. - // If the circuit is open, use the backup endpoint. - if (regionBreaker != null) { - breakerToken = regionBreaker.allow(); - if (!breakerToken.allowed) { - endpoint = service + "." + profile.getBackupEndpoint(); - } - } - - try { - Response resp = internalRequestRaw(request, actionName); - if (Objects.equals(resp.header("Content-Type"), "text/event-stream")) { - return processResponseSSE(resp, typeOfT, breakerToken); - } - return processResponseJson(resp, typeOfT, breakerToken); - } catch (IOException e) { - // Network failure: report to circuit breaker and throw exception. - if (breakerToken != null) { - breakerToken.report(false); - } - throw new TencentCloudSDKException("", e); - } - } - - /** - * Processes a Server-Sent Events (SSE) response. - * - * @param resp The raw HTTP response. - * @param typeOfT The class of the response model. - * @param breakerToken The circuit breaker token. - * @param The type of the response model. - * @return The SSE response model. - * @throws TencentCloudSDKException If an error occurs during processing. - */ - protected T processResponseSSE(Response resp, Class typeOfT, CircuitBreaker.Token breakerToken) throws TencentCloudSDKException { - SSEResponseModel responseModel; - try { - // Create a new instance of the response model. - responseModel = (SSEResponseModel) typeOfT.newInstance(); - } catch (InstantiationException | IllegalAccessException e) { - throw new TencentCloudSDKException("", e); - } - // Set request ID and circuit breaker token in the response model. - responseModel.setRequestId(resp.header("X-TC-RequestId")); - responseModel.setToken(breakerToken); - responseModel.setResponse(resp); - return (T) responseModel; - } - - /** - * Processes a JSON response. - * - * @param resp The raw HTTP response. - * @param typeOfT The class of the response object to deserialize to. - * @param breakerToken The circuit breaker token. - * @param The type of the response object. - * @return The deserialized response object. - * @throws TencentCloudSDKException If an error occurs during processing. - */ - protected T processResponseJson(Response resp, Class typeOfT, CircuitBreaker.Token breakerToken) throws TencentCloudSDKException { - String body; - try { - body = resp.body().string(); - } catch (IOException e) { - String msg = "Cannot transfer response body to string, because Content-Length is too large, or " + - "Content-Length and stream length disagree."; - log.info(msg); - throw new TencentCloudSDKException(msg, e); - } - - JsonResponseModel errResp; - try { - Type errType = new TypeToken>() { - }.getType(); - errResp = gson.fromJson(body, errType); - } catch (JsonSyntaxException e) { - String msg = "json is not a valid representation for an object of type"; - log.info(msg); - throw new TencentCloudSDKException(msg, e); - } - - // Check for API errors in the response. - if (errResp.response.error != null) { - if (breakerToken != null) { - // Report the success/failure of the request to the circuit breaker. - JsonResponseErrModel error = errResp.response; - // Consider a region "OK" if we get a valid requestId and no InternalError. - boolean regionOk = error.requestId != null - && !error.requestId.isEmpty() - && error.error.code != null - && !error.error.code.equals("InternalError"); - breakerToken.report(regionOk); - } - throw new TencentCloudSDKException( - errResp.response.error.message, - errResp.response.requestId, - errResp.response.error.code); - } - - // Deserialize the successful response into the desired object type. - Type type = TypeToken.getParameterized(JsonResponseModel.class, typeOfT).getType(); - return ((JsonResponseModel) gson.fromJson(body, type)).response; - } - - /** - * Executes the raw API request and returns the HTTP Response object. - * - * @param request The request object containing API parameters. - * @param actionName The name of the API action to be called. - * @return The raw HTTP Response object. - * @throws TencentCloudSDKException If an error occurs during the API call. - * @throws IOException If an I/O error occurs. - */ - protected Response internalRequestRaw(AbstractModel request, String actionName) - throws TencentCloudSDKException, IOException { - Response okRsp = null; - String endpoint = this.getEndpoint(); - String[] binaryParams = request.getBinaryParams(); - String sm = this.profile.getSignMethod(); - String reqMethod = this.profile.getHttpProfile().getReqMethod(); - - // currently, customized params only can be supported via post json tc3-hmac-sha256 - HashMap customizedParams = request.any(); - if (customizedParams.size() > 0) { - if (binaryParams.length > 0) { - throw new TencentCloudSDKException( - "WrongUsage: Cannot post multipart with customized parameters."); - } - if (sm.equals(ClientProfile.SIGN_SHA1) || sm.equals(ClientProfile.SIGN_SHA256)) { - throw new TencentCloudSDKException( - "WrongUsage: Cannot use HmacSHA1 or HmacSHA256 with customized parameters."); - } - if (reqMethod.equals(HttpProfile.REQ_GET)) { - throw new TencentCloudSDKException( - "WrongUsage: Cannot use get method with customized parameters."); - } - } - - - if (binaryParams.length > 0 || sm.equals(ClientProfile.SIGN_TC3_256)) { - okRsp = doRequestWithTC3(endpoint, request, actionName); - } else if (sm.equals(ClientProfile.SIGN_SHA1) || sm.equals(ClientProfile.SIGN_SHA256)) { - okRsp = doRequest(endpoint, request, actionName); - } else { - throw new TencentCloudSDKException( - "Signature method " + sm + " is invalid or not supported yet."); - } - - // Check the HTTP response code. - if (okRsp.code() != AbstractClient.HTTP_RSP_OK) { - String msg = "response code is " + okRsp.code() + ", not 200"; - log.info(msg); - throw new TencentCloudSDKException(msg, "", "ServerSideError"); - } - return okRsp; - } - - /** - * Executes an API request using the older signature methods (HmacSHA1 or HmacSHA256). - * - * @param endpoint The API endpoint. - * @param request The request object. - * @param action The API action name. - * @return The HTTP Response object. - * @throws TencentCloudSDKException If an error occurs. - * @throws IOException If an I/O error occurs. - */ - private Response doRequest(String endpoint, AbstractModel request, String action) - throws TencentCloudSDKException, IOException { - HashMap param = new HashMap(); - request.toMap(param, ""); - String strParam = this.formatRequestData(action, param); - String reqMethod = this.profile.getHttpProfile().getReqMethod(); - String protocol = this.profile.getHttpProfile().getProtocol(); - String url = protocol + endpoint + this.path; - String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); - if (null != apigwEndpoint) { - url = protocol + apigwEndpoint; - } - if (reqMethod.equals(HttpProfile.REQ_GET)) { - return this.httpConnection.getRequest(url + "?" + strParam); - } else if (reqMethod.equals(HttpProfile.REQ_POST)) { - return this.httpConnection.postRequest(url, strParam); - } else { - throw new TencentCloudSDKException("Method only support (GET, POST)"); - } - } - - /** - * Executes an API request using the TC3-HMAC-SHA256 signature method. - * - * @param endpoint The API endpoint. - * @param request The request object. - * @param action The API action name. - * @return The HTTP Response object. - * @throws TencentCloudSDKException If an error occurs. - * @throws IOException If an I/O error occurs. - */ - private Response doRequestWithTC3(String endpoint, AbstractModel request, String action) - throws TencentCloudSDKException, IOException { - Credential credSnapshot = this.credential.getSnapshot(); - String httpRequestMethod = this.profile.getHttpProfile().getReqMethod(); - if (httpRequestMethod == null) { - throw new TencentCloudSDKException( - "Request method should not be null, can only be GET or POST"); - } - String contentType = "application/x-www-form-urlencoded"; - byte[] requestPayload = "".getBytes(StandardCharsets.UTF_8); - HashMap params = new HashMap(); - request.toMap(params, ""); - String[] binaryParams = request.getBinaryParams(); - if (binaryParams.length > 0) { - httpRequestMethod = HttpProfile.REQ_POST; - String boundary = UUID.randomUUID().toString(); - // okhttp always set charset even we don't specify it, - // to ensure signature be correct, we have to set it here as well. - contentType = "multipart/form-data; charset=utf-8" + "; boundary=" + boundary; - try { - requestPayload = getMultipartPayload(request, boundary); - } catch (Exception e) { - throw new TencentCloudSDKException("Failed to generate multipart.", e); - } - } else if (httpRequestMethod.equals(HttpProfile.REQ_POST)) { - requestPayload = AbstractModel.toJsonString(request).getBytes(StandardCharsets.UTF_8); - // okhttp always set charset even we don't specify it, - // to ensure signature be correct, we have to set it here as well. - contentType = "application/json; charset=utf-8"; - } - // Construct the canonical request for signature calculation. - String canonicalUri = "/"; - String canonicalQueryString = this.getCanonicalQueryString(params, httpRequestMethod); - String canonicalHeaders = "content-type:" + contentType + "\nhost:" + endpoint + "\n"; - String signedHeaders = "content-type;host"; - - String hashedRequestPayload = ""; - if (this.profile.isUnsignedPayload()) { - hashedRequestPayload = Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8)); - } else { - hashedRequestPayload = Sign.sha256Hex(requestPayload); - } - String canonicalRequest = - httpRequestMethod - + "\n" - + canonicalUri - + "\n" - + canonicalQueryString - + "\n" - + canonicalHeaders - + "\n" - + signedHeaders - + "\n" - + hashedRequestPayload; - - String timestamp = String.valueOf(System.currentTimeMillis() / 1000); - SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); - sdf.setTimeZone(TimeZone.getTimeZone("UTC")); - String date = sdf.format(new Date(Long.valueOf(timestamp + "000"))); - String service = endpoint.split("\\.")[0]; - String credentialScope = date + "/" + service + "/" + "tc3_request"; - String hashedCanonicalRequest = - Sign.sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8)); - String stringToSign = - "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; - boolean skipSign = request.getSkipSign(); - String authorization = ""; - if (skipSign) { - authorization = "SKIP"; - } else { - String secretId = credSnapshot.getSecretId(); - String secretKey = credSnapshot.getSecretKey(); - byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); - byte[] secretService = Sign.hmac256(secretDate, service); - byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); - String signature = - DatatypeConverter.printHexBinary(Sign.hmac256(secretSigning, stringToSign)).toLowerCase(); - authorization = - "TC3-HMAC-SHA256 " - + "Credential=" - + secretId - + "/" - + credentialScope - + ", " - + "SignedHeaders=" - + signedHeaders - + ", " - + "Signature=" - + signature; - } - Builder hb = new Headers.Builder(); - hb.add("Content-Type", contentType) - .add("Host", endpoint) - .add("Authorization", authorization) - .add("X-TC-Action", action) - .add("X-TC-Timestamp", timestamp) - .add("X-TC-Version", this.apiVersion) - .add("X-TC-RequestClient", SDK_VERSION); - if (null != request.GetHeader()) { - for (Map.Entry entry : request.GetHeader().entrySet()) { - hb.add(entry.getKey(), entry.getValue()); - } - } - if (null != this.getRegion()) { - hb.add("X-TC-Region", this.getRegion()); - } - String token = credSnapshot.getToken(); - if (token != null && !token.isEmpty()) { - hb.add("X-TC-Token", token); - } - if (this.profile.isUnsignedPayload()) { - hb.add("X-TC-Content-SHA256", "UNSIGNED-PAYLOAD"); - } - if (null != this.profile.getLanguage()) { - hb.add("X-TC-Language", this.profile.getLanguage().getValue()); - } - - String protocol = this.profile.getHttpProfile().getProtocol(); - String url = protocol + endpoint + this.path; - String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); - if (null != apigwEndpoint) { - url = protocol + apigwEndpoint; - hb.set("Host", apigwEndpoint); - } - Headers headers = hb.build(); - if (httpRequestMethod.equals(HttpProfile.REQ_GET)) { - return this.httpConnection.getRequest(url + "?" + canonicalQueryString, headers); - } else if (httpRequestMethod.equals(HttpProfile.REQ_POST)) { - return this.httpConnection.postRequest(url, requestPayload, headers); - } else { - throw new TencentCloudSDKException("Method only support GET, POST"); - } - } - - /** - * Constructs the multipart payload for file uploads. - * - * @param request The request object containing file parameters. - * @param boundary The boundary string to separate parts of the multipart data. - * @return The byte array representing the multipart payload. - * @throws Exception If an error occurs during payload construction. - */ - private byte[] getMultipartPayload(AbstractModel request, String boundary) throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - String[] binaryParams = request.getBinaryParams(); - // Iterate through each parameter in the multipart request. - for (Map.Entry entry : request.getMultipartRequestParams().entrySet()) { - baos.write("--".getBytes(StandardCharsets.UTF_8)); - baos.write(boundary.getBytes(StandardCharsets.UTF_8)); - baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); - baos.write("Content-Disposition: form-data; name=\"".getBytes(StandardCharsets.UTF_8)); - baos.write(entry.getKey().getBytes(StandardCharsets.UTF_8)); - if (Arrays.asList(binaryParams).contains(entry.getKey())) { - baos.write("\"; filename=\"".getBytes(StandardCharsets.UTF_8)); - baos.write(entry.getKey().getBytes(StandardCharsets.UTF_8)); - baos.write("\"\r\n".getBytes(StandardCharsets.UTF_8)); - } else { - baos.write("\"\r\n".getBytes(StandardCharsets.UTF_8)); - } - baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); - baos.write(entry.getValue()); - baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); - } - // Write the closing boundary if there's any data. - if (baos.size() != 0) { - baos.write("--".getBytes(StandardCharsets.UTF_8)); - baos.write(boundary.getBytes(StandardCharsets.UTF_8)); - baos.write("--\r\n".getBytes(StandardCharsets.UTF_8)); - } - byte[] bytes = baos.toByteArray(); - baos.close(); - return bytes; - } - - /** - * Generates the canonical query string for GET requests. - * - * @param params The map of request parameters. - * @param method The HTTP method (should be GET). - * @return The canonical query string. - * @throws TencentCloudSDKException If UTF-8 encoding is not supported. - */ - private String getCanonicalQueryString(HashMap params, String method) - throws TencentCloudSDKException { - // POST requests don't have a query string in the signature. - if (method != null && method.equals(HttpProfile.REQ_POST)) { - return ""; - } - StringBuilder queryString = new StringBuilder(""); - // Iterate through each parameter and build the query string. - for (Map.Entry entry : params.entrySet()) { - String v; - try { - v = URLEncoder.encode(entry.getValue(), "UTF8"); - } catch (UnsupportedEncodingException e) { - throw new TencentCloudSDKException("UTF8 is not supported.", e); - } - queryString.append("&").append(entry.getKey()).append("=").append(v); - } - // Remove the leading '&' if the query string is not empty. - if (queryString.length() == 0) { - return ""; - } else { - return queryString.toString().substring(1); - } - } - - /** - * Formats the request data for signing (older signature methods). - * - * @param action The API action name. - * @param param The map of request parameters. - * @return The formatted string for signing. - * @throws TencentCloudSDKException If UTF-8 encoding is not supported. - */ - private String formatRequestData(String action, Map param) - throws TencentCloudSDKException { - Credential credSnapshot = this.credential.getSnapshot(); - String secretId = credSnapshot.getSecretId(); - String secretKey = credSnapshot.getSecretKey(); - String token = credSnapshot.getToken(); - param.put("Action", action); - param.put("RequestClient", this.sdkVersion); - param.put("Nonce", String.valueOf(Math.abs(new SecureRandom().nextInt()))); - param.put("Timestamp", String.valueOf(System.currentTimeMillis() / 1000)); - param.put("Version", this.apiVersion); - - // Add SecretId, Region, SignatureMethod, and Token if available. - if (secretId != null && (!secretId.isEmpty())) { - param.put("SecretId", secretId); - } - - if (this.region != null && (!this.region.isEmpty())) { - param.put("Region", this.region); - } - - if (this.profile.getSignMethod() != null && (!this.profile.getSignMethod().isEmpty())) { - param.put("SignatureMethod", this.profile.getSignMethod()); - } - - if (token != null && (!token.isEmpty())) { - param.put("Token", token); - } - - if (null != this.profile.getLanguage()) { - param.put("Language", this.profile.getLanguage().getValue()); - } - - String endpoint = this.getEndpoint(); - - // Generate the string to be signed. - String sigInParam = - Sign.makeSignPlainText( - new TreeMap(param), - this.profile.getHttpProfile().getReqMethod(), - endpoint, - this.path); - // Generate the signature. - String sigOutParam = - Sign.sign(secretKey, sigInParam, this.profile.getSignMethod()); - - String strParam = ""; - try { - // URL-encode each parameter and construct the query string. - for (Map.Entry entry : param.entrySet()) { - strParam += - (URLEncoder.encode(entry.getKey(), "utf-8") - + "=" - + URLEncoder.encode(entry.getValue(), "utf-8") - + "&"); - } - strParam += ("Signature=" + URLEncoder.encode(sigOutParam, "utf-8")); - } catch (UnsupportedEncodingException e) { - throw new TencentCloudSDKException("", e); - } - return strParam; - } - - /** - * Performs initializations to avoid performance costs in the first request. - */ - private void warmup() { - try { - // Initialize Mac instances (used for signature calculation). - // First invoke costs around 250 ms. - Mac.getInstance("HmacSHA1"); - Mac.getInstance("HmacSHA256"); - // Initialize SSLContext (used for HTTPS connections). - // First invoke costs around 150 ms. - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, null, null); - } catch (Exception e) { - // Ignore but print the stack trace to the console for debugging. - e.printStackTrace(); - } - } - - /** - * Gets the API endpoint. - * - * @return The API endpoint URL. - */ - private String getEndpoint() { - // Use the endpoint from the profile if it's set, otherwise construct it from service and domain. - if (null != this.profile.getHttpProfile().getEndpoint()) { - return this.profile.getHttpProfile().getEndpoint(); - } else { - // protected abstract String getService(); - // use this.getService() from overrided subclass will be better - return this.service + "." + this.profile.getHttpProfile().getRootDomain(); - } - } - - /** - * 请注意购买类接口谨慎调用,可能导致多次购买 - * 仅幂等接口推荐使用 - * - * @param req - * @param retryTimes - * @throws TencentCloudSDKException - */ - public Object retry(AbstractModel req, int retryTimes) throws TencentCloudSDKException { - if (retryTimes < 0 || retryTimes > 10) { - throw new TencentCloudSDKException("The number of retryTimes supported is 0 to 10.", "", "ClientSideError"); - } - Class cls = this.getClass(); - String methodName = req.getClass().getSimpleName().replace("Request", ""); - Method method; - try { - method = cls.getMethod(methodName, req.getClass()); - } catch (NoSuchMethodException e) { - throw new TencentCloudSDKException("ClientSideError", e); - } - do { - try { - return method.invoke(this, req); - } catch (IllegalAccessException e) { - throw new TencentCloudSDKException("ClientSideError", e); - } catch (InvocationTargetException e) { - if (retryTimes == 0) { - throw (TencentCloudSDKException) e.getTargetException(); - } - } - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new TencentCloudSDKException("ClientSideError", e); - } - } while (--retryTimes >= 0); - return null; - } - - public CircuitBreaker getRegionBreaker() { - return regionBreaker; - } - - public void setRegionBreaker(CircuitBreaker regionBreaker) { - this.regionBreaker = regionBreaker; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.http.HttpConnection; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import okhttp3.*; +import okhttp3.Headers.Builder; + +import javax.crypto.Mac; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.sql.Date; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * AbstractClient provides the basic functionalities for interacting with Tencent Cloud services. + * It handles request signing, sending, and response processing. + */ +public abstract class AbstractClient { + + public static final int HTTP_RSP_OK = 200; + public static final String SDK_VERSION = "SDK_JAVA_3.1.1354"; + public Gson gson; + + // User's security credentials (SecretId, SecretKey, Token). + private Credential credential; + + // Client configuration (e.g., timeout, endpoint). + private ClientProfile profile; + + // API endpoint URL. + private String endpoint; + + // Service name (e.g., "cvm"). + private String service; + + // Region (e.g., "ap-guangzhou"). + private String region; + + // API request path (usually "/"). + private String path; + + // SDK version string. + private String sdkVersion; + + // API version string. + private String apiVersion; + + // Logger for debugging and information. + private TCLog log; + + // Handles HTTP connections. + private HttpConnection httpConnection; + + /** + * Constructor for AbstractClient with default client profile. + * + * @param endpoint API endpoint URL. + * @param version API version. + * @param credential User credentials. + * @param region Region. + */ + public AbstractClient(String endpoint, String version, Credential credential, String region) { + this(endpoint, version, credential, region, new ClientProfile()); + } + + /** + * Constructor for AbstractClient with a custom client profile. + * + * @param endpoint API endpoint URL. + * @param version API version. + * @param credential User credentials. + * @param region Region. + * @param profile Client configuration profile. + */ + public AbstractClient( + String endpoint, + String version, + Credential credential, + String region, + ClientProfile profile) { + this.credential = credential; + this.profile = profile; + this.endpoint = endpoint; + this.service = endpoint.split("\\.")[0]; + this.region = region; + this.path = "/"; + this.sdkVersion = AbstractClient.SDK_VERSION; + this.apiVersion = version; + this.gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + this.log = new TCLog(getClass().getName(), profile.isDebug()); + this.httpConnection = new HttpConnection( + this.profile.getHttpProfile().getConnTimeout(), + this.profile.getHttpProfile().getReadTimeout(), + this.profile.getHttpProfile().getWriteTimeout() + ); + this.httpConnection.addInterceptors(this.log); + this.trySetProxy(this.httpConnection); + this.trySetSSLSocketFactory(this.httpConnection); + if (this.profile.isEnableDomainFailover()) { + this.httpConnection.addInterceptors(new EndpointFailoverInterceptor(this)); + } + this.trySetHostnameVerifier(this.httpConnection); + this.trySetHttpClient(); + warmup(); + } + + /** + * Gets the region. + * + * @return The region. + */ + public String getRegion() { + return this.region; + } + + /** + * Sets the region. + * + * @param region The region to set. + */ + public void setRegion(String region) { + this.region = region; + } + + /** + * Gets the client profile. + * + * @return The client profile. + */ + public ClientProfile getClientProfile() { + return this.profile; + } + + /** + * Sets the client profile. + * + * @param profile The client profile to set. + */ + public void setClientProfile(ClientProfile profile) { + this.profile = profile; + } + + /** + * Gets the credential. + * + * @return The credential. + */ + public Credential getCredential() { + return this.credential; + } + + /** + * Sets the credential. + * + * @param credential The credential to set. + */ + public void setCredential(Credential credential) { + this.credential = credential; + } + + /** + * Calls an API action with JSON payload using the TC3-HMAC-SHA256 signature. + * Ignores the request method and signature method defined in the profile. + * + * @param action Name of the API action. + * @param jsonPayload JSON string containing the request parameters. + * @return Raw response from the API. + * @throws TencentCloudSDKException If an error occurs during the API call. + */ + public String call(String action, String jsonPayload) throws TencentCloudSDKException { + Credential credSnapshot = this.credential.getSnapshot(); + byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8); + String endpoint = this.getEndpoint(); + String protocol = this.profile.getHttpProfile().getProtocol(); + String url = protocol + endpoint + this.path; + String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); + if (null != apigwEndpoint) { + url = protocol + apigwEndpoint; + } + Headers.Builder callerHeaders = new Headers.Builder(); + RequestBuilder rb = RequestBuilder.create() + .fromClient(this) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withURL(HttpUrl.parse(url)) + .withHost(null != apigwEndpoint ? apigwEndpoint : endpoint) + .withMethod(HttpProfile.REQ_POST) + .withContentTypeJson() + .withPayload(requestPayload) + .withHeaders(callerHeaders.build()) + .withAction(action) + .withVersion(this.apiVersion) + .withRegion(this.region) + .withRequestClient(SDK_VERSION); + Response resp; + try { + resp = this.httpConnection.doRequest(rb.build()); + } catch (IOException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage(), e); + } + return parseResponseBody(resp); + } + + /** + * Calls an API action with binary payload using the TC3-HMAC-SHA256 signature. + * Ignores the request method and signature method defined in the profile. + * + * @param action Name of the API action. + * @param headers HTTP headers to include in the request. + * @param body Binary payload (octet-stream). + * @return Raw response from the API. + * @throws TencentCloudSDKException If an error occurs during the API call. + */ + public String callOctetStream(String action, HashMap headers, byte[] body) + throws TencentCloudSDKException { + String endpoint = this.getEndpoint(); + String protocol = this.profile.getHttpProfile().getProtocol(); + String url = protocol + endpoint + this.path; + String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); + if (null != apigwEndpoint) { + url = protocol + apigwEndpoint; + } + Headers.Builder callerHeaders = new Headers.Builder(); + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + callerHeaders.add(entry.getKey(), entry.getValue()); + } + } + RequestBuilder rb = RequestBuilder.create() + .fromClient(this) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withURL(HttpUrl.parse(url)) + .withHost(null != apigwEndpoint ? apigwEndpoint : endpoint) + .withMethod(HttpProfile.REQ_POST) + .withContentTypeOctetStream() + .withPayload(body) + .withHeaders(callerHeaders.build()) + .withAction(action) + .withVersion(this.apiVersion) + .withRegion(this.region) + .withRequestClient(SDK_VERSION); + Response resp; + try { + resp = this.httpConnection.doRequest(rb.build()); + } catch (IOException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage(), e); + } + return parseResponseBody(resp); + } + + /** + * Generates common HTTP headers for Tencent Cloud API requests. + * + * @param credSnapshot Atomic snapshot of the credential to read from. + * @return A HashMap containing the headers. + */ + private HashMap getHeaders(Credential credSnapshot) { + HashMap headers = new HashMap(); + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + headers.put("X-TC-Timestamp", timestamp); + headers.put("X-TC-Version", this.apiVersion); + headers.put("X-TC-Region", this.getRegion()); + headers.put("X-TC-RequestClient", SDK_VERSION); + headers.put("Host", this.getEndpoint()); + String token = credSnapshot.getToken(); + if (token != null && !token.isEmpty()) { + headers.put("X-TC-Token", token); + } + if (this.profile.isUnsignedPayload()) { + headers.put("X-TC-Content-SHA256", "UNSIGNED-PAYLOAD"); + } + if (null != this.profile.getLanguage()) { + headers.put("X-TC-Language", this.profile.getLanguage().getValue()); + } + return headers; + } + + /** + * Generates the authorization header for TC3-HMAC-SHA256 signature. + * + * @param headers HTTP headers. + * @param body Request payload. + * @return The authorization header string. + * @throws TencentCloudSDKException If an error occurs during signature generation. + */ + private String getAuthorization(HashMap headers, byte[] body, Credential credSnapshot) + throws TencentCloudSDKException { + String endpoint = this.getEndpoint(); + // always use post tc3-hmac-sha256 signature process + // okhttp always set charset even we don't specify it, + // to ensure signature be correct, we have to set it here as well. + String contentType = headers.get("Content-Type"); + byte[] requestPayload = body; + String canonicalUri = "/"; + String canonicalQueryString = ""; + String canonicalHeaders = "content-type:" + contentType + "\nhost:" + endpoint + "\n"; + String signedHeaders = "content-type;host"; + + String hashedRequestPayload = ""; + if (this.profile.isUnsignedPayload()) { + hashedRequestPayload = Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8)); + } else { + hashedRequestPayload = Sign.sha256Hex(requestPayload); + } + String canonicalRequest = + HttpProfile.REQ_POST + + "\n" + + canonicalUri + + "\n" + + canonicalQueryString + + "\n" + + canonicalHeaders + + "\n" + + signedHeaders + + "\n" + + hashedRequestPayload; + + String timestamp = headers.get("X-TC-Timestamp"); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + String date = sdf.format(new Date(Long.valueOf(timestamp + "000"))); + String service = endpoint.split("\\.")[0]; + String credentialScope = date + "/" + service + "/" + "tc3_request"; + String hashedCanonicalRequest = + Sign.sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8)); + String stringToSign = + "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; + + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); + byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); + byte[] secretService = Sign.hmac256(secretDate, service); + byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); + String signature = + DatatypeConverter.printHexBinary(Sign.hmac256(secretSigning, stringToSign)).toLowerCase(); + return "TC3-HMAC-SHA256 " + + "Credential=" + + secretId + + "/" + + credentialScope + + ", " + + "SignedHeaders=" + + signedHeaders + + ", " + + "Signature=" + + signature; + } + + /** + * Sends the HTTP request and retrieves the response body. + * + * @param url The request URL. + * @param headers HTTP headers. + * @param body Request payload. + * @return The response body as a string. + * @throws TencentCloudSDKException If an error occurs during the request or response processing. + */ + private String getResponseBody(String url, HashMap headers, byte[] body) + throws TencentCloudSDKException { + Builder hb = new Headers.Builder(); + for (String key : headers.keySet()) { + hb.add(key, headers.get(key)); + } + Response resp = null; + try { + resp = this.httpConnection.postRequest(url, body, hb.build()); + } catch (IOException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage(), e); + } + if (resp.code() != AbstractClient.HTTP_RSP_OK) { + String msg = "response code is " + resp.code() + ", not 200"; + log.info(msg); + throw new TencentCloudSDKException(msg, "", "ServerSideError"); + } + String respbody = null; + try { + respbody = resp.body().string(); + } catch (IOException e) { + String msg = + "Cannot transfer response body to string, because Content-Length is too large, or Content-Length " + + "and stream length disagree."; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + JsonResponseModel errResp = null; + try { + Type errType = new TypeToken>() { + }.getType(); + errResp = gson.fromJson(respbody, errType); + } catch (JsonSyntaxException e) { + String msg = "json is not a valid representation for an object of type"; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + if (errResp.response.error != null) { + throw new TencentCloudSDKException( + errResp.response.error.message, errResp.response.requestId, errResp.response.error.code); + } + return respbody; + } + + private String parseResponseBody(Response resp) throws TencentCloudSDKException { + if (resp.code() != AbstractClient.HTTP_RSP_OK) { + String msg = "response code is " + resp.code() + ", not 200"; + log.info(msg); + throw new TencentCloudSDKException(msg, "", "ServerSideError"); + } + String respbody = null; + try { + respbody = resp.body().string(); + } catch (IOException e) { + String msg = + "Cannot transfer response body to string, because Content-Length is too large, or Content-Length " + + "and stream length disagree."; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + JsonResponseModel errResp = null; + try { + Type errType = new TypeToken>() { + }.getType(); + errResp = gson.fromJson(respbody, errType); + } catch (JsonSyntaxException e) { + String msg = "json is not a valid representation for an object of type"; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + if (errResp.response.error != null) { + throw new TencentCloudSDKException( + errResp.response.error.message, errResp.response.requestId, errResp.response.error.code); + } + return respbody; + } + + private void trySetProxy(HttpConnection conn) { + String host = this.profile.getHttpProfile().getProxyHost(); + int port = this.profile.getHttpProfile().getProxyPort(); + + if (host == null || host.isEmpty()) { + return; + } + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port)); + conn.setProxy(proxy); + + final String username = this.profile.getHttpProfile().getProxyUsername(); + String configuredPassword = this.profile.getHttpProfile().getProxyPassword(); + final String password = configuredPassword == null ? "" : configuredPassword; + if (username == null || username.isEmpty()) { + return; + } + conn.setProxyAuthenticator( + new Authenticator() { + @Override + public Request authenticate(Route route, Response response) throws IOException { + String credential = Credentials.basic(username, password); + return response + .request() + .newBuilder() + .header("Proxy-Authorization", credential) + .build(); + } + }); + } + + private void trySetSSLSocketFactory(HttpConnection conn) { + SSLSocketFactory sslSocketFactory = this.profile.getHttpProfile().getSslSocketFactory(); + X509TrustManager trustManager = this.profile.getHttpProfile().getX509TrustManager(); + if (sslSocketFactory != null) { + if (trustManager != null) { + this.httpConnection.setSSLSocketFactory(sslSocketFactory, trustManager); + } else { + this.httpConnection.setSSLSocketFactory(sslSocketFactory); + } + } + } + + private void trySetHostnameVerifier(HttpConnection conn) { + HostnameVerifier hostnameVerifier = this.profile.getHttpProfile().getHostnameVerifier(); + if (hostnameVerifier != null) { + this.httpConnection.setHostnameVerifier(hostnameVerifier); + } + } + + private void trySetHttpClient() { + Object httpClient = profile.getHttpProfile().getHttpClient(); + if (httpClient != null) { + this.httpConnection.setHttpClient(httpClient); + } + } + + /** + * Executes an API request and returns the raw string response. + * + * @param request The request object containing API parameters. + * @param actionName The name of the API action to be called. + * @return The raw string response from the API. + * @throws TencentCloudSDKException If an error occurs during the API call. + */ + protected String internalRequest(AbstractModel request, String actionName) + throws TencentCloudSDKException { + + Response okRsp; + try { + okRsp = internalRequestRaw(request, actionName); + } catch (IOException e) { + throw new TencentCloudSDKException("", e); + } + + String strResp; + try { + strResp = okRsp.body().string(); + } catch (IOException e) { + String msg = "Cannot transfer response body to string, because Content-Length is too large, or " + + "Content-Length and stream length disagree."; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + + JsonResponseModel errResp; + try { + Type errType = new TypeToken>() { + }.getType(); + errResp = gson.fromJson(strResp, errType); + } catch (JsonSyntaxException e) { + String msg = "json is not a valid representation for an object of type"; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + + if (errResp.response.error != null) { + throw new TencentCloudSDKException( + errResp.response.error.message, + errResp.response.requestId, + errResp.response.error.code); + } + + return strResp; + } + + /** + * Executes an API request and returns the deserialized response object. + * + * @param request The request object containing API parameters. + * @param actionName The name of the API action to be called. + * @param typeOfT The class of the response object to deserialize to. + * @param The type of the response object. + * @return The deserialized response object. + * @throws TencentCloudSDKException If an error occurs during the API call. + */ + protected T internalRequest(AbstractModel request, String actionName, Class typeOfT) + throws TencentCloudSDKException { + try { + Response resp = internalRequestRaw(request, actionName); + if (Objects.equals(resp.header("Content-Type"), "text/event-stream")) { + return processResponseSSE(resp, typeOfT); + } + return processResponseJson(resp, typeOfT); + } catch (IOException e) { + throw new TencentCloudSDKException("", e); + } + } + + /** + * Processes a Server-Sent Events (SSE) response. + * + * @param resp The raw HTTP response. + * @param typeOfT The class of the response model. + * @param The type of the response model. + * @return The SSE response model. + * @throws TencentCloudSDKException If an error occurs during processing. + */ + protected T processResponseSSE(Response resp, Class typeOfT) throws TencentCloudSDKException { + SSEResponseModel responseModel; + try { + responseModel = (SSEResponseModel) typeOfT.newInstance(); + } catch (InstantiationException | IllegalAccessException e) { + throw new TencentCloudSDKException("", e); + } + responseModel.setRequestId(resp.header("X-TC-RequestId")); + responseModel.setResponse(resp); + return (T) responseModel; + } + + /** + * Legacy three-arg overload. The {@code breakerToken} is ignored — region + * failover is now handled by {@link EndpointFailoverInterceptor} at the HTTP + * layer, not via a per-call CircuitBreaker token. Kept so subclasses or + * external callers compiled against earlier SDK versions still link. + * + * @deprecated Use {@link #processResponseSSE(Response, Class)} instead. + */ + @Deprecated + protected T processResponseSSE(Response resp, Class typeOfT, CircuitBreaker.Token breakerToken) + throws TencentCloudSDKException { + return processResponseSSE(resp, typeOfT); + } + + /** + * Processes a JSON response. + * + * @param resp The raw HTTP response. + * @param typeOfT The class of the response object to deserialize to. + * @param The type of the response object. + * @return The deserialized response object. + * @throws TencentCloudSDKException If an error occurs during processing. + */ + protected T processResponseJson(Response resp, Class typeOfT) throws TencentCloudSDKException { + String body; + try { + body = resp.body().string(); + } catch (IOException e) { + String msg = "Cannot transfer response body to string, because Content-Length is too large, or " + + "Content-Length and stream length disagree."; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + + JsonResponseModel errResp; + try { + Type errType = new TypeToken>() { + }.getType(); + errResp = gson.fromJson(body, errType); + } catch (JsonSyntaxException e) { + String msg = "json is not a valid representation for an object of type"; + log.info(msg); + throw new TencentCloudSDKException(msg, e); + } + + if (errResp.response.error != null) { + throw new TencentCloudSDKException( + errResp.response.error.message, + errResp.response.requestId, + errResp.response.error.code); + } + + Type type = TypeToken.getParameterized(JsonResponseModel.class, typeOfT).getType(); + return ((JsonResponseModel) gson.fromJson(body, type)).response; + } + + /** + * Legacy three-arg overload. The {@code breakerToken} is ignored — region + * failover is now handled by {@link EndpointFailoverInterceptor} at the HTTP + * layer, not via a per-call CircuitBreaker token. Kept so subclasses or + * external callers compiled against earlier SDK versions still link. + * + * @deprecated Use {@link #processResponseJson(Response, Class)} instead. + */ + @Deprecated + protected T processResponseJson(Response resp, Class typeOfT, CircuitBreaker.Token breakerToken) + throws TencentCloudSDKException { + return processResponseJson(resp, typeOfT); + } + + /** + * Executes the raw API request and returns the HTTP Response object. + * + * @param request The request object containing API parameters. + * @param actionName The name of the API action to be called. + * @return The raw HTTP Response object. + * @throws TencentCloudSDKException If an error occurs during the API call. + * @throws IOException If an I/O error occurs. + */ + protected Response internalRequestRaw(AbstractModel request, String actionName) + throws TencentCloudSDKException, IOException { + Response okRsp = null; + String endpoint = this.getEndpoint(); + String[] binaryParams = request.getBinaryParams(); + String sm = this.profile.getSignMethod(); + String reqMethod = this.profile.getHttpProfile().getReqMethod(); + + // currently, customized params only can be supported via post json tc3-hmac-sha256 + HashMap customizedParams = request.any(); + if (customizedParams.size() > 0) { + if (binaryParams.length > 0) { + throw new TencentCloudSDKException( + "WrongUsage: Cannot post multipart with customized parameters."); + } + if (sm.equals(ClientProfile.SIGN_SHA1) || sm.equals(ClientProfile.SIGN_SHA256)) { + throw new TencentCloudSDKException( + "WrongUsage: Cannot use HmacSHA1 or HmacSHA256 with customized parameters."); + } + if (reqMethod.equals(HttpProfile.REQ_GET)) { + throw new TencentCloudSDKException( + "WrongUsage: Cannot use get method with customized parameters."); + } + } + + + if (binaryParams.length > 0 || sm.equals(ClientProfile.SIGN_TC3_256)) { + okRsp = doRequestWithTC3(endpoint, request, actionName); + } else if (sm.equals(ClientProfile.SIGN_SHA1) || sm.equals(ClientProfile.SIGN_SHA256)) { + okRsp = doRequest(endpoint, request, actionName); + } else { + throw new TencentCloudSDKException( + "Signature method " + sm + " is invalid or not supported yet."); + } + + // Check the HTTP response code. + if (okRsp.code() != AbstractClient.HTTP_RSP_OK) { + String msg = "response code is " + okRsp.code() + ", not 200"; + log.info(msg); + throw new TencentCloudSDKException(msg, "", "ServerSideError"); + } + return okRsp; + } + + /** + * Executes an API request using the older signature methods (HmacSHA1 or HmacSHA256). + * + * @param endpoint The API endpoint. + * @param request The request object. + * @param action The API action name. + * @return The HTTP Response object. + * @throws TencentCloudSDKException If an error occurs. + * @throws IOException If an I/O error occurs. + */ + private Response doRequest(String endpoint, AbstractModel request, String action) + throws TencentCloudSDKException, IOException { + String reqMethod = this.profile.getHttpProfile().getReqMethod(); + String protocol = this.profile.getHttpProfile().getProtocol(); + String url = protocol + endpoint + this.path; + String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); + if (null != apigwEndpoint) { + url = protocol + apigwEndpoint; + } + Headers.Builder callerHeaders = new Headers.Builder(); + if (null != request.GetHeader()) { + for (Map.Entry entry : request.GetHeader().entrySet()) { + callerHeaders.add(entry.getKey(), entry.getValue()); + } + } + RequestBuilder rb = RequestBuilder.create() + .fromClient(this) + .withSignMethod(this.profile.getSignMethod()) + .withURL(HttpUrl.parse(url)) + .withHost(endpoint) + .withMethod(reqMethod) + .withContentTypeForm() + .withPayload(request) + .withHeaders(callerHeaders.build()) + .withAction(action) + .withVersion(this.apiVersion) + .withRegion(this.region) + .withRequestClient(SDK_VERSION) + .withNonce(Math.abs(new SecureRandom().nextInt())); + return this.httpConnection.doRequest(rb.build()); + } + + /** + * Executes an API request using the TC3-HMAC-SHA256 signature method. + * + * @param endpoint The API endpoint. + * @param request The request object. + * @param action The API action name. + * @return The HTTP Response object. + * @throws TencentCloudSDKException If an error occurs. + * @throws IOException If an I/O error occurs. + */ + private Response doRequestWithTC3(String endpoint, AbstractModel request, String action) + throws TencentCloudSDKException, IOException { + Credential credSnapshot = this.credential.getSnapshot(); + String httpRequestMethod = this.profile.getHttpProfile().getReqMethod(); + if (httpRequestMethod == null) { + throw new TencentCloudSDKException( + "Request method should not be null, can only be GET or POST"); + } + String contentType = "application/x-www-form-urlencoded"; + byte[] requestPayload = "".getBytes(StandardCharsets.UTF_8); + HashMap params = new HashMap(); + request.toMap(params, ""); + String[] binaryParams = request.getBinaryParams(); + if (binaryParams.length > 0) { + httpRequestMethod = HttpProfile.REQ_POST; + String boundary = UUID.randomUUID().toString(); + // okhttp always set charset even we don't specify it, + // to ensure signature be correct, we have to set it here as well. + contentType = "multipart/form-data; charset=utf-8" + "; boundary=" + boundary; + try { + requestPayload = getMultipartPayload(request, boundary); + } catch (Exception e) { + throw new TencentCloudSDKException("Failed to generate multipart.", e); + } + } else if (httpRequestMethod.equals(HttpProfile.REQ_POST)) { + requestPayload = AbstractModel.toJsonString(request).getBytes(StandardCharsets.UTF_8); + // okhttp always set charset even we don't specify it, + // to ensure signature be correct, we have to set it here as well. + contentType = "application/json; charset=utf-8"; + } + if (binaryParams.length == 0) { + String protocol = this.profile.getHttpProfile().getProtocol(); + String url = protocol + endpoint + this.path; + String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); + if (null != apigwEndpoint) { + url = protocol + apigwEndpoint; + } + Headers.Builder callerHeaders = new Headers.Builder(); + if (null != request.GetHeader()) { + for (Map.Entry entry : request.GetHeader().entrySet()) { + callerHeaders.add(entry.getKey(), entry.getValue()); + } + } + RequestBuilder rb = RequestBuilder.create() + .fromClient(this) + .withSignMethod(request.getSkipSign() ? "SKIP" : ClientProfile.SIGN_TC3_256) + .withURL(HttpUrl.parse(url)) + .withHost(endpoint) + .withMethod(httpRequestMethod) + .withContentType(contentType) + .withPayload(request) + .withHeaders(callerHeaders.build()) + .withAction(action) + .withVersion(this.apiVersion) + .withRegion(this.region) + .withRequestClient(SDK_VERSION); + return this.httpConnection.doRequest(rb.build()); + } + // Construct the canonical request for signature calculation. + String canonicalUri = "/"; + String canonicalQueryString = this.getCanonicalQueryString(params, httpRequestMethod); + String canonicalHeaders = "content-type:" + contentType + "\nhost:" + endpoint + "\n"; + String signedHeaders = "content-type;host"; + + String hashedRequestPayload = ""; + if (this.profile.isUnsignedPayload()) { + hashedRequestPayload = Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8)); + } else { + hashedRequestPayload = Sign.sha256Hex(requestPayload); + } + String canonicalRequest = + httpRequestMethod + + "\n" + + canonicalUri + + "\n" + + canonicalQueryString + + "\n" + + canonicalHeaders + + "\n" + + signedHeaders + + "\n" + + hashedRequestPayload; + + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + String date = sdf.format(new Date(Long.valueOf(timestamp + "000"))); + String service = endpoint.split("\\.")[0]; + String credentialScope = date + "/" + service + "/" + "tc3_request"; + String hashedCanonicalRequest = + Sign.sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8)); + String stringToSign = + "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; + boolean skipSign = request.getSkipSign(); + String authorization = ""; + if (skipSign) { + authorization = "SKIP"; + } else { + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); + byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); + byte[] secretService = Sign.hmac256(secretDate, service); + byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); + String signature = + DatatypeConverter.printHexBinary(Sign.hmac256(secretSigning, stringToSign)).toLowerCase(); + authorization = + "TC3-HMAC-SHA256 " + + "Credential=" + + secretId + + "/" + + credentialScope + + ", " + + "SignedHeaders=" + + signedHeaders + + ", " + + "Signature=" + + signature; + } + Builder hb = new Headers.Builder(); + hb.add("Content-Type", contentType) + .add("Host", endpoint) + .add("Authorization", authorization) + .add("X-TC-Action", action) + .add("X-TC-Timestamp", timestamp) + .add("X-TC-Version", this.apiVersion) + .add("X-TC-RequestClient", SDK_VERSION); + if (null != request.GetHeader()) { + for (Map.Entry entry : request.GetHeader().entrySet()) { + hb.add(entry.getKey(), entry.getValue()); + } + } + if (null != this.getRegion()) { + hb.add("X-TC-Region", this.getRegion()); + } + String token = credSnapshot.getToken(); + if (token != null && !token.isEmpty()) { + hb.add("X-TC-Token", token); + } + if (this.profile.isUnsignedPayload()) { + hb.add("X-TC-Content-SHA256", "UNSIGNED-PAYLOAD"); + } + if (null != this.profile.getLanguage()) { + hb.add("X-TC-Language", this.profile.getLanguage().getValue()); + } + + String protocol = this.profile.getHttpProfile().getProtocol(); + String url = protocol + endpoint + this.path; + String apigwEndpoint = this.profile.getHttpProfile().getApigwEndpoint(); + if (null != apigwEndpoint) { + url = protocol + apigwEndpoint; + } + // Tag the outgoing multipart request with a RequestBuilder so failover + // can re-sign for an alternate host. Multipart body is treated as + // octet-stream for re-signing purposes (raw bytes, content-type preserved + // via the Content-Type header). + Headers.Builder callerHeaders = new Headers.Builder(); + if (null != request.GetHeader()) { + for (Map.Entry entry : request.GetHeader().entrySet()) { + callerHeaders.add(entry.getKey(), entry.getValue()); + } + } + RequestBuilder rb = RequestBuilder.create() + .fromClient(this) + .withSignMethod(request.getSkipSign() ? "SKIP" : ClientProfile.SIGN_TC3_256) + .withURL(HttpUrl.parse(url)) + .withHost(null != apigwEndpoint ? apigwEndpoint : endpoint) + .withMethod(httpRequestMethod) + .withContentTypeOctetStream() + .withPayload(requestPayload) + .withHeaders(callerHeaders.build()) + .withAction(action) + .withVersion(this.apiVersion) + .withRegion(this.region) + .withRequestClient(SDK_VERSION); + Request.Builder overrideBuilder = rb.build().newBuilder() + .header("Content-Type", contentType); + return this.httpConnection.doRequest(overrideBuilder.build()); + } + + /** + * Constructs the multipart payload for file uploads. + * + * @param request The request object containing file parameters. + * @param boundary The boundary string to separate parts of the multipart data. + * @return The byte array representing the multipart payload. + * @throws Exception If an error occurs during payload construction. + */ + private byte[] getMultipartPayload(AbstractModel request, String boundary) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + String[] binaryParams = request.getBinaryParams(); + // Iterate through each parameter in the multipart request. + for (Map.Entry entry : request.getMultipartRequestParams().entrySet()) { + baos.write("--".getBytes(StandardCharsets.UTF_8)); + baos.write(boundary.getBytes(StandardCharsets.UTF_8)); + baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); + baos.write("Content-Disposition: form-data; name=\"".getBytes(StandardCharsets.UTF_8)); + baos.write(entry.getKey().getBytes(StandardCharsets.UTF_8)); + if (Arrays.asList(binaryParams).contains(entry.getKey())) { + baos.write("\"; filename=\"".getBytes(StandardCharsets.UTF_8)); + baos.write(entry.getKey().getBytes(StandardCharsets.UTF_8)); + baos.write("\"\r\n".getBytes(StandardCharsets.UTF_8)); + } else { + baos.write("\"\r\n".getBytes(StandardCharsets.UTF_8)); + } + baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); + baos.write(entry.getValue()); + baos.write("\r\n".getBytes(StandardCharsets.UTF_8)); + } + // Write the closing boundary if there's any data. + if (baos.size() != 0) { + baos.write("--".getBytes(StandardCharsets.UTF_8)); + baos.write(boundary.getBytes(StandardCharsets.UTF_8)); + baos.write("--\r\n".getBytes(StandardCharsets.UTF_8)); + } + byte[] bytes = baos.toByteArray(); + baos.close(); + return bytes; + } + + /** + * Generates the canonical query string for GET requests. + * + * @param params The map of request parameters. + * @param method The HTTP method (should be GET). + * @return The canonical query string. + * @throws TencentCloudSDKException If UTF-8 encoding is not supported. + */ + private String getCanonicalQueryString(HashMap params, String method) + throws TencentCloudSDKException { + // POST requests don't have a query string in the signature. + if (method != null && method.equals(HttpProfile.REQ_POST)) { + return ""; + } + StringBuilder queryString = new StringBuilder(""); + // Iterate through each parameter and build the query string. + for (Map.Entry entry : params.entrySet()) { + String v; + try { + v = URLEncoder.encode(entry.getValue(), "UTF8"); + } catch (UnsupportedEncodingException e) { + throw new TencentCloudSDKException("UTF8 is not supported.", e); + } + queryString.append("&").append(entry.getKey()).append("=").append(v); + } + // Remove the leading '&' if the query string is not empty. + if (queryString.length() == 0) { + return ""; + } else { + return queryString.toString().substring(1); + } + } + + /** + * Formats the request data for signing (older signature methods). + * + * @param action The API action name. + * @param param The map of request parameters. + * @return The formatted string for signing. + * @throws TencentCloudSDKException If UTF-8 encoding is not supported. + */ + private String formatRequestData(String action, Map param) + throws TencentCloudSDKException { + Credential credSnapshot = this.credential.getSnapshot(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); + String token = credSnapshot.getToken(); + param.put("Action", action); + param.put("RequestClient", this.sdkVersion); + param.put("Nonce", String.valueOf(Math.abs(new SecureRandom().nextInt()))); + param.put("Timestamp", String.valueOf(System.currentTimeMillis() / 1000)); + param.put("Version", this.apiVersion); + + // Add SecretId, Region, SignatureMethod, and Token if available. + if (secretId != null && (!secretId.isEmpty())) { + param.put("SecretId", secretId); + } + + if (this.region != null && (!this.region.isEmpty())) { + param.put("Region", this.region); + } + + if (this.profile.getSignMethod() != null && (!this.profile.getSignMethod().isEmpty())) { + param.put("SignatureMethod", this.profile.getSignMethod()); + } + + if (token != null && (!token.isEmpty())) { + param.put("Token", token); + } + + if (null != this.profile.getLanguage()) { + param.put("Language", this.profile.getLanguage().getValue()); + } + + String endpoint = this.getEndpoint(); + + // Generate the string to be signed. + String sigInParam = + Sign.makeSignPlainText( + new TreeMap(param), + this.profile.getHttpProfile().getReqMethod(), + endpoint, + this.path); + // Generate the signature. + String sigOutParam = + Sign.sign(secretKey, sigInParam, this.profile.getSignMethod()); + + String strParam = ""; + try { + // URL-encode each parameter and construct the query string. + for (Map.Entry entry : param.entrySet()) { + strParam += + (URLEncoder.encode(entry.getKey(), "utf-8") + + "=" + + URLEncoder.encode(entry.getValue(), "utf-8") + + "&"); + } + strParam += ("Signature=" + URLEncoder.encode(sigOutParam, "utf-8")); + } catch (UnsupportedEncodingException e) { + throw new TencentCloudSDKException("", e); + } + return strParam; + } + + /** + * Performs initializations to avoid performance costs in the first request. + */ + private void warmup() { + try { + // Initialize Mac instances (used for signature calculation). + // First invoke costs around 250 ms. + Mac.getInstance("HmacSHA1"); + Mac.getInstance("HmacSHA256"); + // Initialize SSLContext (used for HTTPS connections). + // First invoke costs around 150 ms. + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, null, null); + } catch (Exception e) { + // Ignore but print the stack trace to the console for debugging. + e.printStackTrace(); + } + } + + /** + * Gets the API endpoint. + * + * @return The API endpoint URL. + */ + private String getEndpoint() { + // Use the endpoint from the profile if it's set, otherwise construct it from service and domain. + if (null != this.profile.getHttpProfile().getEndpoint()) { + return this.profile.getHttpProfile().getEndpoint(); + } else { + // protected abstract String getService(); + // use this.getService() from overrided subclass will be better + return this.service + "." + this.profile.getHttpProfile().getRootDomain(); + } + } + + String getServiceNameForFailover() { + return this.service; + } + + /** + * 请注意购买类接口谨慎调用,可能导致多次购买 + * 仅幂等接口推荐使用 + * + * @param req + * @param retryTimes + * @throws TencentCloudSDKException + */ + public Object retry(AbstractModel req, int retryTimes) throws TencentCloudSDKException { + if (retryTimes < 0 || retryTimes > 10) { + throw new TencentCloudSDKException("The number of retryTimes supported is 0 to 10.", "", "ClientSideError"); + } + Class cls = this.getClass(); + String methodName = req.getClass().getSimpleName().replace("Request", ""); + Method method; + try { + method = cls.getMethod(methodName, req.getClass()); + } catch (NoSuchMethodException e) { + throw new TencentCloudSDKException("ClientSideError", e); + } + do { + try { + return method.invoke(this, req); + } catch (IllegalAccessException e) { + throw new TencentCloudSDKException("ClientSideError", e); + } catch (InvocationTargetException e) { + if (retryTimes == 0) { + throw (TencentCloudSDKException) e.getTargetException(); + } + } + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new TencentCloudSDKException("ClientSideError", e); + } + } while (--retryTimes >= 0); + return null; + } + + /** + * Returns the circuit breaker previously set by + * {@link #setRegionBreaker(CircuitBreaker)}. + * + * @return The circuit breaker, or null if none was set. + */ + public CircuitBreaker getRegionBreaker() { + EndpointFailoverInterceptor interceptor = getFailoverInterceptor(); + return interceptor != null ? interceptor.getRegionBreaker() : null; + } + + /** + * Sets the circuit breaker to use for endpoint failover. The breaker's + * settings (maxFailNum, maxFailPercentage, windowIntervalMs, timeoutMs) + * are copied and applied to all per-host breakers created by the + * {@link EndpointFailoverInterceptor}. + * + * @param regionBreaker The circuit breaker whose settings will be used for failover. + */ + public void setRegionBreaker(CircuitBreaker regionBreaker) { + EndpointFailoverInterceptor interceptor = getFailoverInterceptor(); + if (interceptor != null) { + interceptor.setRegionBreaker(regionBreaker); + } + } + + private EndpointFailoverInterceptor getFailoverInterceptor() { + for (okhttp3.Interceptor i : this.httpConnection.getInterceptors()) { + if (i instanceof EndpointFailoverInterceptor) { + return (EndpointFailoverInterceptor) i; + } + } + return null; + } + +} diff --git a/src/main/java/com/tencentcloudapi/common/AbstractModel.java b/src/main/java/com/tencentcloudapi/common/AbstractModel.java index 638ad0b71e..31d2cfb7eb 100644 --- a/src/main/java/com/tencentcloudapi/common/AbstractModel.java +++ b/src/main/java/com/tencentcloudapi/common/AbstractModel.java @@ -1,274 +1,274 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; - -import java.lang.reflect.Field; -import java.util.HashMap; -import java.util.Map; - -/** - * Abstract base class for all model objects in the Tencent Cloud SDK. - *

This class provides utility methods for serializing to and deserializing from JSON, - * managing customized parameters, and handling HTTP headers and requests.

- */ -public abstract class AbstractModel { - - /** - * Stores HTTP headers for the request. - */ - public Map header = new HashMap(); - - /** - * Flag to indicate whether to skip signing for this request. Defaults to false. - */ - protected boolean skipSign = false; - - /** - * Stores any custom parameters that are not part of the predefined model fields. - * These can be added dynamically and should be serializable to JSON. - */ - private HashMap customizedParams = new HashMap(); - - /** - * Convert an object of type O to its JSON string representation. - * - * @param obj The object to be serialized to JSON. - * @param The type of the object. - * @return A JSON string representation of the object. - */ - public static String toJsonString(O obj) { - return toJsonObject(obj).toString(); - } - - /** - * Recursively generates a JSON object from the model object. - * This method handles serialization for both custom parameters and regular fields. - * - * @param obj The object to be serialized. - * @param The type of the object. - * @return A JSON object representation of the model. - */ - private static JsonObject toJsonObject(O obj) { - Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); - JsonObject joall = new JsonObject(); - - // Serialize customized parameters - JsonObject joadd = gson.toJsonTree(obj.any()).getAsJsonObject(); - for (Map.Entry entry : joadd.entrySet()) { - joall.add(entry.getKey(), entry.getValue()); - } - - // Serialize regular fields of the object - JsonObject jopublic = gson.toJsonTree(obj).getAsJsonObject(); - for (Map.Entry entry : jopublic.entrySet()) { - Object fo = null; - try { - // Access each field using reflection - Field f = obj.getClass().getDeclaredField(entry.getKey()); - f.setAccessible(true); - fo = f.get(obj); - } catch (Exception e) { - // This should never happen - e.printStackTrace(); - } - if (fo instanceof AbstractModel) { - // If the field is an AbstractModel, recursively serialize it - joall.add(entry.getKey(), toJsonObject((AbstractModel) fo)); - } else { - // Otherwise, directly add the value to the JSON object - joall.add(entry.getKey(), entry.getValue()); - } - } - return joall; - } - - /** - * Deserialize a JSON string into an object of a subclass of AbstractModel. - * - * @param json The JSON string to be deserialized. - * @param cls The class of the target model type. - * @param The type of the object to deserialize. - * @return An object of type O. - */ - public static O fromJsonString(String json, Class cls) { - Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); - return gson.fromJson(json, cls); - } - - /** - * Abstract method to be implemented by subclasses. - * Used to convert the model into a map for HTTP requests. - * - * @param map The map to populate with the model's parameters. - * @param prefix A string prefix for the keys. - */ - protected abstract void toMap(HashMap map, String prefix); - - /** - * Mark which parameters are binary type (for multipart requests). - * - * @return An array of parameter names that are binary. - */ - protected String[] getBinaryParams() { - return new String[0]; - } - - /** - * Return the multipart request parameters (if any). - * - * @return A map of multipart parameters. - */ - protected HashMap getMultipartRequestParams() { - return new HashMap(); - } - - /** - * Helper method to set a simple parameter in the map. - * Converts the parameter name and value to a string. - * - * @param map The map to put the parameter into. - * @param key The key for the parameter. - * @param value The value of the parameter. - * @param The type of the value. - */ - protected void setParamSimple(HashMap map, String key, V value) { - if (value != null) { - key = key.substring(0, 1).toUpperCase() + key.substring(1); - key = key.replace("_", "."); - map.put(key, String.valueOf(value)); - } - } - - /** - * Helper method to set an array of simple parameters in the map. - * - * @param map The map to put the parameters into. - * @param prefix The prefix for each key. - * @param array The array of values. - * @param The type of the values in the array. - */ - protected void setParamArraySimple(HashMap map, String prefix, V[] array) { - if (array != null) { - for (int i = 0; i < array.length; i++) { - this.setParamSimple(map, prefix + i, array[i]); - } - } - } - - /** - * Helper method to set an object parameter in the map. - * Recursively calls toMap() for complex model objects. - * - * @param map The map to put the parameter into. - * @param prefix The prefix for the key. - * @param obj The object to add to the map. - * @param The type of the object. - */ - protected void setParamObj( - HashMap map, String prefix, V obj) { - if (obj != null) { - obj.toMap(map, prefix); - } - } - - /** - * Helper method to set an array of object parameters in the map. - * - * @param map The map to put the parameters into. - * @param prefix The prefix for each key. - * @param array The array of object values. - * @param The type of the objects in the array. - */ - protected void setParamArrayObj( - HashMap map, String prefix, V[] array) { - if (array != null) { - for (int i = 0; i < array.length; i++) { - this.setParamObj(map, prefix + i + ".", array[i]); - } - } - } - - /** - * Set any custom key-value pair to this model. - * - * @param key The key to set. - * @param value The value to set. - */ - public void set(String key, Object value) { - this.customizedParams.put(key, value); - } - - /** - * Get all custom key-value pairs from this model. - * - * @return A map of custom parameters. - */ - public HashMap any() { - return this.customizedParams; - } - - /** - * Get the flag indicating whether the sign should be skipped. - * - * @return True if the sign should be skipped; otherwise, false. - */ - public boolean getSkipSign() { - return skipSign; - } - - /** - * Set the flag indicating whether the sign should be skipped. - * - * @param skipSign True to skip the sign; false otherwise. - */ - public void setSkipSign(boolean skipSign) { - this.skipSign = skipSign; - } - - /** - * Get the HTTP request headers. - * - * @return A map of HTTP headers. - */ - public Map GetHeader() { - return header; - } - - /** - * Set the HTTP request headers. - * - * @param header A map of HTTP headers to set. - */ - public void SetHeader(Map header) { - this.header = header; - } - - /** - * Check if the model is a stream type. - * - * @return False, as this model is not a stream type. - */ - public boolean isStream() { - return false; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +/** + * Abstract base class for all model objects in the Tencent Cloud SDK. + *

This class provides utility methods for serializing to and deserializing from JSON, + * managing customized parameters, and handling HTTP headers and requests.

+ */ +public abstract class AbstractModel { + + /** + * Stores HTTP headers for the request. + */ + public Map header = new HashMap(); + + /** + * Flag to indicate whether to skip signing for this request. Defaults to false. + */ + protected boolean skipSign = false; + + /** + * Stores any custom parameters that are not part of the predefined model fields. + * These can be added dynamically and should be serializable to JSON. + */ + private HashMap customizedParams = new HashMap(); + + /** + * Convert an object of type O to its JSON string representation. + * + * @param obj The object to be serialized to JSON. + * @param The type of the object. + * @return A JSON string representation of the object. + */ + public static String toJsonString(O obj) { + return toJsonObject(obj).toString(); + } + + /** + * Recursively generates a JSON object from the model object. + * This method handles serialization for both custom parameters and regular fields. + * + * @param obj The object to be serialized. + * @param The type of the object. + * @return A JSON object representation of the model. + */ + private static JsonObject toJsonObject(O obj) { + Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + JsonObject joall = new JsonObject(); + + // Serialize customized parameters + JsonObject joadd = gson.toJsonTree(obj.any()).getAsJsonObject(); + for (Map.Entry entry : joadd.entrySet()) { + joall.add(entry.getKey(), entry.getValue()); + } + + // Serialize regular fields of the object + JsonObject jopublic = gson.toJsonTree(obj).getAsJsonObject(); + for (Map.Entry entry : jopublic.entrySet()) { + Object fo = null; + try { + // Access each field using reflection + Field f = obj.getClass().getDeclaredField(entry.getKey()); + f.setAccessible(true); + fo = f.get(obj); + } catch (Exception e) { + // This should never happen + e.printStackTrace(); + } + if (fo instanceof AbstractModel) { + // If the field is an AbstractModel, recursively serialize it + joall.add(entry.getKey(), toJsonObject((AbstractModel) fo)); + } else { + // Otherwise, directly add the value to the JSON object + joall.add(entry.getKey(), entry.getValue()); + } + } + return joall; + } + + /** + * Deserialize a JSON string into an object of a subclass of AbstractModel. + * + * @param json The JSON string to be deserialized. + * @param cls The class of the target model type. + * @param The type of the object to deserialize. + * @return An object of type O. + */ + public static O fromJsonString(String json, Class cls) { + Gson gson = new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); + return gson.fromJson(json, cls); + } + + /** + * Abstract method to be implemented by subclasses. + * Used to convert the model into a map for HTTP requests. + * + * @param map The map to populate with the model's parameters. + * @param prefix A string prefix for the keys. + */ + protected abstract void toMap(HashMap map, String prefix); + + /** + * Mark which parameters are binary type (for multipart requests). + * + * @return An array of parameter names that are binary. + */ + protected String[] getBinaryParams() { + return new String[0]; + } + + /** + * Return the multipart request parameters (if any). + * + * @return A map of multipart parameters. + */ + protected HashMap getMultipartRequestParams() { + return new HashMap(); + } + + /** + * Helper method to set a simple parameter in the map. + * Converts the parameter name and value to a string. + * + * @param map The map to put the parameter into. + * @param key The key for the parameter. + * @param value The value of the parameter. + * @param The type of the value. + */ + protected void setParamSimple(HashMap map, String key, V value) { + if (value != null) { + key = key.substring(0, 1).toUpperCase() + key.substring(1); + key = key.replace("_", "."); + map.put(key, String.valueOf(value)); + } + } + + /** + * Helper method to set an array of simple parameters in the map. + * + * @param map The map to put the parameters into. + * @param prefix The prefix for each key. + * @param array The array of values. + * @param The type of the values in the array. + */ + protected void setParamArraySimple(HashMap map, String prefix, V[] array) { + if (array != null) { + for (int i = 0; i < array.length; i++) { + this.setParamSimple(map, prefix + i, array[i]); + } + } + } + + /** + * Helper method to set an object parameter in the map. + * Recursively calls toMap() for complex model objects. + * + * @param map The map to put the parameter into. + * @param prefix The prefix for the key. + * @param obj The object to add to the map. + * @param The type of the object. + */ + protected void setParamObj( + HashMap map, String prefix, V obj) { + if (obj != null) { + obj.toMap(map, prefix); + } + } + + /** + * Helper method to set an array of object parameters in the map. + * + * @param map The map to put the parameters into. + * @param prefix The prefix for each key. + * @param array The array of object values. + * @param The type of the objects in the array. + */ + protected void setParamArrayObj( + HashMap map, String prefix, V[] array) { + if (array != null) { + for (int i = 0; i < array.length; i++) { + this.setParamObj(map, prefix + i + ".", array[i]); + } + } + } + + /** + * Set any custom key-value pair to this model. + * + * @param key The key to set. + * @param value The value to set. + */ + public void set(String key, Object value) { + this.customizedParams.put(key, value); + } + + /** + * Get all custom key-value pairs from this model. + * + * @return A map of custom parameters. + */ + public HashMap any() { + return this.customizedParams; + } + + /** + * Get the flag indicating whether the sign should be skipped. + * + * @return True if the sign should be skipped; otherwise, false. + */ + public boolean getSkipSign() { + return skipSign; + } + + /** + * Set the flag indicating whether the sign should be skipped. + * + * @param skipSign True to skip the sign; false otherwise. + */ + public void setSkipSign(boolean skipSign) { + this.skipSign = skipSign; + } + + /** + * Get the HTTP request headers. + * + * @return A map of HTTP headers. + */ + public Map GetHeader() { + return header; + } + + /** + * Set the HTTP request headers. + * + * @param header A map of HTTP headers to set. + */ + public void SetHeader(Map header) { + this.header = header; + } + + /** + * Check if the model is a stream type. + * + * @return False, as this model is not a stream type. + */ + public boolean isStream() { + return false; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/CircuitBreaker.java b/src/main/java/com/tencentcloudapi/common/CircuitBreaker.java index 344d7a9ea4..e01b1678b0 100644 --- a/src/main/java/com/tencentcloudapi/common/CircuitBreaker.java +++ b/src/main/java/com/tencentcloudapi/common/CircuitBreaker.java @@ -80,6 +80,15 @@ public CircuitBreaker(Setting setting) { this.setting = setting; } + /** + * Returns the settings used by this circuit breaker. + * + * @return The Setting instance. + */ + public Setting getSetting() { + return setting; + } + /** * Attempt to allow a request based on the current state of the circuit breaker. * @@ -214,7 +223,7 @@ private void onFailure(State state, long now) { all++; failures++; consecutiveSuccesses = 0; - consecutiveFailures = 0; + consecutiveFailures++; if (readyToOpen()) { setState(State.Open, now); } diff --git a/src/main/java/com/tencentcloudapi/common/Credential.java b/src/main/java/com/tencentcloudapi/common/Credential.java index f18560eddf..bfdfc1943f 100644 --- a/src/main/java/com/tencentcloudapi/common/Credential.java +++ b/src/main/java/com/tencentcloudapi/common/Credential.java @@ -1,196 +1,196 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common; - -import com.tencentcloudapi.common.exception.TencentCloudSDKException; - -/** - * Credential has many types in Tencent Cloud Access Management service. - * - *

We mainly use two of them: - * - *

Permanent credential: SecretId & SecretKey, can only be obtained from Tencent Cloud Management - * Console, https://console.cloud.tencent.com/cam/capi. - * - *

Ephemeral credential, can be obtained from Security Token Service (STS), has three dimensions: - * SecretId, SecretKey and Token. It will expire after a short time, hence you need to invoke STS - * API to refresh it. - * - *

Thread-safety and atomicity. The triple (secretId, secretKey, token) is only guaranteed - * to be read atomically via {@link #getSnapshot()}. The individual {@code getSecretId()}, - * {@code getSecretKey()}, {@code getToken()} getters do NOT guarantee that two consecutive calls - * observe a consistent triple — a refresh triggered between them may yield an id/key/token - * mismatch. Any code path that consumes more than one of the three fields together (e.g. request - * signing) should use {@link #getSnapshot()}. - */ -public class Credential { - private String secretId; - private String secretKey; - private String token; - private Updater updater; - - public Credential() { - } - - public Credential(String secretId, String secretKey) { - this(secretId, secretKey, ""); - } - - public Credential(String secretId, String secretKey, String token) { - this.secretId = secretId; - this.secretKey = secretKey; - this.token = token; - } - - public Credential(String secretId, String secretKey, String token, Updater updater) { - this.secretId = secretId; - this.secretKey = secretKey; - this.token = token; - this.updater = updater; - } - - public Updater getUpdater() { - return updater; - } - - public void setUpdater(Updater updater) { - this.updater = updater; - } - - /** - * Returns the secret id, triggering a refresh via the attached {@link Updater} if one is set. - * - *

Backward-compatibility note. This getter calls {@link #tryUpdate()} outside of any - * synchronization block. Two consequences follow from this: - *

    - *
  1. Concurrent threads can each enter {@code tryUpdate()} simultaneously, so the - * {@link Updater} may be invoked concurrently; well-behaved Updater implementations must - * guard against this themselves (e.g. via {@code needRefresh()} checks).
  2. - *
  3. An {@link Updater} that calls back into {@code getSecretId()}, {@code getSecretKey()}, - * or {@code getToken()} on the same credential will recurse infinitely. Use - * {@link #setCredential} for writes inside {@code update()}, never the individual - * getters.
  4. - *
- * Both behaviours are retained for backward compatibility. For any code path that reads more - * than one of the three fields together, use {@link #getSnapshot()} instead. - */ - public String getSecretId() { - tryUpdate(); - return this.secretId; - } - - public void setSecretId(String secretId) { - this.secretId = secretId; - } - - /** - * Returns the secret key, triggering a refresh via the attached {@link Updater} if one is set. - * - *

See {@link #getSecretId()} for the backward-compatibility caveats that apply to all three - * individual getters. Prefer {@link #getSnapshot()} when reading multiple fields together. - */ - public String getSecretKey() { - tryUpdate(); - return this.secretKey; - } - - public void setSecretKey(String secretKey) { - this.secretKey = secretKey; - } - - /** - * Returns the session token, triggering a refresh via the attached {@link Updater} if one is - * set. - * - *

See {@link #getSecretId()} for the backward-compatibility caveats that apply to all three - * individual getters. Prefer {@link #getSnapshot()} when reading multiple fields together. - */ - public String getToken() { - tryUpdate(); - return this.token; - } - - public void setToken(String token) { - this.token = token; - } - - /** - * Atomically replaces the entire (secretId, secretKey, token) triple under the same monitor - * used by {@link #getSnapshot()}. - * - *

Use this instead of three separate {@code setSecretId} / {@code setSecretKey} / - * {@code setToken} calls whenever you need to publish a freshly-refreshed triple: concurrent - * readers going through {@link #getSnapshot()} are guaranteed to observe either the old triple - * or the new triple in its entirety, never a mix of the two. The individual setters do not - * provide this guarantee and should only be used when no other thread is reading. - * - *

This method is the intended write-side companion to {@link #getSnapshot()}'s read-side - * atomicity. {@link Updater} implementations that refresh the triple in place (rather than - * constructing a new {@code Credential}) should call this method instead of the individual - * setters. - * - * @param secretId the new secret id. - * @param secretKey the new secret key. - * @param token the new token, may be empty or {@code null} for permanent credentials. - */ - public void setCredential(String secretId, String secretKey, String token) { - synchronized (this) { - this.secretId = secretId; - this.secretKey = secretKey; - this.token = token; - } - } - - /** - * Returns a point-in-time, self-consistent copy of the (secretId, secretKey, token) triple. - * - *

This is the only thread-safe, atomic way to read the credential triple. The refresh hook - * (if any) is invoked exactly once under a lock, and the three fields are then sampled together - * into a new {@code Credential} that does not carry an {@link Updater}. Use this in any code - * path that consumes more than one of the three fields together (e.g. request signing). - * - *

The returned object should be treated as read-only. It is a fresh instance and mutating it - * via the setters has no effect on the source credential, but doing so will break the - * consistency guarantee for the holder of the snapshot. - * - * @return a point-in-time copy of the credential triple, with no attached updater. - */ - public Credential getSnapshot() { - synchronized (this) { - tryUpdate(); - return new Credential(secretId, secretKey, token); - } - } - - private void tryUpdate() { - if (updater == null) { - return; - } - - try { - updater.update(this); - } catch (TencentCloudSDKException e) { - // wrap as RuntimeException to keep API consistent - throw new RuntimeException(e); - } - } - - public interface Updater { - void update(Credential credential) throws TencentCloudSDKException; - } -} +/* + * Copyright (c) 2018 THL A29 Limited, a Tencent company. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; + +/** + * Credential has many types in Tencent Cloud Access Management service. + * + *

We mainly use two of them: + * + *

Permanent credential: SecretId & SecretKey, can only be obtained from Tencent Cloud Management + * Console, https://console.cloud.tencent.com/cam/capi. + * + *

Ephemeral credential, can be obtained from Security Token Service (STS), has three dimensions: + * SecretId, SecretKey and Token. It will expire after a short time, hence you need to invoke STS + * API to refresh it. + * + *

Thread-safety and atomicity. The triple (secretId, secretKey, token) is only guaranteed + * to be read atomically via {@link #getSnapshot()}. The individual {@code getSecretId()}, + * {@code getSecretKey()}, {@code getToken()} getters do NOT guarantee that two consecutive calls + * observe a consistent triple — a refresh triggered between them may yield an id/key/token + * mismatch. Any code path that consumes more than one of the three fields together (e.g. request + * signing) should use {@link #getSnapshot()}. + */ +public class Credential { + private String secretId; + private String secretKey; + private String token; + private Updater updater; + + public Credential() { + } + + public Credential(String secretId, String secretKey) { + this(secretId, secretKey, ""); + } + + public Credential(String secretId, String secretKey, String token) { + this.secretId = secretId; + this.secretKey = secretKey; + this.token = token; + } + + public Credential(String secretId, String secretKey, String token, Updater updater) { + this.secretId = secretId; + this.secretKey = secretKey; + this.token = token; + this.updater = updater; + } + + public Updater getUpdater() { + return updater; + } + + public void setUpdater(Updater updater) { + this.updater = updater; + } + + /** + * Returns the secret id, triggering a refresh via the attached {@link Updater} if one is set. + * + *

Backward-compatibility note. This getter calls {@link #tryUpdate()} outside of any + * synchronization block. Two consequences follow from this: + *

    + *
  1. Concurrent threads can each enter {@code tryUpdate()} simultaneously, so the + * {@link Updater} may be invoked concurrently; well-behaved Updater implementations must + * guard against this themselves (e.g. via {@code needRefresh()} checks).
  2. + *
  3. An {@link Updater} that calls back into {@code getSecretId()}, {@code getSecretKey()}, + * or {@code getToken()} on the same credential will recurse infinitely. Use + * {@link #setCredential} for writes inside {@code update()}, never the individual + * getters.
  4. + *
+ * Both behaviours are retained for backward compatibility. For any code path that reads more + * than one of the three fields together, use {@link #getSnapshot()} instead. + */ + public String getSecretId() { + tryUpdate(); + return this.secretId; + } + + public void setSecretId(String secretId) { + this.secretId = secretId; + } + + /** + * Returns the secret key, triggering a refresh via the attached {@link Updater} if one is set. + * + *

See {@link #getSecretId()} for the backward-compatibility caveats that apply to all three + * individual getters. Prefer {@link #getSnapshot()} when reading multiple fields together. + */ + public String getSecretKey() { + tryUpdate(); + return this.secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + /** + * Returns the session token, triggering a refresh via the attached {@link Updater} if one is + * set. + * + *

See {@link #getSecretId()} for the backward-compatibility caveats that apply to all three + * individual getters. Prefer {@link #getSnapshot()} when reading multiple fields together. + */ + public String getToken() { + tryUpdate(); + return this.token; + } + + public void setToken(String token) { + this.token = token; + } + + /** + * Atomically replaces the entire (secretId, secretKey, token) triple under the same monitor + * used by {@link #getSnapshot()}. + * + *

Use this instead of three separate {@code setSecretId} / {@code setSecretKey} / + * {@code setToken} calls whenever you need to publish a freshly-refreshed triple: concurrent + * readers going through {@link #getSnapshot()} are guaranteed to observe either the old triple + * or the new triple in its entirety, never a mix of the two. The individual setters do not + * provide this guarantee and should only be used when no other thread is reading. + * + *

This method is the intended write-side companion to {@link #getSnapshot()}'s read-side + * atomicity. {@link Updater} implementations that refresh the triple in place (rather than + * constructing a new {@code Credential}) should call this method instead of the individual + * setters. + * + * @param secretId the new secret id. + * @param secretKey the new secret key. + * @param token the new token, may be empty or {@code null} for permanent credentials. + */ + public void setCredential(String secretId, String secretKey, String token) { + synchronized (this) { + this.secretId = secretId; + this.secretKey = secretKey; + this.token = token; + } + } + + /** + * Returns a point-in-time, self-consistent copy of the (secretId, secretKey, token) triple. + * + *

This is the only thread-safe, atomic way to read the credential triple. The refresh hook + * (if any) is invoked exactly once under a lock, and the three fields are then sampled together + * into a new {@code Credential} that does not carry an {@link Updater}. Use this in any code + * path that consumes more than one of the three fields together (e.g. request signing). + * + *

The returned object should be treated as read-only. It is a fresh instance and mutating it + * via the setters has no effect on the source credential, but doing so will break the + * consistency guarantee for the holder of the snapshot. + * + * @return a point-in-time copy of the credential triple, with no attached updater. + */ + public Credential getSnapshot() { + synchronized (this) { + tryUpdate(); + return new Credential(secretId, secretKey, token); + } + } + + private void tryUpdate() { + if (updater == null) { + return; + } + + try { + updater.update(this); + } catch (TencentCloudSDKException e) { + // wrap as RuntimeException to keep API consistent + throw new RuntimeException(e); + } + } + + public interface Updater { + void update(Credential credential) throws TencentCloudSDKException; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java b/src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java new file mode 100644 index 0000000000..eb04f1a8b4 --- /dev/null +++ b/src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import okhttp3.*; + +import javax.net.ssl.SSLException; +import java.io.IOException; +import java.net.*; +import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Selects a healthy API host via per-host circuit breakers. + * No same-request retry — API calls may be non-idempotent. + * All breakers open → fall through to origin host. + */ +class EndpointFailoverInterceptor implements Interceptor { + + /** More-specific families first — matchFamily returns first hit. */ + static final String[][] FAILOVER_DOMAIN_FAMILIES = { + { + "ai.tencentcloudapi.com", + "ai.tencentcloudapi.com.cn", + "ai.tencentcloudapi.cn", + }, + { + "internal.tencentcloudapi.com", + "internal.tencentcloudapi.com.cn", + "internal.tencentcloudapi.cn", + }, + { + "tencentcloudapi.com", + "tencentcloudapi.com.cn", + "tencentcloudapi.cn", + }, + }; + + public static long BREAKER_TIMEOUT_MS = 60 * 1000; + + private final AbstractClient client; + private final String backupEndpoint; + // Per AbstractClient instance. + private final ConcurrentHashMap breakers = + new ConcurrentHashMap(); + + /** + * Stores the region breaker set via {@link AbstractClient#setRegionBreaker} + * so its settings are applied to all per-host breakers created here. + */ + private CircuitBreaker regionBreaker; + + EndpointFailoverInterceptor(AbstractClient client) { + this.client = client; + String bp = client.getClientProfile().getBackupEndpoint(); + this.backupEndpoint = (bp != null && !bp.isEmpty()) ? bp : null; + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + String originHost = request.url().host(); + + // selectHost always returns a non-null Candidate. + Candidate c = selectHost(request); + + try { + // rewriteFor returns the original request unchanged when c.host == originHost. + Request rewritten = rewriteFor(request, originHost, c.host); + Response raw = chain.proceed(rewritten); + Response validated = validateResponse(raw); + c.token.report(true); + return validated; + } catch (TencentCloudSDKException e) { + throw new IOException("Failed to re-sign request for failover: " + e.getMessage(), e); + } catch (IOException e) { + if (!shouldFailover(e)) { + throw e; + } + c.token.report(false); + throw e; + } + } + + // --- Candidate selection --- + + /** + * Returns the first candidate whose breaker is closed, or the origin host + * when all breakers are open. + */ + Candidate selectHost(Request request) { + String urlHost = request.url().host(); + + // Fast path: check the origin host's breaker first. In the common case + // the origin is healthy, so we avoid allocating the candidate list entirely. + CircuitBreaker.Token originToken = breakerFor(urlHost).allow(); + if (originToken.allowed) { + return new Candidate(urlHost, originToken); + } + + // Origin host is tripped — build the failover candidates (excluding the + // origin, which we already checked) and walk them. + List candidates = buildCandidateHosts(urlHost); + for (int i = 0; i < candidates.size(); i++) { + String host = candidates.get(i); + CircuitBreaker.Token token = breakerFor(host).allow(); + if (token.allowed) { + return new Candidate(host, token); + } + } + // All breakers open: fall back to origin host. + return new Candidate(urlHost, originToken); + } + + /** + * Ordered failover candidates (excluding the origin host, which is checked + * first in {@link #selectHost}). Priority: backup endpoint, then TLD-family rotation. + */ + private List buildCandidateHosts(String urlHost) { + String prefix = serviceOf(urlHost); + if (prefix == null || prefix.isEmpty()) { + return Collections.emptyList(); + } + + // 1. backup endpoint + if (backupEndpoint != null) { + return Collections.singletonList(prefix + "." + backupEndpoint); + } + + // 2. Default TLD-family rotation + Match match = matchFamily(urlHost); + if (match == null) { + return Collections.emptyList(); + } + List hosts = new ArrayList(); + for (int i = 1; i < match.family.length; i++) { + int tldIdx = (match.tldIdx + i) % match.family.length; + hosts.add(prefix + "." + match.family[tldIdx]); + } + return hosts; + } + + CircuitBreaker breakerFor(String host) { + CircuitBreaker existing = breakers.get(host); + if (existing != null) { + return existing; + } + CircuitBreaker created = newBreaker(); + CircuitBreaker prev = breakers.putIfAbsent(host, created); + return prev != null ? prev : created; + } + + /** Test hook. */ + void putBreakerForTesting(String host, CircuitBreaker breaker) { + breakers.put(host, breaker); + } + + private CircuitBreaker newBreaker() { + if (regionBreaker != null) { + return new CircuitBreaker(regionBreaker.getSetting()); + } + CircuitBreaker.Setting s = new CircuitBreaker.Setting(); + s.timeoutMs = BREAKER_TIMEOUT_MS; + return new CircuitBreaker(s); + } + + /** Package-private: store the region breaker instance. */ + void setRegionBreaker(CircuitBreaker regionBreaker) { + this.regionBreaker = regionBreaker; + } + + /** Package-private: return the region breaker instance. */ + CircuitBreaker getRegionBreaker() { + return regionBreaker; + } + + // Per-candidate helpers. + + private Request rewriteFor(Request request, String originHost, String targetHost) + throws TencentCloudSDKException, IOException { + if (originHost.equals(targetHost)) { + return request; + } + return RequestBuilder.from(request) + .fromClient(client) + .withUrlHost(targetHost) + .withUrlSchemeHttps() + .withHost(targetHost) + .build(); + } + + static final class Candidate { + final String host; + final CircuitBreaker.Token token; + + Candidate(String host, CircuitBreaker.Token token) { + this.host = host; + this.token = token; + } + } + + // --- Host classification & TLD family matching --- + + static boolean isKnownTencentCloudHost(String host) { + return matchFamily(host) != null; + } + + /** Returns null if host matches no family. */ + static Match matchFamily(String host) { + if (host == null) { + return null; + } + for (int familyIdx = 0; familyIdx < FAILOVER_DOMAIN_FAMILIES.length; familyIdx++) { + Match m = tryMatchFamily(host, FAILOVER_DOMAIN_FAMILIES[familyIdx]); + if (m != null) { + return m; + } + } + return null; + } + + private static Match tryMatchFamily(String host, String[] family) { + for (int tldIdx = 0; tldIdx < family.length; tldIdx++) { + String suffix = family[tldIdx]; + if (!host.endsWith("." + suffix)) { + continue; + } + String prefix = host.substring(0, host.length() - suffix.length() - 1); + if (prefix.isEmpty() || hasEmptyLabel(prefix)) { + return null; + } + return new Match(family, prefix, tldIdx); + } + return null; + } + + // "foo..bar" → malformed hostname. + private static boolean hasEmptyLabel(String prefix) { + if (prefix.startsWith(".") || prefix.endsWith(".")) { + return true; + } + for (int i = 0; i < prefix.length() - 1; i++) { + if (prefix.charAt(i) == '.' && prefix.charAt(i + 1) == '.') { + return true; + } + } + return false; + } + + static final class Match { + final String[] family; + final String prefix; + final int tldIdx; + + Match(String[] family, String prefix, int tldIdx) { + this.family = family; + this.prefix = prefix; + this.tldIdx = tldIdx; + } + } + + /** Test hook. */ + static String hostWithTld(String originHost, int newTldIdx) { + Match m = matchFamily(originHost); + return serviceOf(originHost) + "." + m.family[newTldIdx]; + } + + private static String serviceOf(String host) { + int dot = host.indexOf('.'); + return dot < 0 ? host : host.substring(0, dot); + } + + // Failure classification. + + // Host is unreachable or compromised — not a transient application error. + private static boolean shouldFailover(IOException e) { + return e instanceof UnknownHostException + || e instanceof SSLException + || e instanceof SocketException + || e instanceof SocketTimeoutException + || e instanceof UnhealthyResponseException; + } + + // Wraps non-200 or invalid-JSON responses so validateResponse's caller + // records them as breaker failures alongside transport errors. + private static final class UnhealthyResponseException extends IOException { + UnhealthyResponseException(String message) { + super(message); + } + } + + /** + * Validates response is healthy based solely on the HTTP status code. + * Body content is not inspected — business-level errors (HTTP 200 + JSON + * error body) are handled by the response parser, not by the failover + * breaker. Non-200 responses are recorded as breaker failures. + */ + private static Response validateResponse(Response resp) throws IOException { + if (resp.code() != 200) { + String msg = "HTTP " + resp.code() + " " + resp.message(); + resp.close(); + throw new UnhealthyResponseException(msg); + } + return resp; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/JsonResponseErrModel.java b/src/main/java/com/tencentcloudapi/common/JsonResponseErrModel.java index 9902706765..d72b2a5e0c 100644 --- a/src/main/java/com/tencentcloudapi/common/JsonResponseErrModel.java +++ b/src/main/java/com/tencentcloudapi/common/JsonResponseErrModel.java @@ -1,41 +1,41 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -public class JsonResponseErrModel { - @SerializedName("RequestId") - @Expose - public String requestId; - - @SerializedName("Error") - @Expose - public ErrorInfo error; - - class ErrorInfo { - @SerializedName("Code") - @Expose - public String code; - - @Expose - @SerializedName("Message") - public String message; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +public class JsonResponseErrModel { + @SerializedName("RequestId") + @Expose + public String requestId; + + @SerializedName("Error") + @Expose + public ErrorInfo error; + + class ErrorInfo { + @SerializedName("Code") + @Expose + public String code; + + @Expose + @SerializedName("Message") + public String message; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/JsonResponseModel.java b/src/main/java/com/tencentcloudapi/common/JsonResponseModel.java index 54af3dcd22..8c10cef954 100644 --- a/src/main/java/com/tencentcloudapi/common/JsonResponseModel.java +++ b/src/main/java/com/tencentcloudapi/common/JsonResponseModel.java @@ -1,27 +1,27 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common; - -import com.google.gson.annotations.Expose; -import com.google.gson.annotations.SerializedName; - -public class JsonResponseModel { - @SerializedName("Response") - @Expose - public T response; -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +public class JsonResponseModel { + @SerializedName("Response") + @Expose + public T response; +} diff --git a/src/main/java/com/tencentcloudapi/common/RequestBuilder.java b/src/main/java/com/tencentcloudapi/common/RequestBuilder.java new file mode 100644 index 0000000000..47a2e7091f --- /dev/null +++ b/src/main/java/com/tencentcloudapi/common/RequestBuilder.java @@ -0,0 +1,715 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import okhttp3.*; +import okio.Buffer; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * Holds all parameters for one cloud API call and builds a signed OkHttp {@link Request}. + * + *

Mirrors the Go SDK requestBuilder: {@code url} is the single source of truth for + * transport, {@code host} signs the canonical Host header. Failover swaps both + * {@code url.host} and {@code host}, then rebuilds.

+ */ +final class RequestBuilder { + static final String SIGN_SKIP = "SKIP"; + + private enum ContentType { + JSON("application/json"), + FORM("application/x-www-form-urlencoded"), + OCTET_STREAM("application/octet-stream"); + + private final String wire; + + ContentType(String wire) { + this.wire = wire; + } + + String wire() { + return wire; + } + } + + interface NowProvider { + long currentTimeMillis(); + } + + private static final NowProvider SYSTEM_NOW = new NowProvider() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + }; + + // API identity + private String service; + private String version; + private String action; + private String region; + + // Transport: single source of truth; resign only swaps url.host + private HttpUrl url; + // host signs the canonical Host header; may differ from url.host + private String host; + private String method; + + // Payload + private Object payload; + private ContentType contentType; + + // Caller headers (excluded from signing) + private Headers headers = new Headers.Builder().build(); + + // Signing + private Credential credential; + private String signMethod; + private boolean unsignedPayload; + private String language; + private String requestClient; + + private NowProvider now = SYSTEM_NOW; + private long nonce; + + static RequestBuilder create() { + return new RequestBuilder(); + } + + RequestBuilder(Request original) throws IOException, TencentCloudSDKException { + this.url = original.url(); + this.host = hostHeaderOf(original); + this.method = original.method(); + this.contentType = contentTypeFromHeader(original.header("Content-Type"), original.method()); + this.payload = payloadFromRequest(original, this.contentType); + this.headers = callerHeadersFrom(original); + this.action = original.header("X-TC-Action"); + this.version = original.header("X-TC-Version"); + this.region = original.header("X-TC-Region"); + this.language = original.header("X-TC-Language"); + this.requestClient = original.header("X-TC-RequestClient"); + this.unsignedPayload = "UNSIGNED-PAYLOAD".equals(original.header("X-TC-Content-SHA256")); + if (SIGN_SKIP.equals(original.header("Authorization"))) { + this.signMethod = SIGN_SKIP; + } + Map params = paramsIfAvailable(this.payload); + if (params != null) { + if (this.action == null) this.action = params.get("Action"); + if (this.version == null) this.version = params.get("Version"); + if (this.region == null) this.region = params.get("Region"); + if (this.requestClient == null) this.requestClient = params.get("RequestClient"); + } + } + + static RequestBuilder from(Request request) throws IOException, TencentCloudSDKException { + RequestBuilder tagged = request.tag(RequestBuilder.class); + return tagged == null ? new RequestBuilder(request) : tagged.copy(); + } + + RequestBuilder withCredential(Credential credential) { + this.credential = credential; + return this; + } + + RequestBuilder withSignMethod(String signMethod) { + this.signMethod = signMethod; + return this; + } + + RequestBuilder withUnsignedPayload(boolean unsignedPayload) { + this.unsignedPayload = unsignedPayload; + return this; + } + + RequestBuilder withHost(String host) { + this.host = host; + return this; + } + + /** Swaps url.host to targetHost (transport target), matching Go's rb.url.Host = targetHost. */ + RequestBuilder withUrlHost(String targetHost) { + if (this.url == null) { + throw new IllegalStateException("url must be set before withUrlHost"); + } + this.url = this.url.newBuilder().host(targetHost).build(); + return this; + } + + /** Forces url.scheme to https — failover endpoints are https-only. */ + RequestBuilder withUrlSchemeHttps() { + if (this.url == null) { + throw new IllegalStateException("url must be set before withUrlSchemeHttps"); + } + this.url = this.url.newBuilder().scheme("https").build(); + return this; + } + + /** Overrides the full transport target. */ + RequestBuilder withURL(URL url) { + this.url = HttpUrl.get(url); + return this; + } + + RequestBuilder withURL(HttpUrl url) { + this.url = url; + return this; + } + + RequestBuilder withMethod(String method) { + this.method = method; + return this; + } + + RequestBuilder withContentType(String headerValue) { + this.contentType = contentTypeFromHeader(headerValue, method); + return this; + } + + RequestBuilder withContentTypeJson() { + this.contentType = ContentType.JSON; + return this; + } + + RequestBuilder withContentTypeForm() { + this.contentType = ContentType.FORM; + return this; + } + + RequestBuilder withContentTypeOctetStream() { + this.contentType = ContentType.OCTET_STREAM; + return this; + } + + RequestBuilder withService(String service) { + this.service = service; + return this; + } + + RequestBuilder withVersion(String version) { + this.version = version; + return this; + } + + RequestBuilder withAction(String action) { + this.action = action; + return this; + } + + RequestBuilder withRegion(String region) { + this.region = region; + return this; + } + + RequestBuilder withPayload(Object payload) { + this.payload = payload; + return this; + } + + RequestBuilder withHeaders(Headers headers) { + this.headers = headers == null ? new Headers.Builder().build() : headers; + return this; + } + + RequestBuilder withLanguage(String language) { + this.language = language; + return this; + } + + RequestBuilder withRequestClient(String requestClient) { + this.requestClient = requestClient; + return this; + } + + RequestBuilder withNow(NowProvider now) { + this.now = now == null ? SYSTEM_NOW : now; + return this; + } + + RequestBuilder withNonce(long nonce) { + this.nonce = nonce; + return this; + } + + RequestBuilder fromClient(AbstractClient client) { + ClientProfile profile = client.getClientProfile(); + return withCredential(client.getCredential()) + .withSignMethod(this.signMethod != null ? this.signMethod : profile.getSignMethod()) + .withUnsignedPayload(profile.isUnsignedPayload() || this.unsignedPayload) + .withService(client.getServiceNameForFailover()) + .withRegion(client.getRegion()) + .withLanguage(profile.getLanguage() == null ? null : profile.getLanguage().getValue()) + .withRequestClient(this.requestClient != null ? this.requestClient : AbstractClient.SDK_VERSION); + } + + Request build() throws TencentCloudSDKException, IOException { + if (method == null) { + method = HttpProfile.REQ_POST; + } + if (contentType == null) { + contentType = HttpProfile.REQ_GET.equalsIgnoreCase(method) ? ContentType.FORM : ContentType.JSON; + } + if (signMethod == null) { + throw new TencentCloudSDKException("sign method must be set"); + } + if (!SIGN_SKIP.equals(signMethod) + && !ClientProfile.SIGN_TC3_256.equals(signMethod) + && !ClientProfile.SIGN_SHA256.equals(signMethod) + && !ClientProfile.SIGN_SHA1.equals(signMethod)) { + throw new TencentCloudSDKException( + "Signature method " + signMethod + " is invalid or not supported yet."); + } + if (!SIGN_SKIP.equals(signMethod) && credential == null) { + throw new TencentCloudSDKException("credential must be set"); + } + if (ClientProfile.SIGN_TC3_256.equals(signMethod)) { + return buildV3(); + } + if (ClientProfile.SIGN_SHA256.equals(signMethod) || ClientProfile.SIGN_SHA1.equals(signMethod)) { + return buildV1(signMethod); + } + return buildSkip(); + } + + // ---------- V3 (TC3-HMAC-SHA256) ---------- + + private Request buildV3() throws TencentCloudSDKException, IOException { + long millis = now.currentTimeMillis(); + String timestamp = String.valueOf(millis / 1000); + String date = utcDate(timestamp); + + BodyAndQuery bq = v3BodyAndQuery(); + String signingHost = resolvedHost(); + String serviceName = resolvedService(signingHost); + String ctStr = contentType.wire(); + + String canonicalUri = encodedPath(url); + String canonicalHeaders = "content-type:" + ctStr + "\nhost:" + signingHost + "\n"; + String signedHeaders = "content-type;host"; + String hashedRequestPayload = unsignedPayload + ? Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8)) + : Sign.sha256Hex(bq.body); + String canonicalRequest = method + "\n" + + canonicalUri + "\n" + + bq.query + "\n" + + canonicalHeaders + "\n" + + signedHeaders + "\n" + + hashedRequestPayload; + + String credentialScope = date + "/" + serviceName + "/tc3_request"; + String stringToSign = "TC3-HMAC-SHA256\n" + timestamp + "\n" + + credentialScope + "\n" + + Sign.sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8)); + + byte[] secretDate = Sign.hmac256( + ("TC3" + credential.getSecretKey()).getBytes(StandardCharsets.UTF_8), date); + byte[] secretService = Sign.hmac256(secretDate, serviceName); + byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); + String signature = DatatypeConverter + .printHexBinary(Sign.hmac256(secretSigning, stringToSign)) + .toLowerCase(); + String authorization = "TC3-HMAC-SHA256 " + + "Credential=" + credential.getSecretId() + "/" + credentialScope + ", " + + "SignedHeaders=" + signedHeaders + ", " + + "Signature=" + signature; + + Headers.Builder hb = signedHeaderBuilder(ctStr, signingHost, timestamp); + hb.set("Authorization", authorization); + addCallerHeaders(hb); + return newRequest(bq, hb.build()); + } + + // ---------- V1 (HmacSHA1 / HmacSHA256) ---------- + + private Request buildV1(String sm) throws TencentCloudSDKException, IOException { + if (!HttpProfile.REQ_GET.equalsIgnoreCase(method) && !HttpProfile.REQ_POST.equalsIgnoreCase(method)) { + throw new TencentCloudSDKException("Method only support (GET, POST) for Hmac sign"); + } + Map params = paramsFromPayload(); + putIfNotNull(params, "Action", action); + putIfNotNull(params, "Version", version); + putIfNotNull(params, "Region", region); + params.put("Timestamp", String.valueOf(now.currentTimeMillis() / 1000)); + params.put("Nonce", String.valueOf(nonce)); + putIfNotNull(params, "RequestClient", requestClient); + + String secretId = credential.getSecretId(); + String secretKey = credential.getSecretKey(); + String token = credential.getToken(); + if (secretId != null && !secretId.isEmpty()) { + params.put("SecretId", secretId); + } + params.put("SignatureMethod", sm); + if (token != null && !token.isEmpty()) { + params.put("Token", token); + } else { + params.remove("Token"); + } + params.remove("Signature"); + + String signingHost = resolvedHost(); + String stringToSign = Sign.makeSignPlainText( + new TreeMap(params), method, signingHost, encodedPath(url)); + String signature = Sign.sign(secretKey, stringToSign, sm); + params.put("Signature", signature); + + HttpUrl outUrl = url == null + ? new HttpUrl.Builder().scheme("https").host(signingHost).addPathSegment("").build() + : url; + Request.Builder rb = new Request.Builder(); + if (HttpProfile.REQ_GET.equalsIgnoreCase(method)) { + rb.url(outUrl.newBuilder().encodedQuery(encodedParams(params)).build()).get(); + } else { + rb.url(outUrl).post(RequestBody.create( + MediaType.parse(ContentType.FORM.wire()), encodedParams(params))); + } + Headers.Builder hb = new Headers.Builder(); + hb.set("Content-Type", ContentType.FORM.wire()); + hb.set("Host", signingHost); + addCallerHeaders(hb); + return rb.headers(hb.build()).tag(RequestBuilder.class, copy()).build(); + } + + // ---------- SKIP (no signature) ---------- + + private Request buildSkip() throws TencentCloudSDKException, IOException { + BodyAndQuery bq = v3BodyAndQuery(); + String signingHost = resolvedHost(); + Headers.Builder hb = signedHeaderBuilder(contentType.wire(), signingHost, + String.valueOf(now.currentTimeMillis() / 1000)); + hb.set("Authorization", SIGN_SKIP); + addCallerHeaders(hb); + return newRequest(bq, hb.build()); + } + + // ---------- Shared helpers ---------- + + private Headers.Builder signedHeaderBuilder(String ctStr, String signingHost, String timestamp) { + Headers.Builder hb = new Headers.Builder(); + hb.set("Content-Type", ctStr); + hb.set("Host", signingHost); + putHeaderIfNotEmpty(hb, "X-TC-Action", action); + putHeaderIfNotEmpty(hb, "X-TC-Version", version); + hb.set("X-TC-Timestamp", timestamp); + putHeaderIfNotEmpty(hb, "X-TC-RequestClient", requestClient); + putHeaderIfNotEmpty(hb, "X-TC-Language", language); + putHeaderIfNotEmpty(hb, "X-TC-Region", region); + if (credential != null) { + String token = credential.getToken(); + if (token != null && !token.isEmpty()) { + hb.set("X-TC-Token", token); + } + } + if (unsignedPayload) { + hb.set("X-TC-Content-SHA256", "UNSIGNED-PAYLOAD"); + } + return hb; + } + + private Request newRequest(BodyAndQuery bq, Headers headers) { + HttpUrl outUrl = url; + if (outUrl == null) { + String signingHost = resolvedHost(); + outUrl = new HttpUrl.Builder().scheme("https").host(signingHost).addPathSegment("").build(); + } + if (bq.query != null && !bq.query.isEmpty()) { + outUrl = outUrl.newBuilder().encodedQuery(bq.query).build(); + } + RequestBody body = null; + if (!HttpProfile.REQ_GET.equalsIgnoreCase(method) && bq.body.length > 0) { + body = RequestBody.create(MediaType.parse(contentType.wire()), bq.body); + } + Request.Builder rb = new Request.Builder().url(outUrl).headers(headers).tag(RequestBuilder.class, copy()); + if (HttpProfile.REQ_GET.equalsIgnoreCase(method)) { + rb.get(); + } else { + rb.method(method, body == null ? RequestBody.create(null, new byte[0]) : body); + } + return rb.build(); + } + + private BodyAndQuery v3BodyAndQuery() throws TencentCloudSDKException { + if (HttpProfile.REQ_GET.equalsIgnoreCase(method)) { + Map params = paramsFromPayload(); + for (String key : new String[]{"Action", "Version", "Region", "Timestamp", "Nonce", "RequestClient"}) { + params.remove(key); + } + return new BodyAndQuery(new byte[0], encodedParams(params)); + } + + switch (contentType) { + case JSON: + if (payload instanceof byte[]) { + return new BodyAndQuery((byte[]) payload, ""); + } + if (payload instanceof String) { + return new BodyAndQuery(((String) payload).getBytes(StandardCharsets.UTF_8), ""); + } + if (payload instanceof AbstractModel) { + return new BodyAndQuery(AbstractModel.toJsonString((AbstractModel) payload) + .getBytes(StandardCharsets.UTF_8), ""); + } + return new BodyAndQuery(String.valueOf(payload).getBytes(StandardCharsets.UTF_8), ""); + case FORM: + return new BodyAndQuery(encodedParams(paramsFromPayload()).getBytes(StandardCharsets.UTF_8), ""); + case OCTET_STREAM: + if (!(payload instanceof byte[])) { + throw new TencentCloudSDKException( + "octet-stream payload must be byte[], got " + payload.getClass().getName()); + } + return new BodyAndQuery((byte[]) payload, ""); + default: + throw new TencentCloudSDKException("unsupported content type"); + } + } + + private Map paramsFromPayload() throws TencentCloudSDKException { + Map existing = paramsIfAvailable(payload); + if (existing != null) { + return new LinkedHashMap(existing); + } + if (payload instanceof AbstractModel) { + HashMap params = new HashMap(); + ((AbstractModel) payload).toMap(params, ""); + return params; + } + if (payload instanceof byte[]) { + return decodeFormParams(new String((byte[]) payload, StandardCharsets.UTF_8)); + } + throw new TencentCloudSDKException("cannot derive params from payload type " + + (payload == null ? "null" : payload.getClass().getName())); + } + + @SuppressWarnings("unchecked") + private static Map paramsIfAvailable(Object payload) { + if (payload instanceof Map) { + Map in = (Map) payload; + LinkedHashMap out = new LinkedHashMap(); + for (Map.Entry e : in.entrySet()) { + out.put(String.valueOf(e.getKey()), e.getValue() == null ? "" : String.valueOf(e.getValue())); + } + return out; + } + return null; + } + + private String resolvedHost() { + return host != null && !host.isEmpty() ? host : (url != null ? url.host() : ""); + } + + private String resolvedService(String signingHost) { + if (service != null && !service.isEmpty()) { + return service; + } + int dot = signingHost.indexOf('.'); + return dot < 0 ? signingHost : signingHost.substring(0, dot); + } + + private RequestBuilder copy() { + RequestBuilder rb = new RequestBuilder(); + rb.service = service; + rb.version = version; + rb.action = action; + rb.region = region; + rb.url = url; + rb.host = host; + rb.method = method; + rb.payload = copyPayload(payload); + rb.contentType = contentType; + rb.headers = headers; + rb.credential = credential; + rb.signMethod = signMethod; + rb.unsignedPayload = unsignedPayload; + rb.language = language; + rb.requestClient = requestClient; + rb.now = now; + rb.nonce = nonce; + return rb; + } + + private RequestBuilder() { + } + + private static Object copyPayload(Object payload) { + if (payload instanceof byte[]) { + byte[] src = (byte[]) payload; + return Arrays.copyOf(src, src.length); + } + if (payload instanceof Map) { + return new LinkedHashMap((Map) payload); + } + return payload; + } + + private void addCallerHeaders(Headers.Builder hb) { + for (int i = 0, n = headers.size(); i < n; i++) { + hb.add(headers.name(i), headers.value(i)); + } + } + + private static void putHeaderIfNotEmpty(Headers.Builder hb, String name, String value) { + if (value != null && !value.isEmpty()) { + hb.set(name, value); + } + } + + private static void putIfNotNull(Map params, String key, String value) { + if (value != null) { + params.put(key, value); + } + } + + private static String hostHeaderOf(Request request) { + String h = request.header("Host"); + return (h != null && !h.isEmpty()) ? h : request.url().host(); + } + + private static Object payloadFromRequest(Request request, ContentType contentType) + throws IOException, TencentCloudSDKException { + if (HttpProfile.REQ_GET.equalsIgnoreCase(request.method())) { + return decodeQueryParams(request.url()); + } + RequestBody body = request.body(); + if (body == null) { + return new byte[0]; + } + Buffer buffer = new Buffer(); + body.writeTo(buffer); + byte[] bodyBytes = buffer.readByteArray(); + if (contentType == ContentType.FORM) { + return decodeFormParams(new String(bodyBytes, StandardCharsets.UTF_8)); + } + return bodyBytes; + } + + private static ContentType contentTypeFromHeader(String header, String method) { + if (HttpProfile.REQ_GET.equalsIgnoreCase(method)) { + return ContentType.FORM; + } + if (header != null && header.toLowerCase(Locale.ROOT).contains("application/octet-stream")) { + return ContentType.OCTET_STREAM; + } + if (header != null && header.toLowerCase(Locale.ROOT).contains("application/x-www-form-urlencoded")) { + return ContentType.FORM; + } + return ContentType.JSON; + } + + private static Headers callerHeadersFrom(Request request) { + Headers.Builder hb = new Headers.Builder(); + Headers h = request.headers(); + for (int i = 0, n = h.size(); i < n; i++) { + String name = h.name(i); + if (isSdkHeader(name)) { + continue; + } + hb.add(name, h.value(i)); + } + return hb.build(); + } + + private static boolean isSdkHeader(String name) { + return name.equalsIgnoreCase("Host") + || name.equalsIgnoreCase("Content-Type") + || name.equalsIgnoreCase("Authorization") + || name.equalsIgnoreCase("X-TC-Action") + || name.equalsIgnoreCase("X-TC-Version") + || name.equalsIgnoreCase("X-TC-Timestamp") + || name.equalsIgnoreCase("X-TC-RequestClient") + || name.equalsIgnoreCase("X-TC-Language") + || name.equalsIgnoreCase("X-TC-Region") + || name.equalsIgnoreCase("X-TC-Token") + || name.equalsIgnoreCase("X-TC-Content-SHA256"); + } + + private static String encodedPath(HttpUrl url) { + String p = url.encodedPath(); + return p == null || p.isEmpty() ? "/" : p; + } + + private static String utcDate(String timestamp) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return sdf.format(new Date(Long.valueOf(timestamp + "000"))); + } + + private static String encodedParams(Map params) throws TencentCloudSDKException { + TreeMap sorted = new TreeMap(params); + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : sorted.entrySet()) { + try { + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(URLEncoder.encode(e.getKey(), "utf-8")) + .append("=") + .append(URLEncoder.encode(e.getValue(), "utf-8")); + } catch (UnsupportedEncodingException ex) { + throw new TencentCloudSDKException("UTF-8 not supported", ex); + } + } + return sb.toString(); + } + + private static Map decodeQueryParams(HttpUrl url) { + LinkedHashMap map = new LinkedHashMap(); + for (int i = 0, n = url.querySize(); i < n; i++) { + String value = url.queryParameterValue(i); + map.put(url.queryParameterName(i), value == null ? "" : value); + } + return map; + } + + private static Map decodeFormParams(String body) + throws TencentCloudSDKException { + LinkedHashMap map = new LinkedHashMap(); + if (body == null || body.isEmpty()) { + return map; + } + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + String k = eq < 0 ? pair : pair.substring(0, eq); + String v = eq < 0 ? "" : pair.substring(eq + 1); + try { + map.put(URLDecoder.decode(k, "utf-8"), URLDecoder.decode(v, "utf-8")); + } catch (UnsupportedEncodingException e) { + throw new TencentCloudSDKException("UTF-8 not supported", e); + } + } + return map; + } + + private static final class BodyAndQuery { + final byte[] body; + final String query; + + BodyAndQuery(byte[] body, String query) { + this.body = body == null ? new byte[0] : body; + this.query = query == null ? "" : query; + } + } +} diff --git a/src/main/java/com/tencentcloudapi/common/SSEResponseModel.java b/src/main/java/com/tencentcloudapi/common/SSEResponseModel.java index b495b02e1d..965f7e4a94 100644 --- a/src/main/java/com/tencentcloudapi/common/SSEResponseModel.java +++ b/src/main/java/com/tencentcloudapi/common/SSEResponseModel.java @@ -29,7 +29,6 @@ public abstract class SSEResponseModel extends AbstractModel implements Iterable, Closeable { private Response response; - private CircuitBreaker.Token token; public abstract String getRequestId(); @@ -43,8 +42,15 @@ public boolean isStream() { return this.response != null; } + /** + * No-op since the region-failover CircuitBreaker was folded into + * {@link EndpointFailoverInterceptor}. Kept for binary/source compatibility + * with code compiled against earlier SDK versions. + * + * @deprecated Failover is now handled at the HTTP layer; this token has no effect. + */ + @Deprecated public void setToken(CircuitBreaker.Token token) { - this.token = token; } public static class SSE { diff --git a/src/main/java/com/tencentcloudapi/common/Sign.java b/src/main/java/com/tencentcloudapi/common/Sign.java index ebe1a2e6cb..f622d9b4ac 100644 --- a/src/main/java/com/tencentcloudapi/common/Sign.java +++ b/src/main/java/com/tencentcloudapi/common/Sign.java @@ -1,162 +1,162 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common; - -import com.tencentcloudapi.common.exception.TencentCloudSDKException; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.TreeMap; - -public class Sign { - - // UTF-8 character set - private static final Charset UTF8 = StandardCharsets.UTF_8; - - /** - * Signs the given string using the specified method and secret key. - * - * @param secretKey The secret key used to generate the signature. - * @param sigStr The string to sign. - * @param sigMethod The signing method (e.g., "HmacSHA256"). - * @return The generated signature in Base64 encoding. - * @throws TencentCloudSDKException If there is an error during signing. - */ - public static String sign(String secretKey, String sigStr, String sigMethod) throws TencentCloudSDKException { - String sig = null; - try { - // Create the Mac (Message Authentication Code) instance using the specified method - Mac mac = Mac.getInstance(sigMethod); - byte[] hash; - SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(UTF8), mac.getAlgorithm()); - - mac.init(secretKeySpec); - hash = mac.doFinal(sigStr.getBytes(UTF8)); - sig = DatatypeConverter.printBase64Binary(hash); - } catch (Exception e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - return sig; - } - - /** - * Constructs the plain text string for signing. - * - * @param requestParams The request parameters to include in the string. - * @param reqMethod The HTTP request method (GET, POST, etc.). - * @param host The host (API endpoint). - * @param path The request path. - * @return The plain text string used for signing. - */ - public static String makeSignPlainText(TreeMap requestParams, String reqMethod, String host, - String path) { - String retStr = ""; - retStr += reqMethod; - retStr += host; - retStr += path; - retStr += buildParamStr(requestParams, reqMethod); - return retStr; - } - - /** - * Builds the parameter string for signing, formatting the parameters in a query string format. - * - * @param requestParams The request parameters to include in the string. - * @param requestMethod The HTTP request method (GET, POST, etc.). - * @return The formatted parameter string. - */ - protected static String buildParamStr(TreeMap requestParams, String requestMethod) { - String retStr = ""; - // Iterate through the parameters and append them to the string - for (String key : requestParams.keySet()) { - String value = requestParams.get(key).toString(); - if (retStr.length() == 0) { - retStr += '?'; - } else { - retStr += '&'; - } - retStr += key.replace("_", ".") + '=' + value; - } - return retStr; - } - - /** - * Calculates the SHA-256 hash of the given string and returns it as a hexadecimal string. - * - * @param s The string to hash. - * @return The SHA-256 hash as a hexadecimal string. - * @throws TencentCloudSDKException If SHA-256 is not supported. - */ - public static String sha256Hex(String s) throws TencentCloudSDKException { - MessageDigest md; - try { - md = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new TencentCloudSDKException("SHA-256 is not supported." + e.getMessage()); - } - byte[] d = md.digest(s.getBytes(UTF8)); - return DatatypeConverter.printHexBinary(d).toLowerCase(); - } - - /** - * Calculates the SHA-256 hash of the given byte array and returns it as a hexadecimal string. - * - * @param b The byte array to hash. - * @return The SHA-256 hash as a hexadecimal string. - * @throws TencentCloudSDKException If SHA-256 is not supported. - */ - public static String sha256Hex(byte[] b) throws TencentCloudSDKException { - MessageDigest md; - try { - md = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new TencentCloudSDKException("SHA-256 is not supported." + e.getMessage()); - } - byte[] d = md.digest(b); - return DatatypeConverter.printHexBinary(d).toLowerCase(); - } - - /** - * Calculates the HMAC-SHA-256 signature of the given message using the specified key. - * - * @param key The key to use for HMAC. - * @param msg The message to sign. - * @return The HMAC-SHA-256 signature as a byte array. - * @throws TencentCloudSDKException If HMAC-SHA-256 is not supported. - */ - public static byte[] hmac256(byte[] key, String msg) throws TencentCloudSDKException { - Mac mac; - try { - mac = Mac.getInstance("HmacSHA256"); - } catch (NoSuchAlgorithmException e) { - throw new TencentCloudSDKException("HmacSHA256 is not supported." + e.getMessage()); - } - SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm()); - try { - mac.init(secretKeySpec); - } catch (InvalidKeyException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - return mac.doFinal(msg.getBytes(UTF8)); - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.TreeMap; + +public class Sign { + + // UTF-8 character set + private static final Charset UTF8 = StandardCharsets.UTF_8; + + /** + * Signs the given string using the specified method and secret key. + * + * @param secretKey The secret key used to generate the signature. + * @param sigStr The string to sign. + * @param sigMethod The signing method (e.g., "HmacSHA256"). + * @return The generated signature in Base64 encoding. + * @throws TencentCloudSDKException If there is an error during signing. + */ + public static String sign(String secretKey, String sigStr, String sigMethod) throws TencentCloudSDKException { + String sig = null; + try { + // Create the Mac (Message Authentication Code) instance using the specified method + Mac mac = Mac.getInstance(sigMethod); + byte[] hash; + SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(UTF8), mac.getAlgorithm()); + + mac.init(secretKeySpec); + hash = mac.doFinal(sigStr.getBytes(UTF8)); + sig = DatatypeConverter.printBase64Binary(hash); + } catch (Exception e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + return sig; + } + + /** + * Constructs the plain text string for signing. + * + * @param requestParams The request parameters to include in the string. + * @param reqMethod The HTTP request method (GET, POST, etc.). + * @param host The host (API endpoint). + * @param path The request path. + * @return The plain text string used for signing. + */ + public static String makeSignPlainText(TreeMap requestParams, String reqMethod, String host, + String path) { + String retStr = ""; + retStr += reqMethod; + retStr += host; + retStr += path; + retStr += buildParamStr(requestParams, reqMethod); + return retStr; + } + + /** + * Builds the parameter string for signing, formatting the parameters in a query string format. + * + * @param requestParams The request parameters to include in the string. + * @param requestMethod The HTTP request method (GET, POST, etc.). + * @return The formatted parameter string. + */ + protected static String buildParamStr(TreeMap requestParams, String requestMethod) { + String retStr = ""; + // Iterate through the parameters and append them to the string + for (String key : requestParams.keySet()) { + String value = requestParams.get(key).toString(); + if (retStr.length() == 0) { + retStr += '?'; + } else { + retStr += '&'; + } + retStr += key.replace("_", ".") + '=' + value; + } + return retStr; + } + + /** + * Calculates the SHA-256 hash of the given string and returns it as a hexadecimal string. + * + * @param s The string to hash. + * @return The SHA-256 hash as a hexadecimal string. + * @throws TencentCloudSDKException If SHA-256 is not supported. + */ + public static String sha256Hex(String s) throws TencentCloudSDKException { + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new TencentCloudSDKException("SHA-256 is not supported." + e.getMessage()); + } + byte[] d = md.digest(s.getBytes(UTF8)); + return DatatypeConverter.printHexBinary(d).toLowerCase(); + } + + /** + * Calculates the SHA-256 hash of the given byte array and returns it as a hexadecimal string. + * + * @param b The byte array to hash. + * @return The SHA-256 hash as a hexadecimal string. + * @throws TencentCloudSDKException If SHA-256 is not supported. + */ + public static String sha256Hex(byte[] b) throws TencentCloudSDKException { + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new TencentCloudSDKException("SHA-256 is not supported." + e.getMessage()); + } + byte[] d = md.digest(b); + return DatatypeConverter.printHexBinary(d).toLowerCase(); + } + + /** + * Calculates the HMAC-SHA-256 signature of the given message using the specified key. + * + * @param key The key to use for HMAC. + * @param msg The message to sign. + * @return The HMAC-SHA-256 signature as a byte array. + * @throws TencentCloudSDKException If HMAC-SHA-256 is not supported. + */ + public static byte[] hmac256(byte[] key, String msg) throws TencentCloudSDKException { + Mac mac; + try { + mac = Mac.getInstance("HmacSHA256"); + } catch (NoSuchAlgorithmException e) { + throw new TencentCloudSDKException("HmacSHA256 is not supported." + e.getMessage()); + } + SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm()); + try { + mac.init(secretKeySpec); + } catch (InvalidKeyException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + return mac.doFinal(msg.getBytes(UTF8)); + } +} diff --git a/src/main/java/com/tencentcloudapi/common/exception/TencentCloudSDKException.java b/src/main/java/com/tencentcloudapi/common/exception/TencentCloudSDKException.java index 6f61362ca3..4b73ae590c 100644 --- a/src/main/java/com/tencentcloudapi/common/exception/TencentCloudSDKException.java +++ b/src/main/java/com/tencentcloudapi/common/exception/TencentCloudSDKException.java @@ -1,77 +1,77 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common.exception; - -public class TencentCloudSDKException extends Exception { - private static final long serialVersionUID = 1L; - - /** - * UUID of the request, it will be empty if request is not fulfilled. - */ - private String requestId; - - /** - * Error code, When API returns a failure, it must have an error code. - */ - private String errorCode; - - public TencentCloudSDKException(String message, Throwable cause) { - super(message, cause); - } - - public TencentCloudSDKException(String message) { - this(message, ""); - } - - public TencentCloudSDKException(String message, String requestId) { - this(message, requestId, ""); - } - - public TencentCloudSDKException(String message, String requestId, String errorCode) { - super(message); - this.requestId = requestId; - this.errorCode = errorCode; - } - - public String getRequestId() { - return requestId; - } - - /** - * Get error code - * - * @return A string represents error code - */ - public String getErrorCode() { - return errorCode; - } - - public String toString() { - String msg = "[TencentCloudSDKException]" - + "code: " - + this.getErrorCode() - + " message:" - + this.getMessage() - + " requestId:" - + this.getRequestId(); - if (getCause() != null) { - msg += " cause:" + getCause().toString(); - } - return msg; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common.exception; + +public class TencentCloudSDKException extends Exception { + private static final long serialVersionUID = 1L; + + /** + * UUID of the request, it will be empty if request is not fulfilled. + */ + private String requestId; + + /** + * Error code, When API returns a failure, it must have an error code. + */ + private String errorCode; + + public TencentCloudSDKException(String message, Throwable cause) { + super(message, cause); + } + + public TencentCloudSDKException(String message) { + this(message, ""); + } + + public TencentCloudSDKException(String message, String requestId) { + this(message, requestId, ""); + } + + public TencentCloudSDKException(String message, String requestId, String errorCode) { + super(message); + this.requestId = requestId; + this.errorCode = errorCode; + } + + public String getRequestId() { + return requestId; + } + + /** + * Get error code + * + * @return A string represents error code + */ + public String getErrorCode() { + return errorCode; + } + + public String toString() { + String msg = "[TencentCloudSDKException]" + + "code: " + + this.getErrorCode() + + " message:" + + this.getMessage() + + " requestId:" + + this.getRequestId(); + if (getCause() != null) { + msg += " cause:" + getCause().toString(); + } + return msg; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/http/HttpConnection.java b/src/main/java/com/tencentcloudapi/common/http/HttpConnection.java index f837274e79..fd982f6344 100644 --- a/src/main/java/com/tencentcloudapi/common/http/HttpConnection.java +++ b/src/main/java/com/tencentcloudapi/common/http/HttpConnection.java @@ -1,153 +1,158 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common.http; - -import com.tencentcloudapi.common.exception.TencentCloudSDKException; -import okhttp3.*; - -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; -import java.io.IOException; -import java.net.Proxy; -import java.util.concurrent.TimeUnit; - -public class HttpConnection { - - // https://stackoverflow.com/questions/31423154/performance-of-a-singleton-instance-okhttpclient - // https://github.com/square/okhttp/issues/3372 - // Creating dispatcher and connectionPool is expensive. - // Share them between OkHttpClients by singleton's Builder. - private static final OkHttpClient clientSingleton = new OkHttpClient(); - private OkHttpClient client; - - public HttpConnection(Integer connTimeout, Integer readTimeout, Integer writeTimeout) { - this.client = clientSingleton.newBuilder() - .connectTimeout(connTimeout, TimeUnit.SECONDS) - .readTimeout(readTimeout, TimeUnit.SECONDS) - .writeTimeout(writeTimeout, TimeUnit.SECONDS) - .build(); - } - - public void addInterceptors(Interceptor interceptor) { - this.client = this.client.newBuilder().addInterceptor(interceptor).build(); - } - - public void setProxy(Proxy proxy) { - this.client = this.client.newBuilder().proxy(proxy).build(); - } - - public void setProxyAuthenticator(Authenticator authenticator) { - this.client = this.client.newBuilder().proxyAuthenticator(authenticator).build(); - } - - @Deprecated - public void setSSLSocketFactory(SSLSocketFactory sslSocketFactory) { - this.client = this.client.newBuilder().sslSocketFactory(sslSocketFactory).build(); - } - - public void setSSLSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { - this.client = this.client.newBuilder().sslSocketFactory(sslSocketFactory, trustManager).build(); - } - - public void setHostnameVerifier(HostnameVerifier hostnameVerifier) { - this.client = this.client.newBuilder().hostnameVerifier(hostnameVerifier).build(); - } - - public Response doRequest(Request request) throws IOException { - return this.client.newCall(request).execute(); - } - - public Response getRequest(String url) throws TencentCloudSDKException, IOException { - Request request = null; - try { - request = new Request.Builder().url(url).get().build(); - } catch (IllegalArgumentException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - - return this.doRequest(request); - } - - public Response getRequest(String url, Headers headers) throws TencentCloudSDKException, IOException { - Request request = null; - try { - request = new Request.Builder().url(url).headers(headers).get().build(); - } catch (IllegalArgumentException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - - return this.doRequest(request); - } - - public Response postRequest(String url, String body) throws TencentCloudSDKException, IOException { - MediaType contentType = MediaType.parse("application/x-www-form-urlencoded"); - Request request = null; - try { - request = new Request.Builder().url(url).post(RequestBody.create(contentType, body)).build(); - } catch (IllegalArgumentException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - - return this.doRequest(request); - } - - public Response postRequest(String url, String body, Headers headers) - throws TencentCloudSDKException, IOException { - MediaType contentType = MediaType.parse(headers.get("Content-Type")); - Request request = null; - try { - request = - new Request.Builder() - .url(url) - .post(RequestBody.create(contentType, body)) - .headers(headers) - .build(); - } catch (IllegalArgumentException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - - return this.doRequest(request); - } - - public Response postRequest(String url, byte[] body, Headers headers) - throws TencentCloudSDKException, IOException { - MediaType contentType = MediaType.parse(headers.get("Content-Type")); - Request request = null; - try { - request = - new Request.Builder() - .url(url) - .post(RequestBody.create(contentType, body)) - .headers(headers) - .build(); - } catch (IllegalArgumentException e) { - throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); - } - - return this.doRequest(request); - } - - public void setHttpClient(Object httpClient) { - client = (OkHttpClient) httpClient; - } - - public Object getHttpClient() { - return client; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common.http; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import okhttp3.*; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.net.Proxy; +import java.util.List; +import java.util.concurrent.TimeUnit; + +public class HttpConnection { + + // https://stackoverflow.com/questions/31423154/performance-of-a-singleton-instance-okhttpclient + // https://github.com/square/okhttp/issues/3372 + // Creating dispatcher and connectionPool is expensive. + // Share them between OkHttpClients by singleton's Builder. + private static final OkHttpClient clientSingleton = new OkHttpClient(); + private OkHttpClient client; + + public HttpConnection(Integer connTimeout, Integer readTimeout, Integer writeTimeout) { + this.client = clientSingleton.newBuilder() + .connectTimeout(connTimeout, TimeUnit.SECONDS) + .readTimeout(readTimeout, TimeUnit.SECONDS) + .writeTimeout(writeTimeout, TimeUnit.SECONDS) + .build(); + } + + public void addInterceptors(Interceptor interceptor) { + this.client = this.client.newBuilder().addInterceptor(interceptor).build(); + } + + public List getInterceptors() { + return this.client.interceptors(); + } + + public void setProxy(Proxy proxy) { + this.client = this.client.newBuilder().proxy(proxy).build(); + } + + public void setProxyAuthenticator(Authenticator authenticator) { + this.client = this.client.newBuilder().proxyAuthenticator(authenticator).build(); + } + + @Deprecated + public void setSSLSocketFactory(SSLSocketFactory sslSocketFactory) { + this.client = this.client.newBuilder().sslSocketFactory(sslSocketFactory).build(); + } + + public void setSSLSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { + this.client = this.client.newBuilder().sslSocketFactory(sslSocketFactory, trustManager).build(); + } + + public void setHostnameVerifier(HostnameVerifier hostnameVerifier) { + this.client = this.client.newBuilder().hostnameVerifier(hostnameVerifier).build(); + } + + public Response doRequest(Request request) throws IOException { + return this.client.newCall(request).execute(); + } + + public Response getRequest(String url) throws TencentCloudSDKException, IOException { + Request request = null; + try { + request = new Request.Builder().url(url).get().build(); + } catch (IllegalArgumentException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + + return this.doRequest(request); + } + + public Response getRequest(String url, Headers headers) throws TencentCloudSDKException, IOException { + Request request = null; + try { + request = new Request.Builder().url(url).headers(headers).get().build(); + } catch (IllegalArgumentException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + + return this.doRequest(request); + } + + public Response postRequest(String url, String body) throws TencentCloudSDKException, IOException { + MediaType contentType = MediaType.parse("application/x-www-form-urlencoded"); + Request request = null; + try { + request = new Request.Builder().url(url).post(RequestBody.create(contentType, body)).build(); + } catch (IllegalArgumentException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + + return this.doRequest(request); + } + + public Response postRequest(String url, String body, Headers headers) + throws TencentCloudSDKException, IOException { + MediaType contentType = MediaType.parse(headers.get("Content-Type")); + Request request = null; + try { + request = + new Request.Builder() + .url(url) + .post(RequestBody.create(contentType, body)) + .headers(headers) + .build(); + } catch (IllegalArgumentException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + + return this.doRequest(request); + } + + public Response postRequest(String url, byte[] body, Headers headers) + throws TencentCloudSDKException, IOException { + MediaType contentType = MediaType.parse(headers.get("Content-Type")); + Request request = null; + try { + request = + new Request.Builder() + .url(url) + .post(RequestBody.create(contentType, body)) + .headers(headers) + .build(); + } catch (IllegalArgumentException e) { + throw new TencentCloudSDKException(e.getClass().getName() + "-" + e.getMessage()); + } + + return this.doRequest(request); + } + + public void setHttpClient(Object httpClient) { + client = (OkHttpClient) httpClient; + } + + public Object getHttpClient() { + return client; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java b/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java index c26389200d..b1c71dc3b1 100644 --- a/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java +++ b/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java @@ -1,214 +1,243 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common.profile; - -/** - * ClientProfile represents the profile configuration for a client - * in terms of signature methods, HTTP profile, language settings, - * and other configurations for interacting with Tencent Cloud APIs. - */ -public class ClientProfile { - - /** - * Constant for signature process using HmacSHA1 (version 1). - * This is an older signature method for API requests. - */ - public static final String SIGN_SHA1 = "HmacSHA1"; - - /** - * Constant for signature process using HmacSHA256 (version 1). - * This is a more secure signature method for API requests. - */ - public static final String SIGN_SHA256 = "HmacSHA256"; - - /** - * Constant for signature process using TC3-HMAC-SHA256 (version 3). - * This is the latest and most secure signature method for API requests. - */ - public static final String SIGN_TC3_256 = "TC3-HMAC-SHA256"; - - // HTTP profile associated with the client. - private HttpProfile httpProfile; - - // The method used for signing requests (HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). - private String signMethod; - - /** - * Flag indicating whether the payload (request body) is involved in the signing process. - * If true, the payload will be ignored during signing. - * Default value is false. - */ - private boolean unsignedPayload; - - /** - * Language setting for API responses. - * Valid options: zh-CN (Simplified Chinese), en-US (English). - */ - private Language language; - - // Flag indicating whether to enable debugging (logs for request/response details). - private boolean debug; - - // Backup endpoint for API requests, useful in case the primary endpoint fails. - private String backupEndpoint; - - /** - * Constructor to initialize ClientProfile with a specific signing method and HTTP profile. - * If the signing method is null or empty, it defaults to "TC3-HMAC-SHA256". - * - * @param signMethod The method used for signing the request (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). - * @param httpProfile The HTTP profile containing endpoint and connection settings. - */ - public ClientProfile(String signMethod, HttpProfile httpProfile) { - if (signMethod == null || signMethod.isEmpty()) { - signMethod = SIGN_TC3_256; - } - this.signMethod = signMethod; - this.httpProfile = httpProfile; - this.unsignedPayload = false; - this.language = null; - this.setDebug(false); - } - - /** - * Constructor to initialize ClientProfile with a specific signing method. - * Initializes the HTTP profile to a default new instance. - * - * @param signMethod The method used for signing the request (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). - */ - public ClientProfile(String signMethod) { - this(signMethod, new HttpProfile()); - } - - /** - * Default constructor which initializes the ClientProfile with the default - * signing method "TC3-HMAC-SHA256" and a default HTTP profile. - */ - public ClientProfile() { - this(ClientProfile.SIGN_TC3_256, new HttpProfile()); - } - - /** - * Getter for the signature method used in API requests. - * - * @return The signature method (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). - */ - public String getSignMethod() { - return this.signMethod; - } - - /** - * Setter for the signature method used in API requests. - * - * @param signMethod The signature method (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). - */ - public void setSignMethod(String signMethod) { - this.signMethod = signMethod; - } - - /** - * Getter for the HTTP profile associated with the client. - * - * @return The HTTP profile containing endpoint and connection settings. - */ - public HttpProfile getHttpProfile() { - return this.httpProfile; - } - - /** - * Setter for the HTTP profile associated with the client. - * - * @param httpProfile The new HTTP profile to be set. - */ - public void setHttpProfile(HttpProfile httpProfile) { - this.httpProfile = httpProfile; - } - - /** - * Getter for the flag indicating whether the payload is ignored during signing. - * - * @return true if the payload is ignored during signing, false otherwise. - */ - public boolean isUnsignedPayload() { - return this.unsignedPayload; - } - - /** - * Setter for the flag indicating whether the payload should be ignored during signing. - * This is only relevant for POST requests. - * - * @param flag Set to true if the payload should be ignored, false otherwise. - */ - public void setUnsignedPayload(boolean flag) { - this.unsignedPayload = flag; - } - - /** - * Getter for the language setting of the client. - * - * @return The current language setting (e.g., zh-CN, en-US). - */ - public Language getLanguage() { - return this.language; - } - - /** - * Setter for the language setting of the client. - * - * @param lang The language to set (e.g., zh-CN, en-US). - */ - public void setLanguage(Language lang) { - this.language = lang; - } - - /** - * Getter for the debug flag, which indicates whether debugging is enabled. - * - * @return true if debugging is enabled, false otherwise. - */ - public boolean isDebug() { - return debug; - } - - /** - * Setter for the debug flag, enabling or disabling debugging. - * - * @param debug Set to true to enable debugging, false to disable. - */ - public void setDebug(boolean debug) { - this.debug = debug; - } - - /** - * Getter for the backup endpoint, used when the primary endpoint is unavailable. - * - * @return The backup endpoint URL. - */ - public String getBackupEndpoint() { - return backupEndpoint; - } - - /** - * Setter for the backup endpoint. - * - * @param backupEndpoint The backup endpoint URL to be set. - */ - public void setBackupEndpoint(String backupEndpoint) { - this.backupEndpoint = backupEndpoint; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common.profile; + +/** + * ClientProfile represents the profile configuration for a client + * in terms of signature methods, HTTP profile, language settings, + * and other configurations for interacting with Tencent Cloud APIs. + */ +public class ClientProfile { + + /** + * Constant for signature process using HmacSHA1 (version 1). + * This is an older signature method for API requests. + */ + public static final String SIGN_SHA1 = "HmacSHA1"; + + /** + * Constant for signature process using HmacSHA256 (version 1). + * This is a more secure signature method for API requests. + */ + public static final String SIGN_SHA256 = "HmacSHA256"; + + /** + * Constant for signature process using TC3-HMAC-SHA256 (version 3). + * This is the latest and most secure signature method for API requests. + */ + public static final String SIGN_TC3_256 = "TC3-HMAC-SHA256"; + + // HTTP profile associated with the client. + private HttpProfile httpProfile; + + // The method used for signing requests (HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). + private String signMethod; + + /** + * Flag indicating whether the payload (request body) is involved in the signing process. + * If true, the payload will be ignored during signing. + * Default value is false. + */ + private boolean unsignedPayload; + + /** + * Language setting for API responses. + * Valid options: zh-CN (Simplified Chinese), en-US (English). + */ + private Language language; + + // Flag indicating whether to enable debugging (logs for request/response details). + private boolean debug; + + // Backup endpoint for API requests, useful in case the primary endpoint fails. + private String backupEndpoint; + + /** + * Whether to enable region-level domain failover. When true (default), the + * SDK automatically retries against backup TLDs (e.g. tencentcloudapi.com.cn / + * tencentcloudapi.cn) on DNS / TLS / network reachability failures of the + * primary domain. Custom apigw endpoints, region-pinned hosts, and any host + * the SDK does not recognise are passed through unchanged. + * + *

This field also controls the legacy single-fallback "backup endpoint" + * mode (see {@link #setBackupEndpoint}); both schemes are gated by the same + * switch. + */ + private boolean enableDomainFailover = true; + + /** + * Constructor to initialize ClientProfile with a specific signing method and HTTP profile. + * If the signing method is null or empty, it defaults to "TC3-HMAC-SHA256". + * + * @param signMethod The method used for signing the request (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). + * @param httpProfile The HTTP profile containing endpoint and connection settings. + */ + public ClientProfile(String signMethod, HttpProfile httpProfile) { + if (signMethod == null || signMethod.isEmpty()) { + signMethod = SIGN_TC3_256; + } + this.signMethod = signMethod; + this.httpProfile = httpProfile; + this.unsignedPayload = false; + this.language = null; + this.setDebug(false); + } + + /** + * Constructor to initialize ClientProfile with a specific signing method. + * Initializes the HTTP profile to a default new instance. + * + * @param signMethod The method used for signing the request (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). + */ + public ClientProfile(String signMethod) { + this(signMethod, new HttpProfile()); + } + + /** + * Default constructor which initializes the ClientProfile with the default + * signing method "TC3-HMAC-SHA256" and a default HTTP profile. + */ + public ClientProfile() { + this(ClientProfile.SIGN_TC3_256, new HttpProfile()); + } + + /** + * Getter for the signature method used in API requests. + * + * @return The signature method (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). + */ + public String getSignMethod() { + return this.signMethod; + } + + /** + * Setter for the signature method used in API requests. + * + * @param signMethod The signature method (e.g., HmacSHA1, HmacSHA256, TC3-HMAC-SHA256). + */ + public void setSignMethod(String signMethod) { + this.signMethod = signMethod; + } + + /** + * Getter for the HTTP profile associated with the client. + * + * @return The HTTP profile containing endpoint and connection settings. + */ + public HttpProfile getHttpProfile() { + return this.httpProfile; + } + + /** + * Setter for the HTTP profile associated with the client. + * + * @param httpProfile The new HTTP profile to be set. + */ + public void setHttpProfile(HttpProfile httpProfile) { + this.httpProfile = httpProfile; + } + + /** + * Getter for the flag indicating whether the payload is ignored during signing. + * + * @return true if the payload is ignored during signing, false otherwise. + */ + public boolean isUnsignedPayload() { + return this.unsignedPayload; + } + + /** + * Setter for the flag indicating whether the payload should be ignored during signing. + * This is only relevant for POST requests. + * + * @param flag Set to true if the payload should be ignored, false otherwise. + */ + public void setUnsignedPayload(boolean flag) { + this.unsignedPayload = flag; + } + + /** + * Getter for the language setting of the client. + * + * @return The current language setting (e.g., zh-CN, en-US). + */ + public Language getLanguage() { + return this.language; + } + + /** + * Setter for the language setting of the client. + * + * @param lang The language to set (e.g., zh-CN, en-US). + */ + public void setLanguage(Language lang) { + this.language = lang; + } + + /** + * Getter for the debug flag, which indicates whether debugging is enabled. + * + * @return true if debugging is enabled, false otherwise. + */ + public boolean isDebug() { + return debug; + } + + /** + * Setter for the debug flag, enabling or disabling debugging. + * + * @param debug Set to true to enable debugging, false to disable. + */ + public void setDebug(boolean debug) { + this.debug = debug; + } + + /** + * Getter for the backup endpoint, used when the primary endpoint is unavailable. + * + * @return The backup endpoint URL. + */ + public String getBackupEndpoint() { + return backupEndpoint; + } + + /** + * Setter for the backup endpoint. + * + * @param backupEndpoint The backup endpoint URL to be set. + */ + public void setBackupEndpoint(String backupEndpoint) { + this.backupEndpoint = backupEndpoint; + } + + /** + * @return true if region-level domain failover is enabled (default), false otherwise. + */ + public boolean isEnableDomainFailover() { + return this.enableDomainFailover; + } + + /** + * Enable or disable region-level domain failover. See {@link #enableDomainFailover}. + * + * @param enabled true to enable (default), false to disable. + */ + public void setEnableDomainFailover(boolean enabled) { + this.enableDomainFailover = enabled; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java b/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java index f480adc3c6..d7d7afe1a4 100644 --- a/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java +++ b/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java @@ -1,416 +1,416 @@ -/* - * Copyright (c) 2018 Tencent. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.tencentcloudapi.common.profile; - -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; - -/** - * HttpProfile defines the configuration settings for HTTP requests made by the Tencent Cloud client. - * It includes settings for the request method, endpoint, timeouts, proxy configurations, SSL settings, etc. - */ -public class HttpProfile { - - // Constants for request protocols and methods - public static final String REQ_HTTPS = "https://"; - public static final String REQ_HTTP = "http://"; - public static final String REQ_POST = "POST"; - public static final String REQ_GET = "GET"; - - /** - * Time unit constant: 1 minute equals 60 seconds. - */ - public static final int TM_MINUTE = 60; - - // HTTP method for the request (GET/POST) - private String reqMethod; - - /** - * The endpoint for the API request (e.g., "cvm.tencentcloudapi.com"). - */ - private String endpoint; - - /** - * The root domain of the API (e.g., "tencentcloudapi.com"). - */ - private String rootDomain; - - /** - * The protocol used for the request. Currently, only HTTPS is valid. - */ - private String protocol; - - /** - * Read timeout in seconds. Specifies the time to wait for a server response. - */ - private int readTimeout; - - /** - * Write timeout in seconds. Specifies the time to wait for data to be sent to the server. - */ - private int writeTimeout; - - /** - * Connect timeout in seconds. Specifies the time to wait for a connection to be established. - */ - private int connTimeout; - - /** - * HTTP proxy host, if a proxy is being used. - */ - private String proxyHost; - - /** - * HTTP proxy port, if a proxy is being used. - */ - private int proxyPort; - - /** - * HTTP proxy username, if authentication is required for the proxy. - */ - private String proxyUsername; - - /** - * HTTP proxy password, if authentication is required for the proxy. - */ - private String proxyPassword; - - // SSL-related fields for secure communication - private SSLSocketFactory sslSocketFactory; - private X509TrustManager trustManager; - private HostnameVerifier hostnameVerifier; - - /** - * The API Gateway endpoint, which can be different from the regular API endpoint. - */ - private String apigwEndpoint; - - /** - * For advanced users, an object for fine-grained control over the HTTP client. - * If this is set, other configurations like timeouts and proxy settings will be ignored. - */ - private Object httpClient; - - /** - * Default constructor for HttpProfile. - * Initializes default values for the HTTP profile configuration. - */ - public HttpProfile() { - this.reqMethod = HttpProfile.REQ_POST; - this.endpoint = null; - this.rootDomain = "tencentcloudapi.com"; - this.protocol = HttpProfile.REQ_HTTPS; - this.readTimeout = 0; - this.writeTimeout = 0; - this.connTimeout = HttpProfile.TM_MINUTE; - this.apigwEndpoint = null; - } - - /** - * Get the HTTP request method (e.g., POST or GET). - * - * @return The request method. - */ - public String getReqMethod() { - return this.reqMethod; - } - - /** - * Set the HTTP request method (e.g., POST or GET). - * - * @param reqMethod The HTTP method to set. - */ - public void setReqMethod(String reqMethod) { - this.reqMethod = reqMethod; - } - - /** - * Get the endpoint to which the request is sent. - * - * @return The endpoint (e.g., "cvm.tencentcloudapi.com"). - */ - public String getEndpoint() { - return this.endpoint; - } - - /** - * Set the endpoint for the API request. - * - *

Endpoint is the domain where the request is sent, e.g., [productName].tencentcloudapi.com. - * If you need to request a specific region (e.g., Guangzhou), set it to [productName].ap-guangzhou - * .tencentcloudapi.com. - * - * @param endpoint The endpoint URL. - */ - public void setEndpoint(String endpoint) { - this.endpoint = endpoint; - } - - /** - * Get the read timeout value in seconds. - * - * @return The read timeout in seconds. - */ - public int getReadTimeout() { - return this.readTimeout; - } - - /** - * Set the read timeout value in seconds. This specifies how long to wait for a response from the server. - * - * @param readTimeout The read timeout in seconds. - */ - public void setReadTimeout(int readTimeout) { - this.readTimeout = readTimeout; - } - - /** - * Get the write timeout value in seconds. - * - * @return The write timeout in seconds. - */ - public int getWriteTimeout() { - return this.writeTimeout; - } - - /** - * Set the write timeout value in seconds. This specifies how long to wait for the server to accept the data. - * - * @param writeTimeout The write timeout in seconds. - */ - public void setWriteTimeout(int writeTimeout) { - this.writeTimeout = writeTimeout; - } - - /** - * Get the connect timeout value in seconds. - * - * @return The connection timeout in seconds. - */ - public int getConnTimeout() { - return this.connTimeout; - } - - /** - * Set the connect timeout value in seconds. This specifies how long to wait for a connection to be established. - * - * @param connTimeout The connect timeout in seconds. - */ - public void setConnTimeout(int connTimeout) { - this.connTimeout = connTimeout; - } - - /** - * Get the protocol used for the request (HTTP or HTTPS). - * - * @return The protocol (e.g., "https://"). - */ - public String getProtocol() { - return this.protocol; - } - - /** - * Set the protocol for the request. Currently, only HTTPS is supported. - * - * @param protocol The protocol to use (e.g., "https://"). - */ - public void setProtocol(String protocol) { - this.protocol = protocol; - } - - /** - * Get the proxy host if a proxy is being used. - * - * @return The proxy host. - */ - public String getProxyHost() { - return proxyHost; - } - - /** - * Set the proxy host for HTTP requests. - * - * @param proxyHost The proxy host to set. - */ - public void setProxyHost(String proxyHost) { - this.proxyHost = proxyHost; - } - - /** - * Get the proxy port if a proxy is being used. - * - * @return The proxy port. - */ - public int getProxyPort() { - return proxyPort; - } - - /** - * Set the proxy port for HTTP requests. - * - * @param proxyPort The proxy port to set. - */ - public void setProxyPort(int proxyPort) { - this.proxyPort = proxyPort; - } - - /** - * Get the proxy username for authentication. - * - * @return The proxy username. - */ - public String getProxyUsername() { - return proxyUsername; - } - - /** - * Set the proxy username for authentication. - * - * @param proxyUsername The proxy username to set. - */ - public void setProxyUsername(String proxyUsername) { - this.proxyUsername = proxyUsername; - } - - /** - * Get the proxy password for authentication. - * - * @return The proxy password. - */ - public String getProxyPassword() { - return proxyPassword; - } - - /** - * Set the proxy password for authentication. - * - * @param proxyPassword The proxy password to set. - */ - public void setProxyPassword(String proxyPassword) { - this.proxyPassword = proxyPassword; - } - - /** - * Get the root domain (e.g., "tencentcloudapi.com"). - * - * @return The root domain. - */ - public String getRootDomain() { - return rootDomain; - } - - /** - * Set the root domain of the API (e.g., "tencentcloudapi.com"). - * - * @param rootDomain The root domain to set. - */ - public void setRootDomain(String rootDomain) { - this.rootDomain = rootDomain; - } - - /** - * Get the SSLSocketFactory for SSL connections. - * - * @return The SSL socket factory. - */ - public SSLSocketFactory getSslSocketFactory() { - return sslSocketFactory; - } - - /** - * Set the SSLSocketFactory for SSL connections. - * - * @param sslSocketFactory The SSL socket factory to set. - */ - public void setSslSocketFactory(SSLSocketFactory sslSocketFactory) { - this.sslSocketFactory = sslSocketFactory; - } - - /** - * Get the trust manager for SSL connections. - * - * @return The X509 trust manager. - */ - public X509TrustManager getX509TrustManager() { - return trustManager; - } - - /** - * Set the trust manager for SSL connections. - * - * @param trustManager The X509 trust manager to set. - */ - public void setX509TrustManager(X509TrustManager trustManager) { - this.trustManager = trustManager; - } - - /** - * Get the API Gateway endpoint. - * - * @return The API Gateway endpoint. - */ - public String getApigwEndpoint() { - return apigwEndpoint; - } - - /** - * Set the API Gateway endpoint. - * - * @param apigwEndpoint The API Gateway endpoint to set. - */ - public void setApigwEndpoint(String apigwEndpoint) { - this.apigwEndpoint = apigwEndpoint; - } - - /** - * Get the HostnameVerifier used for hostname verification. - * - * @return The hostname verifier. - */ - public HostnameVerifier getHostnameVerifier() { - return hostnameVerifier; - } - - /** - * Set the HostnameVerifier used for hostname verification. - * - * @param hostnameVerifier The hostname verifier to set. - */ - public void setHostnameVerifier(HostnameVerifier hostnameVerifier) { - this.hostnameVerifier = hostnameVerifier; - } - - /** - * Get the custom HTTP client for advanced configuration. - * - * @return The custom HTTP client. - */ - public Object getHttpClient() { - return httpClient; - } - - /** - * Set a custom HTTP client for advanced configuration. - * - * @param client The custom HTTP client to set. - */ - public void setHttpClient(Object client) { - httpClient = client; - } -} +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.tencentcloudapi.common.profile; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +/** + * HttpProfile defines the configuration settings for HTTP requests made by the Tencent Cloud client. + * It includes settings for the request method, endpoint, timeouts, proxy configurations, SSL settings, etc. + */ +public class HttpProfile { + + // Constants for request protocols and methods + public static final String REQ_HTTPS = "https://"; + public static final String REQ_HTTP = "http://"; + public static final String REQ_POST = "POST"; + public static final String REQ_GET = "GET"; + + /** + * Time unit constant: 1 minute equals 60 seconds. + */ + public static final int TM_MINUTE = 60; + + // HTTP method for the request (GET/POST) + private String reqMethod; + + /** + * The endpoint for the API request (e.g., "cvm.tencentcloudapi.com"). + */ + private String endpoint; + + /** + * The root domain of the API (e.g., "tencentcloudapi.com"). + */ + private String rootDomain; + + /** + * The protocol used for the request. Currently, only HTTPS is valid. + */ + private String protocol; + + /** + * Read timeout in seconds. Specifies the time to wait for a server response. + */ + private int readTimeout; + + /** + * Write timeout in seconds. Specifies the time to wait for data to be sent to the server. + */ + private int writeTimeout; + + /** + * Connect timeout in seconds. Specifies the time to wait for a connection to be established. + */ + private int connTimeout; + + /** + * HTTP proxy host, if a proxy is being used. + */ + private String proxyHost; + + /** + * HTTP proxy port, if a proxy is being used. + */ + private int proxyPort; + + /** + * HTTP proxy username, if authentication is required for the proxy. + */ + private String proxyUsername; + + /** + * HTTP proxy password, if authentication is required for the proxy. + */ + private String proxyPassword; + + // SSL-related fields for secure communication + private SSLSocketFactory sslSocketFactory; + private X509TrustManager trustManager; + private HostnameVerifier hostnameVerifier; + + /** + * The API Gateway endpoint, which can be different from the regular API endpoint. + */ + private String apigwEndpoint; + + /** + * For advanced users, an object for fine-grained control over the HTTP client. + * If this is set, other configurations like timeouts and proxy settings will be ignored. + */ + private Object httpClient; + + /** + * Default constructor for HttpProfile. + * Initializes default values for the HTTP profile configuration. + */ + public HttpProfile() { + this.reqMethod = HttpProfile.REQ_POST; + this.endpoint = null; + this.rootDomain = "tencentcloudapi.com"; + this.protocol = HttpProfile.REQ_HTTPS; + this.readTimeout = 0; + this.writeTimeout = 0; + this.connTimeout = HttpProfile.TM_MINUTE; + this.apigwEndpoint = null; + } + + /** + * Get the HTTP request method (e.g., POST or GET). + * + * @return The request method. + */ + public String getReqMethod() { + return this.reqMethod; + } + + /** + * Set the HTTP request method (e.g., POST or GET). + * + * @param reqMethod The HTTP method to set. + */ + public void setReqMethod(String reqMethod) { + this.reqMethod = reqMethod; + } + + /** + * Get the endpoint to which the request is sent. + * + * @return The endpoint (e.g., "cvm.tencentcloudapi.com"). + */ + public String getEndpoint() { + return this.endpoint; + } + + /** + * Set the endpoint for the API request. + * + *

Endpoint is the domain where the request is sent, e.g., [productName].tencentcloudapi.com. + * If you need to request a specific region (e.g., Guangzhou), set it to [productName].ap-guangzhou + * .tencentcloudapi.com. + * + * @param endpoint The endpoint URL. + */ + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + /** + * Get the read timeout value in seconds. + * + * @return The read timeout in seconds. + */ + public int getReadTimeout() { + return this.readTimeout; + } + + /** + * Set the read timeout value in seconds. This specifies how long to wait for a response from the server. + * + * @param readTimeout The read timeout in seconds. + */ + public void setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + } + + /** + * Get the write timeout value in seconds. + * + * @return The write timeout in seconds. + */ + public int getWriteTimeout() { + return this.writeTimeout; + } + + /** + * Set the write timeout value in seconds. This specifies how long to wait for the server to accept the data. + * + * @param writeTimeout The write timeout in seconds. + */ + public void setWriteTimeout(int writeTimeout) { + this.writeTimeout = writeTimeout; + } + + /** + * Get the connect timeout value in seconds. + * + * @return The connection timeout in seconds. + */ + public int getConnTimeout() { + return this.connTimeout; + } + + /** + * Set the connect timeout value in seconds. This specifies how long to wait for a connection to be established. + * + * @param connTimeout The connect timeout in seconds. + */ + public void setConnTimeout(int connTimeout) { + this.connTimeout = connTimeout; + } + + /** + * Get the protocol used for the request (HTTP or HTTPS). + * + * @return The protocol (e.g., "https://"). + */ + public String getProtocol() { + return this.protocol; + } + + /** + * Set the protocol for the request. Currently, only HTTPS is supported. + * + * @param protocol The protocol to use (e.g., "https://"). + */ + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + /** + * Get the proxy host if a proxy is being used. + * + * @return The proxy host. + */ + public String getProxyHost() { + return proxyHost; + } + + /** + * Set the proxy host for HTTP requests. + * + * @param proxyHost The proxy host to set. + */ + public void setProxyHost(String proxyHost) { + this.proxyHost = proxyHost; + } + + /** + * Get the proxy port if a proxy is being used. + * + * @return The proxy port. + */ + public int getProxyPort() { + return proxyPort; + } + + /** + * Set the proxy port for HTTP requests. + * + * @param proxyPort The proxy port to set. + */ + public void setProxyPort(int proxyPort) { + this.proxyPort = proxyPort; + } + + /** + * Get the proxy username for authentication. + * + * @return The proxy username. + */ + public String getProxyUsername() { + return proxyUsername; + } + + /** + * Set the proxy username for authentication. + * + * @param proxyUsername The proxy username to set. + */ + public void setProxyUsername(String proxyUsername) { + this.proxyUsername = proxyUsername; + } + + /** + * Get the proxy password for authentication. + * + * @return The proxy password. + */ + public String getProxyPassword() { + return proxyPassword; + } + + /** + * Set the proxy password for authentication. + * + * @param proxyPassword The proxy password to set. + */ + public void setProxyPassword(String proxyPassword) { + this.proxyPassword = proxyPassword; + } + + /** + * Get the root domain (e.g., "tencentcloudapi.com"). + * + * @return The root domain. + */ + public String getRootDomain() { + return rootDomain; + } + + /** + * Set the root domain of the API (e.g., "tencentcloudapi.com"). + * + * @param rootDomain The root domain to set. + */ + public void setRootDomain(String rootDomain) { + this.rootDomain = rootDomain; + } + + /** + * Get the SSLSocketFactory for SSL connections. + * + * @return The SSL socket factory. + */ + public SSLSocketFactory getSslSocketFactory() { + return sslSocketFactory; + } + + /** + * Set the SSLSocketFactory for SSL connections. + * + * @param sslSocketFactory The SSL socket factory to set. + */ + public void setSslSocketFactory(SSLSocketFactory sslSocketFactory) { + this.sslSocketFactory = sslSocketFactory; + } + + /** + * Get the trust manager for SSL connections. + * + * @return The X509 trust manager. + */ + public X509TrustManager getX509TrustManager() { + return trustManager; + } + + /** + * Set the trust manager for SSL connections. + * + * @param trustManager The X509 trust manager to set. + */ + public void setX509TrustManager(X509TrustManager trustManager) { + this.trustManager = trustManager; + } + + /** + * Get the API Gateway endpoint. + * + * @return The API Gateway endpoint. + */ + public String getApigwEndpoint() { + return apigwEndpoint; + } + + /** + * Set the API Gateway endpoint. + * + * @param apigwEndpoint The API Gateway endpoint to set. + */ + public void setApigwEndpoint(String apigwEndpoint) { + this.apigwEndpoint = apigwEndpoint; + } + + /** + * Get the HostnameVerifier used for hostname verification. + * + * @return The hostname verifier. + */ + public HostnameVerifier getHostnameVerifier() { + return hostnameVerifier; + } + + /** + * Set the HostnameVerifier used for hostname verification. + * + * @param hostnameVerifier The hostname verifier to set. + */ + public void setHostnameVerifier(HostnameVerifier hostnameVerifier) { + this.hostnameVerifier = hostnameVerifier; + } + + /** + * Get the custom HTTP client for advanced configuration. + * + * @return The custom HTTP client. + */ + public Object getHttpClient() { + return httpClient; + } + + /** + * Set a custom HTTP client for advanced configuration. + * + * @param client The custom HTTP client to set. + */ + public void setHttpClient(Object client) { + httpClient = client; + } +} diff --git a/src/main/java/com/tencentcloudapi/common/provider/DefaultCredentialsProvider.java b/src/main/java/com/tencentcloudapi/common/provider/DefaultCredentialsProvider.java index 09a44e2139..9d57a14b70 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/DefaultCredentialsProvider.java +++ b/src/main/java/com/tencentcloudapi/common/provider/DefaultCredentialsProvider.java @@ -3,8 +3,6 @@ import com.tencentcloudapi.common.Credential; import com.tencentcloudapi.common.exception.TencentCloudSDKException; -import java.io.IOException; - public class DefaultCredentialsProvider implements CredentialsProvider { @Override public Credential getCredentials() throws TencentCloudSDKException { @@ -26,7 +24,13 @@ public Credential getCredentials() throws TencentCloudSDKException { return cred; } - cred = new OIDCRoleArnProvider().getCredentials(); - return cred; + try { + cred = new OIDCRoleArnProvider().getCredentials(); + return cred; + } catch (TencentCloudSDKException e) { + // OIDC not configured or unavailable; fall through + } + + throw new TencentCloudSDKException("No valid credential"); } } diff --git a/src/main/java/com/tencentcloudapi/common/provider/ProfileCredentialsProvider.java b/src/main/java/com/tencentcloudapi/common/provider/ProfileCredentialsProvider.java index c3dcc30795..ba272316e7 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/ProfileCredentialsProvider.java +++ b/src/main/java/com/tencentcloudapi/common/provider/ProfileCredentialsProvider.java @@ -24,9 +24,9 @@ private static INIConfiguration getIni() throws TencentCloudSDKException { } else { throw new TencentCloudSDKException("Not found file"); } - try { + try (FileReader reader = new FileReader(fileName)) { ini = new INIConfiguration(); - ini.read(new FileReader(fileName)); + ini.read(reader); } catch (IOException | ConfigurationException e) { throw new TencentCloudSDKException("IOException or ConfigurationException"); } diff --git a/src/test/java/com/tencentcloudapi/common/AbstractClientProxyAuthTest.java b/src/test/java/com/tencentcloudapi/common/AbstractClientProxyAuthTest.java new file mode 100644 index 0000000000..81b1a17911 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/AbstractClientProxyAuthTest.java @@ -0,0 +1,120 @@ +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.http.HttpConnection; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import okhttp3.Authenticator; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +/** + * Unit tests for the proxy authentication setup in {@link AbstractClient}. + * + *

Regression coverage for the okhttp 3.x -> 4.x upgrade: okhttp 4's + * {@code Credentials.basic(username, password)} rejects a null password with an NPE, + * so {@code AbstractClient.trySetProxy} now falls back to an empty password when the + * profile only configures a proxy username. + */ +public class AbstractClientProxyAuthTest { + + /** + * Minimal concrete subclass: AbstractClient declares no abstract methods, so an empty + * subclass is enough to instantiate it. The constructor performs no network I/O. + */ + private static final class TestClient extends AbstractClient { + TestClient(ClientProfile profile) { + super("cvm.tencentcloudapi.com", "2017-03-12", + new Credential("secret-id", "secret-key"), "ap-guangzhou", profile); + } + } + + private static ClientProfile proxyProfile(String username, String password) { + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setProxyHost("127.0.0.1"); + httpProfile.setProxyPort(8080); + if (username != null) { + httpProfile.setProxyUsername(username); + } + if (password != null) { + httpProfile.setProxyPassword(password); + } + ClientProfile profile = new ClientProfile(); + profile.setHttpProfile(httpProfile); + return profile; + } + + private static OkHttpClient extractOkHttpClient(AbstractClient client) throws Exception { + Field connField = AbstractClient.class.getDeclaredField("httpConnection"); + connField.setAccessible(true); + HttpConnection conn = (HttpConnection) connField.get(client); + Field clientField = HttpConnection.class.getDeclaredField("client"); + clientField.setAccessible(true); + return (OkHttpClient) clientField.get(conn); + } + + private static Response fakeProxyChallenge() { + Request request = new Request.Builder() + .url("https://cvm.tencentcloudapi.com/") + .build(); + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(407) + .message("Proxy Authentication Required") + .build(); + } + + /** + * The core regression: with only a proxy username configured (password is null), the + * authenticator must not throw on okhttp 4, and must send an empty password. + * base64("user:") = "dXNlcjo=". + */ + @Test + public void nullProxyPasswordFallsBackToEmpty() throws Exception { + OkHttpClient ok = extractOkHttpClient(new TestClient(proxyProfile("user", null))); + Authenticator authenticator = ok.proxyAuthenticator(); + Request authenticated = authenticator.authenticate(null, fakeProxyChallenge()); + assertEquals("Basic dXNlcjo=", authenticated.header("Proxy-Authorization")); + } + + /** + * A configured password must be passed through unchanged. + * base64("user:pass") = "dXNlcjpwYXNz". + */ + @Test + public void configuredProxyPasswordIsUsed() throws Exception { + OkHttpClient ok = extractOkHttpClient(new TestClient(proxyProfile("user", "pass"))); + Authenticator authenticator = ok.proxyAuthenticator(); + Request authenticated = authenticator.authenticate(null, fakeProxyChallenge()); + assertEquals("Basic dXNlcjpwYXNz", authenticated.header("Proxy-Authorization")); + } + + /** + * Proxy host configured but no username: no authenticator should be installed + * (okhttp default is Authenticator.NONE). + */ + @Test + public void proxyWithoutUsernameKeepsDefaultAuthenticator() throws Exception { + OkHttpClient ok = extractOkHttpClient(new TestClient(proxyProfile(null, "pass"))); + assertSame(Authenticator.NONE, ok.proxyAuthenticator()); + } + + /** + * No proxy configured at all: neither proxy nor authenticator should be installed. + */ + @Test + public void noProxyKeepsDefaults() throws Exception { + OkHttpClient ok = extractOkHttpClient(new TestClient(new ClientProfile())); + assertSame(Authenticator.NONE, ok.proxyAuthenticator()); + assertNull(ok.proxy()); + } +} diff --git a/src/test/java/com/tencentcloudapi/common/CircuitBreakerUnitTest.java b/src/test/java/com/tencentcloudapi/common/CircuitBreakerUnitTest.java new file mode 100644 index 0000000000..e00003453d --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/CircuitBreakerUnitTest.java @@ -0,0 +1,412 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.tencentcloudapi.common; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Unit tests for {@link CircuitBreaker} state-machine branches not covered by + * the basic smoke tests in + * {@code integration/common/CircuitBreakerTest.java}. + * + *

Covers: failure-percentage threshold, consecutive-failure threshold, + * HalfOpen success path, stale-generation report discarding, window-interval + * counter reset, custom {@link CircuitBreaker.Setting} combinations, and + * {@link EndpointFailoverInterceptor#breakerFor(String)} inheriting the region + * breaker's settings. + */ +public class CircuitBreakerUnitTest { + + // ================================================================= + // readyToOpen: maxFailPercentage threshold + // ================================================================= + + /** + * {@code readyToOpen()} fires when {@code failures >= maxFailNum && failPre + * >= maxFailPercentage}. To isolate the percentage branch from the + * consecutive-failure branch (consecutiveFailures > 5) and from the pure + * count branch, use maxFailNum=6 so the count alone wouldn't fire at 5 + * failures but the conjunction does once both thresholds are met. + * + *

Sequence with maxFailNum=6, maxFailPercentage=0.75 (default): + * 3 failures + 1 success + 3 failures = failures=6, all=7, failPre=6/7≈0.857 + * ≥ 0.75 and failures=6 >= 6 → opens. The success keeps consecutiveFailures + * at 3 on the final failure, so the consecutive branch (consecutiveFailures > 5) + * does not fire first. + */ + @Test + public void testOpensOnFailurePercentageThreshold() { + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.maxFailNum = 6; // count branch alone needs >= 6 + CircuitBreaker cb = new CircuitBreaker(setting); + CircuitBreaker.Token t; + + // 3 failures (consecutiveFailures=3, failures=3, all=3). + for (int i = 0; i < 3; i++) { + t = cb.allow(); + Assert.assertTrue("call " + (i + 1) + " should be allowed", t.allowed); + t.report(false); + } + + // 1 success resets consecutiveFailures but keeps failures=3, all=4. + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(true); + + // 2 more failures: failures=5, all=6, failPre=5/6≈0.83 ≥ 0.75 but + // failures=5 < 6 → not open yet (count branch not satisfied). + for (int i = 0; i < 2; i++) { + t = cb.allow(); + Assert.assertTrue("failure " + (i + 1) + " should be allowed", t.allowed); + t.report(false); + } + + // 3rd failure after success: failures=6 >= 6 and failPre=6/7≈0.857 + // ≥ 0.75 → opens. consecutiveFailures=3 so the consecutive branch + // is not the trigger. + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + + t = cb.allow(); + Assert.assertFalse( + "percentage branch should have opened the breaker", t.allowed); + } + + // ================================================================= + // readyToOpen: consecutiveFailures > 5 threshold + // ================================================================= + + /** + * {@code readyToOpen()}'s second condition: {@code consecutiveFailures > 5} + * opens the breaker regardless of failure count or percentage. With 5 + * consecutive failures {@code consecutiveFailures == 5} (not > 5), so the + * breaker stays closed; the 6th consecutive failure trips it. + */ + @Test + public void testOpensOnSixConsecutiveFailures() { + CircuitBreaker cb = new CircuitBreaker(); + CircuitBreaker.Token t; + + // 5 consecutive failures — fails the percentage branch check too + // (failures=5 >= 5, failPre=1.0 >= 0.75), so this actually opens at 5. + // To isolate the consecutiveFailures branch we must keep failPre low, + // but maxFailNum=5 dominates once reached. Use a custom setting where + // maxFailNum is high so only the consecutive branch can fire. + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.maxFailNum = 100; // disable the count branch + setting.maxFailPercentage = 1.0f; // disable the percentage branch + cb = new CircuitBreaker(setting); + + // 5 consecutive failures: consecutiveFailures=5, not > 5 → stays closed. + for (int i = 0; i < 5; i++) { + t = cb.allow(); + Assert.assertTrue("call " + (i + 1) + " should be allowed", t.allowed); + t.report(false); + } + + // Insert a success to prove consecutiveFailures is the trigger, not + // total failures: reset consecutiveFailures, then accumulate again. + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(true); + + // 5 more consecutive failures — consecutiveFailures=5 again, still closed. + for (int i = 0; i < 5; i++) { + t = cb.allow(); + Assert.assertTrue("call " + (i + 1) + " after reset should be allowed", t.allowed); + t.report(false); + } + + // 6th consecutive failure (consecutiveFailures=6 > 5) → opens. + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + + t = cb.allow(); + Assert.assertFalse("breaker should be open on 6th consecutive failure", t.allowed); + } + + // ================================================================= + // HalfOpen success → Closed + // ================================================================= + + /** + * {@code onSuccess(State.HalfOpen, ...)} transitions to Closed when + * {@code all - failures > maxRequests}. With default maxRequests=0, the + * first HalfOpen success (all=1, failures=0, 1-0>0) closes the breaker. + * This complements {@code integration} testFail5Recover2 which only + * covers the HalfOpen-failure → re-Open path. + */ + @Test + public void testHalfOpenSuccessClosesBreaker() throws InterruptedException { + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.timeoutMs = 100; + CircuitBreaker cb = new CircuitBreaker(setting); + CircuitBreaker.Token t; + + // 5 failures → Open. + for (int i = 0; i < 5; i++) { + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + } + t = cb.allow(); + Assert.assertFalse(t.allowed); + + // Wait past Open timeout → HalfOpen. + Thread.sleep(setting.timeoutMs + 20); + + // First HalfOpen probe succeeds → Closed. + t = cb.allow(); + Assert.assertTrue("HalfOpen probe should be allowed", t.allowed); + t.report(true); + + // Now Closed: subsequent calls are allowed and a fresh failure does + // not immediately re-open (consecutiveFailures=1, not > 5; failures=1 + // < 5). + t = cb.allow(); + Assert.assertTrue("Closed after HalfOpen success", t.allowed); + t.report(false); + t = cb.allow(); + Assert.assertTrue("single failure in fresh Closed window should not re-open", t.allowed); + } + + // ================================================================= + // Stale-generation report is discarded + // ================================================================= + + /** + * {@code report()} returns early when {@code result.generation != + * beforeGeneration}. A token captured before a state-transition-induced + * generation bump must not affect the new generation's counters. + * + *

Sequence: 5 failures → Open (gen=1). Capture a token (gen=1) but do + * not report. Sleep past Open timeout → HalfOpen (gen=2). Reporting the + * stale gen=1 token with failure must NOT trip HalfOpen back to Open — + * the next allow must still be permitted (HalfOpen). + */ + @Test + public void testStaleGenerationReportIsDiscarded() throws InterruptedException { + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.timeoutMs = 100; + CircuitBreaker cb = new CircuitBreaker(setting); + CircuitBreaker.Token t; + + for (int i = 0; i < 5; i++) { + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + } + // Open — capture a token but hold it without reporting. + CircuitBreaker.Token staleToken = cb.allow(); + Assert.assertFalse(staleToken.allowed); + + // Sleep into HalfOpen (new generation). + Thread.sleep(setting.timeoutMs + 20); + CircuitBreaker.Token halfOpenToken = cb.allow(); + Assert.assertTrue("should be in HalfOpen", halfOpenToken.allowed); + + // Report the stale token as a failure — must be discarded. + staleToken.report(false); + + // The HalfOpen probe should still be viable: reporting it as success + // should close the breaker. If the stale report had been applied, + // HalfOpen would have flipped back to Open and the success report + // would land on a new generation and be discarded, leaving the next + // allow() rejected. + halfOpenToken.report(true); + CircuitBreaker.Token after = cb.allow(); + Assert.assertTrue( + "stale failure report must not have flipped HalfOpen back to Open", + after.allowed); + } + + // ================================================================= + // windowIntervalMs resets Closed counters + // ================================================================= + + /** + * In Closed state, when {@code expiry < now} the breaker calls + * {@code toNewGeneration}, zeroing {@code failures} and {@code all} (but + * NOT {@code consecutiveFailures} — that field is only reset by a success + * or a state transition). Verify the window reset by showing that a fresh + * failure burst opens the breaker later than it would if the counters had + * carried over. + * + *

With maxFailNum=5, maxFailPercentage=0.75: 3 failures + 1 success + * (resets consecutiveFailures) + window expiry + 5 failures. With reset, + * failures climbs 1..5 and opens on the 5th (failures=5 >= 5, failPre=1.0 + * >= 0.75, consecutiveFailures=5, not > 5). Without reset, failures would + * be 3+2=5 on the 2nd post-window failure and open early. + */ + @Test + public void testWindowIntervalResetsClosedCounters() throws InterruptedException { + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.windowIntervalMs = 200; + setting.timeoutMs = 200; + CircuitBreaker cb = new CircuitBreaker(setting); + CircuitBreaker.Token t; + + // 3 failures (failures=3, all=3, consecutiveFailures=3). + for (int i = 0; i < 3; i++) { + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + } + + // 1 success: consecutiveFailures=0, failures=3, all=4. + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(true); + + // Wait past the Closed window so the next allow() starts a new + // generation with zeroed failures/all. + Thread.sleep(setting.windowIntervalMs + 100); + + // 4 post-window failures: with reset, failures=4 < 5 → still closed. + // (consecutiveFailures=4, not > 5.) Without reset, failures would be + // 3+4=7 and the breaker would have opened at the 2nd failure here. + for (int i = 0; i < 4; i++) { + t = cb.allow(); + Assert.assertTrue( + "failure " + (i + 1) + " after reset should be allowed", t.allowed); + t.report(false); + } + + // 5th post-window failure: failures=5 >= 5, failPre=5/5=1.0 >= 0.75 → opens. + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + + t = cb.allow(); + Assert.assertFalse( + "breaker should open once failures re-accumulates past threshold", + t.allowed); + } + + // ================================================================= + // Custom Setting combinations + // ================================================================= + + /** + * Exercises non-default {@link CircuitBreaker.Setting} values together: + * maxFailNum=3 lowers the count threshold; maxFailPercentage=0.5 lowers + * the percentage threshold; maxRequests=2 requires more than 2 + * HalfOpen successes (with no failures) before closing. + */ + @Test + public void testCustomSettingCombination() throws InterruptedException { + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.maxFailNum = 3; + setting.maxFailPercentage = 0.5f; + setting.timeoutMs = 100; + setting.maxRequests = 2; + CircuitBreaker cb = new CircuitBreaker(setting); + CircuitBreaker.Token t; + + // 3 failures, failPre=1.0 >= 0.5 and failures=3 >= 3 → opens on 3rd. + for (int i = 0; i < 3; i++) { + t = cb.allow(); + Assert.assertTrue(t.allowed); + t.report(false); + } + t = cb.allow(); + Assert.assertFalse("custom maxFailNum=3 should open after 3 failures", t.allowed); + + // Sleep into HalfOpen. + Thread.sleep(setting.timeoutMs + 20); + + // maxRequests=2: need all - failures > 2, i.e. at least 3 successes. + // 2 successes → all=2, failures=0, 2-0=2 not > 2 → stays HalfOpen. + for (int i = 0; i < 2; i++) { + t = cb.allow(); + Assert.assertTrue("HalfOpen probe " + (i + 1) + " should be allowed", t.allowed); + t.report(true); + } + // 3rd success → all=3, 3-0=3 > 2 → Closed. + t = cb.allow(); + Assert.assertTrue("HalfOpen probe 3 should be allowed", t.allowed); + t.report(true); + + // Closed again — fresh allow must succeed and a single failure must + // not re-open (failures=1 < 3). + t = cb.allow(); + Assert.assertTrue("should be Closed after 3 HalfOpen successes", t.allowed); + t.report(false); + t = cb.allow(); + Assert.assertTrue("single failure should not re-open custom-threshold breaker", t.allowed); + } + + // ================================================================= + // EndpointFailoverInterceptor.newBreaker inherits regionBreaker setting + // ================================================================= + + /** + * {@link EndpointFailoverInterceptor#breakerFor(String)} calls + * {@code newBreaker()}, which — when a region breaker has been set — + * constructs the per-host breaker with {@code regionBreaker.getSetting()} + * instead of the default. Verify the inherited setting takes effect by + * checking the per-host breaker opens after the region breaker's custom + * maxFailNum, not the default 5. + */ + @Test + public void testNewBreakerInheritsRegionBreakerSetting() throws Exception { + // Build an interceptor via reflection: the constructor needs an + // AbstractClient, but newBreaker()/breakerFor()/setRegionBreaker() + // only read client.getClientProfile().getBackupEndpoint() in the + // constructor. Use a real CvmClient with a default profile. + com.tencentcloudapi.common.profile.ClientProfile profile = + new com.tencentcloudapi.common.profile.ClientProfile(); + com.tencentcloudapi.cvm.v20170312.CvmClient client = + new com.tencentcloudapi.cvm.v20170312.CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + + EndpointFailoverInterceptor interceptor = + new EndpointFailoverInterceptor(client); + + CircuitBreaker.Setting regionSetting = new CircuitBreaker.Setting(); + regionSetting.maxFailNum = 3; + regionSetting.maxFailPercentage = 1.0f; // isolate the count branch + CircuitBreaker regionBreaker = new CircuitBreaker(regionSetting); + interceptor.setRegionBreaker(regionBreaker); + + // Trigger creation of a per-host breaker. + String host = "cvm.tencentcloudapi.com"; + CircuitBreaker perHost = interceptor.breakerFor(host); + + // The new breaker should share the region breaker's Setting instance + // (newBreaker returns new CircuitBreaker(regionBreaker.getSetting())). + Assert.assertSame( + "per-host breaker should inherit regionBreaker's Setting instance", + regionSetting, perHost.getSetting()); + + // Behavioural check: opens after 3 failures (region's maxFailNum), + // not 5 (default). With maxFailPercentage=1.0f the percentage branch + // never fires, so only the count branch (failures >= 3) can open it. + CircuitBreaker.Token t; + for (int i = 0; i < 3; i++) { + t = perHost.allow(); + Assert.assertTrue("failure " + (i + 1) + " should be allowed", t.allowed); + t.report(false); + } + t = perHost.allow(); + Assert.assertFalse( + "inherited maxFailNum=3 should open after 3 failures", t.allowed); + } +} diff --git a/src/test/java/com/tencentcloudapi/common/CommonRequestTest.java b/src/test/java/com/tencentcloudapi/common/CommonRequestTest.java new file mode 100644 index 0000000000..3743f8764d --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/CommonRequestTest.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.tencentcloudapi.common; + +import org.junit.Test; + +import java.util.HashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests for {@link CommonRequest}. + * + *

{@link CommonRequest} wraps a JSON string into an {@link AbstractModel} + * by deserialising it into a {@code HashMap} and feeding each + * entry into {@code set(key, value)}. Its {@code toMap} then recursively + * flattens nested maps/lists into dot-prefixed string keys. + */ +public class CommonRequestTest { + + // ================================================================= + // Constructor / deserialisation + // ================================================================= + + /** + * The constructor deserialises the JSON and writes each top-level entry + * into {@code any()} (the parent's {@code customizedParams}). + */ + @Test + public void testConstructorPopulatesAnyFromJson() { + CommonRequest req = new CommonRequest("{\"Region\":\"ap-guangzhou\",\"Limit\":10}"); + HashMap any = req.any(); + assertEquals("ap-guangzhou", any.get("Region")); + // LONG_OR_DOUBLE strategy: integer → Long + assertTrue("Limit should be a Long under LONG_OR_DOUBLE strategy, got " + + (any.get("Limit") == null ? "null" : any.get("Limit").getClass().getName()), + any.get("Limit") instanceof Long); + assertEquals(10L, any.get("Limit")); + } + + /** + * LONG_OR_DOUBLE number strategy: a number with a decimal point becomes + * {@code Double}; an integer becomes {@code Long}. + */ + @Test + public void testNumberStrategyLongOrDouble() { + CommonRequest req = new CommonRequest("{\"Int\":1,\"Float\":1.5}"); + HashMap any = req.any(); + assertTrue("integer should be Long", any.get("Int") instanceof Long); + assertTrue("float should be Double", any.get("Float") instanceof Double); + assertEquals(1L, any.get("Int")); + assertEquals(1.5d, (Double) any.get("Float"), 0.0); + } + + // ================================================================= + // toMap flattening — invoked via the protected method from same package + // ================================================================= + + /** + * Calls {@link CommonRequest#toMap(HashMap, String)} with an empty prefix + * and returns the resulting flat map. + */ + private static HashMap flatten(CommonRequest req) { + HashMap map = new HashMap(); + req.toMap(map, ""); + return map; + } + + /** Top-level scalar key/value pairs flatten directly with no prefix. */ + @Test + public void testToMapFlatScalars() { + CommonRequest req = new CommonRequest("{\"Region\":\"ap-guangzhou\",\"Limit\":10}"); + HashMap map = flatten(req); + assertEquals("ap-guangzhou", map.get("Region")); + assertEquals("10", map.get("Limit")); + assertEquals(2, map.size()); + } + + /** Nested map keys are joined with a dot. */ + @Test + public void testToMapNestedMapUsesDotPrefix() { + CommonRequest req = new CommonRequest( + "{\"Filters\":{\"Name\":\"zone\",\"Values\":\"ap-gz1\"}}"); + HashMap map = flatten(req); + assertEquals("zone", map.get("Filters.Name")); + assertEquals("ap-gz1", map.get("Filters.Values")); + } + + /** List elements are indexed positionally with a dot before the index. */ + @Test + public void testToMapListUsesIndexPrefix() { + CommonRequest req = new CommonRequest( + "{\"Zones\":[\"ap-gz1\",\"ap-gz2\",\"ap-gz3\"]}"); + HashMap map = flatten(req); + assertEquals("ap-gz1", map.get("Zones.0")); + assertEquals("ap-gz2", map.get("Zones.1")); + assertEquals("ap-gz3", map.get("Zones.2")); + assertEquals(3, map.size()); + } + + /** Mixed nested map/list structures flatten with combined prefixes. */ + @Test + public void testToMapMixedNestedStructure() { + CommonRequest req = new CommonRequest( + "{\"Filters\":[{\"Name\":\"zone\",\"Values\":[\"ap-gz1\",\"ap-gz2\"]}]}"); + HashMap map = flatten(req); + assertEquals("Filters.0.Name", "zone", map.get("Filters.0.Name")); + assertEquals("ap-gz1", map.get("Filters.0.Values.0")); + assertEquals("ap-gz2", map.get("Filters.0.Values.1")); + assertEquals(3, map.size()); + } + + /** + * A null value anywhere in the structure is skipped — {@code toMapFromObject} + * returns early for null layers. + */ + @Test + public void testToMapNullValuesAreSkipped() { + CommonRequest req = new CommonRequest( + "{\"Keep\":\"v\",\"Drop\":null,\"Nested\":{\"A\":1,\"B\":null}}"); + HashMap map = flatten(req); + assertEquals("v", map.get("Keep")); + assertNull("null top-level value should be skipped", map.get("Drop")); + assertEquals("1", map.get("Nested.A")); + assertNull("null nested value should be skipped", map.get("Nested.B")); + assertEquals(2, map.size()); + } + + /** + * A non-empty prefix is prepended to every key (used when the parent + * calls toMap with a field-name prefix). + */ + @Test + public void testToMapWithNonEmptyPrefix() { + CommonRequest req = new CommonRequest("{\"A\":1,\"B\":2}"); + HashMap map = new HashMap(); + req.toMap(map, "Root"); + assertEquals("1", map.get("Root.A")); + assertEquals("2", map.get("Root.B")); + assertEquals(2, map.size()); + } + + /** + * Boolean leaf values are stringified via {@code toString()} → "true"/"false". + */ + @Test + public void testToMapBooleanLeafStringified() { + CommonRequest req = new CommonRequest("{\"Enabled\":true,\"Disabled\":false}"); + HashMap map = flatten(req); + assertEquals("true", map.get("Enabled")); + assertEquals("false", map.get("Disabled")); + } +} diff --git a/src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java b/src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java new file mode 100644 index 0000000000..6ab7fb66e7 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java @@ -0,0 +1,1611 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.http.HttpConnection; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import com.tencentcloudapi.cvm.v20170312.CvmClient; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesRequest; +import com.tencentcloudapi.cvm.v20170312.models.DescribeInstancesResponse; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.Test; + +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests for {@link EndpointFailoverInterceptor}. + * + *

How tests are organized

+ * + *

Each test scenario is run against every domain family (N1/N2/N3) via a + * {@code for (Family f : FAMILIES)} loop inside the test method. This gives + * M × N coverage while keeping the code DRY. + * + *

Scenarios that do NOT depend on the domain family (e.g. pure helper + * methods like {@code isKnownTencentCloudHost}) use a single test method + * without the family loop. + * + *

Adding a new test case

+ * + *
    + *
  1. Choose the right section. Find the scenario group that best + * matches (see the M-numbered list below), or add a new group at the + * bottom. + *
  2. If family-dependent: Copy the pattern of an existing test in + * that section — wrap the test body in {@code for (Family f : FAMILIES)} + * and use {@code f.originHost}, {@code f.firstFailover}, etc. + * Include {@code f.name} in assertion messages for debugging. + *
  3. If family-independent: Write a plain {@code @Test} method + * without the loop. + *
  4. Use existing helpers: + *
      + *
    • {@link #newClient(Family)} — creates a CvmClient for a family + *
    • {@link #installStub(AbstractClient)} — installs TransportStub + *
    • {@link #tripBreakerFor(AbstractClient, String, long)} — trips a breaker + *
    • {@link #tripAllBreakersFor(AbstractClient, Family, long)} — trips all 3 + *
    • {@link #failoverInterceptorOf(AbstractClient)} — gets the interceptor + *
    + *
  5. Script the TransportStub: Use {@code transport.programOk()}, + * {@code transport.programFailure(ex)}, {@code transport.programJsonOk(json)}, + * {@code transport.programResponse(code, body)}, or + * {@code transport.programResponseWithCt(code, body, contentType)}. + *
  6. Assert transport hits: Check {@code transport.received.size()} + * and {@code transport.received.get(i).url().host()}. + *
  7. Assign a scenario number. Pick the next available M number and + * update the list below so the index stays current. + *
+ * + *

Domain families (N)

+ *
    + *
  • N1: Normal — {@code tencentcloudapi.{com,cn,com.cn}} + *
  • N2: AI — {@code ai.tencentcloudapi.{com,cn,com.cn}} + *
  • N3: Internal — {@code internal.tencentcloudapi.{com,cn,com.cn}} + *
+ * + *

Test scenarios (M)

+ * + *

Pure helpers

+ *
    + *
  1. isKnownTencentCloudHost — host classification + *
  2. hostWithTld / serviceOf — utility helpers + *
+ * + *

Pass-through

+ *
    + *
  1. Non-TencentCloud host — passthrough + *
  2. Unknown TLD — passthrough + *
  3. Non-POST request — passthrough + *
  4. Non-failover IOException — propagate without retry + *
+ * + *

Failover triggers

+ *
    + *
  1. UnknownHostException + *
  2. SSLPeerUnverifiedException + *
  3. SSLHandshakeException + *
  4. ConnectException + *
  5. NoRouteToHostException + *
  6. PortUnreachableException + *
  7. SocketTimeoutException + *
  8. HTTP non-200 response (UnhealthyResponseException) + *
+ * + *

Response after failover

+ *
    + *
  1. API response delivered intact after selecting alternate host + *
+ * + *

Circuit breaker lifecycle

+ *
    + *
  1. Sustained failure opens breaker + *
  2. Open breaker short-circuits host + *
  3. Open → HalfOpen after cooldown + *
  4. HalfOpen probe success → Closed + *
  5. HalfOpen probe failure → re-Open + *
  6. All breakers open → fallback to origin host + *
+ * + *

Failure reporting & isolation

+ *
    + *
  1. One transport attempt per request (no same-request retry) + *
  2. Failure preserves original exception type and message + *
  3. Breaker skip mixed with real failure + *
  4. Failure does not pollute next request + *
  5. Breaker state isolated across origin hosts + *
+ * + *

Request re-signing

+ *
    + *
  1. TC3 V3 re-sign (POST) + *
  2. TC3 V3 GET re-sign + *
  3. Hmac V1 re-sign + *
  4. SKIP V3 — rewrite Host only + *
  5. Re-sign uses current credential (SecretId/Key rotation) + *
  6. X-TC-Token rotation + *
+ * + *

Response type handling

+ *
    + *
  1. SSE (text/event-stream) — no failover + *
  2. No Content-Type — no failover + *
  3. 200 + JSON business error — no failover + *
+ * + *

backupEndpoint mode

+ *
    + *
  1. backupEndpoint failover behavior + *
+ * + *

TLD boundary & region-pinned

+ *
    + *
  1. hostWithTld from .cn / .com.cn origins + *
  2. hostWithTld preserves region prefix + *
+ * + *

Passthrough details

+ *
    + *
  1. Non-TencentCloud host DNS miss — no retry + *
  2. Non-TencentCloud host + backupEndpoint — no retry + *
+ * + *

Endpoint eligibility

+ *
    + *
  1. .cn origin eligible for failover + *
  2. Region-pinned host eligible for failover + *
  3. setEnableDomainFailover(false) at runtime — no effect + *
+ * + *

TLD family rotation

+ *
    + *
  1. AI family stays within ai.tencentcloudapi + *
  2. Internal family stays within internal.tencentcloudapi + *
  3. Region-pinned failover preserves prefix + *
+ * + *

Re-sign details

+ *
    + *
  1. TC3 resign preserves body bytes and Content-Type + *
  2. X-TC-Token dropped when cleared + *
+ * + *

backupEndpoint details

+ *
    + *
  1. Origin DNS miss — no same-request retry to backup + *
  2. Non-failover IOException propagates directly + *
  3. No backupEndpoint — DNS miss behavior + *
+ */ +public class EndpointFailoverInterceptorTest { + + // ================================================================= + // Domain families + // ================================================================= + + private static final Family N1 = new Family( + "Normal", "tencentcloudapi.com", + "cvm.tencentcloudapi.com", + "cvm.tencentcloudapi.com.cn", + "cvm.tencentcloudapi.cn"); + + private static final Family N2 = new Family( + "AI", "ai.tencentcloudapi.com", + "cvm.ai.tencentcloudapi.com", + "cvm.ai.tencentcloudapi.com.cn", + "cvm.ai.tencentcloudapi.cn"); + + private static final Family N3 = new Family( + "Internal", "internal.tencentcloudapi.com", + "cvm.internal.tencentcloudapi.com", + "cvm.internal.tencentcloudapi.com.cn", + "cvm.internal.tencentcloudapi.cn"); + + private static final Family[] FAMILIES = {N1, N2, N3}; + + private static class Family { + final String name; + final String rootDomain; + final String originHost; + final String firstFailover; + final String secondFailover; + final String[] allTldHosts; + + Family(String name, String rootDomain, String host0, String host1, String host2) { + this.name = name; + this.rootDomain = rootDomain; + this.originHost = host0; + this.firstFailover = host1; + this.secondFailover = host2; + this.allTldHosts = new String[]{host0, host1, host2}; + } + } + + // ================================================================= + // M1: isKnownTencentCloudHost — host classification + // ================================================================= + + @Test + public void testIsKnownTencentCloudHost() { + // Normal family (tencentcloudapi.com / .cn / .com.cn) + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.com")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.cn")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.com.cn")); + // intl prefix maps to the normal family. + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.intl.tencentcloudapi.com")); + // Region-pinned. + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ap-shanghai.tencentcloudapi.com")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ap-shanghai.tencentcloudapi.cn")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ap-shanghai.tencentcloudapi.com.cn")); + + // AI family (ai.tencentcloudapi.com / .cn / .com.cn) + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ai.tencentcloudapi.com")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ai.tencentcloudapi.cn")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ai.tencentcloudapi.com.cn")); + // Region-pinned. + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ap-guangzhou.ai.tencentcloudapi.com")); + + // Internal family (internal.tencentcloudapi.com / .cn / .com.cn) + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.internal.tencentcloudapi.com")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.internal.tencentcloudapi.cn")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.internal.tencentcloudapi.com.cn")); + // Region-pinned. + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ap-guangzhou.internal.tencentcloudapi.com")); + + // Empty prefix (no service label) + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("tencentcloudapi.com")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("tencentcloudapi.cn")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("tencentcloudapi.com.cn")); + + // Malformed prefix + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost(".tencentcloudapi.com")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost(".foo.tencentcloudapi.com")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("foo..tencentcloudapi.com")); + + // Non-TencentCloud hosts + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("example.com")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.woa.com")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("proxy.internal")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("192.168.0.1")); + assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost(null)); + } + + // ================================================================= + // M2: hostWithTld / serviceOf — utility helpers + // ================================================================= + + @Test + public void testHostWithTldBuildsCorrectHosts() { + assertEquals("cvm.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com", 0)); + assertEquals("cvm.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com", 1)); + assertEquals("cvm.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com", 2)); + + assertEquals("cvm.ai.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.ai.tencentcloudapi.com", 0)); + assertEquals("cvm.ai.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.ai.tencentcloudapi.com", 1)); + + assertEquals("cvm.internal.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.internal.tencentcloudapi.com", 0)); + } + + @Test + public void testMatchFamilyReturnsNullForUnknownHosts() { + assertNull(EndpointFailoverInterceptor.matchFamily("example.com")); + assertNull(EndpointFailoverInterceptor.matchFamily(null)); + } + + @Test + public void testMatchFamilyReturnsFirstMatchingFamily() { + EndpointFailoverInterceptor.Match m; + + m = EndpointFailoverInterceptor.matchFamily("cvm.tencentcloudapi.com"); + assertNotNull(m); + assertEquals("cvm", m.prefix); + assertEquals("tencentcloudapi.com", m.family[0]); + + m = EndpointFailoverInterceptor.matchFamily("cvm.ai.tencentcloudapi.cn"); + assertNotNull(m); + assertEquals("cvm", m.prefix); + assertEquals("ai.tencentcloudapi.com", m.family[0]); + + m = EndpointFailoverInterceptor.matchFamily("cvm.internal.tencentcloudapi.com.cn"); + assertNotNull(m); + assertEquals("cvm", m.prefix); + assertEquals("internal.tencentcloudapi.com", m.family[0]); + } + + // intl prefix (cvm.intl.tencentcloudapi.com) maps to the normal family, + // with "intl" folded into the prefix rather than treated as a TLD. + @Test + public void testMatchFamilyHandlesIntlPrefix() { + EndpointFailoverInterceptor.Match m = + EndpointFailoverInterceptor.matchFamily("cvm.intl.tencentcloudapi.com"); + assertNotNull(m); + assertEquals("cvm.intl", m.prefix); + assertEquals("tencentcloudapi.com", m.family[0]); + assertEquals(0, m.tldIdx); + } + + @Test + public void testHostWithTldDropsIntlPrefix() { + assertEquals("cvm.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.intl.tencentcloudapi.com", 1)); + assertEquals("cvm.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.intl.tencentcloudapi.com", 2)); + } + + // End-to-end: an intl-prefixed origin host rotates within the normal + // family (.com -> .com.cn -> .cn), same as a plain public host. + @Test + public void testIntlHostFailoverRotatesWithinNormalFamily() throws Exception { + Family f = new Family("Intl", "tencentcloudapi.com", + "cvm.intl.tencentcloudapi.com", + "cvm.tencentcloudapi.com.cn", + "cvm.tencentcloudapi.cn"); + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(1, transport.received.size()); + assertEquals(f.firstFailover, transport.received.get(0).url().host()); + } + + // ================================================================= + // M2a: hostWithTld — boundary origins (.cn, .com.cn, region-pinned) + // ================================================================= + + @Test + public void testHostWithTldFromCnAndComCnOrigins() { + // From .cn origin (tldIdx=2): idx 0 -> .com, idx 2 -> .cn + assertEquals("cvm.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.cn", 0)); + assertEquals("cvm.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.cn", 2)); + + // From .com.cn origin (tldIdx=1): idx 0 -> .com, idx 1 -> .com.cn + assertEquals("cvm.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com.cn", 0)); + assertEquals("cvm.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com.cn", 1)); + } + + @Test + public void testHostWithTldDropsRegionInPrefix() { + assertEquals("cvm.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.ap-guangzhou.tencentcloudapi.com", 1)); + assertEquals("cvm.ai.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.ap-guangzhou.ai.tencentcloudapi.com", 1)); + } + + // ================================================================= + // M3: Non-TencentCloud host — passthrough (family loop) + // ================================================================= + + @Test + public void testPassthroughNonTencentCloudHost() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + // Build a request to a non-TencentCloud host. + OkHttpClient http = grabOkHttpClient(client); + Request req = new Request.Builder() + .url("https://example.com/") + .header("Host", "example.com") + .header("Authorization", "SKIP") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + transport.programOk(); + Response resp = http.newCall(req).execute(); + resp.close(); + assertEquals(1, transport.received.size()); + assertEquals("example.com", transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M3a: Non-TencentCloud host — DNS miss + backup details + // ================================================================= + + @Test + public void testNonTencentHostDnsMissPropagatesWithoutRetry() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("proxy dns miss")); + + OkHttpClient http = grabOkHttpClient(client); + Request req = new Request.Builder() + .url("https://proxy.internal/") + .header("Host", "proxy.internal") + .header("Authorization", "SKIP") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + + try { + http.newCall(req).execute(); + fail(f.name + ": expected IOException"); + } catch (IOException e) { + assertTrue(f.name, e instanceof UnknownHostException); + } + assertEquals(f.name + ": no retry for non-TencentCloud host", + 1, transport.received.size()); + } + } + + @Test + public void testNonTencentHostWithBackupDoesNotRetrySameRequest() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(f.originHost); + profile.setBackupEndpoint("backup.example.com"); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("proxy dns miss")); + + OkHttpClient http = grabOkHttpClient(client); + Request req = new Request.Builder() + .url("https://proxy.internal/") + .header("Host", "proxy.internal") + .header("Authorization", "SKIP") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + + try { + http.newCall(req).execute(); + fail(f.name + ": expected IOException"); + } catch (IOException ignored) { } + assertEquals(f.name + ": only one attempt, no retry to backup", + 1, transport.received.size()); + } + } + + // ================================================================= + // M4: Unknown TLD — passthrough (family loop) + // ================================================================= + + @Test + public void testPassthroughUnknownTld() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M5: Non-POST request — passthrough (family loop) + // ================================================================= + + @Test + public void testPassthroughNonPostRequest() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setReqMethod(HttpProfile.REQ_GET); + profile.getHttpProfile().setEndpoint(f.originHost); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + TransportStub transport = installStub(client); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + } + } + + // ================================================================= + // M6: Non-failover IOException — propagate without retry (family loop) + // ================================================================= + + @Test + public void testGenericIOExceptionPropagatesWithoutFailover() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new IOException("unrelated I/O error")); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException e) { + assertTrue(f.name + ": expected IOException cause", + e.getCause() instanceof IOException); + assertEquals(f.name + ": should not retry", 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + } + + // ================================================================= + // M6a-M6d: Endpoint eligibility (family loop) + // ================================================================= + + @Test + public void testCnOriginIsEligibleForFailover() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("cn dns miss")); + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + @Test + public void testRegionPinnedHostIsEligibleForFailover() throws Exception { + // Use region-pinned hosts: cvm.ap-guangzhou.tencentcloudapi.com etc. + Family[] regionFamilies = { + new Family("Normal-R", "tencentcloudapi.com", + "cvm.ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.com.cn", + "cvm.tencentcloudapi.cn"), + new Family("AI-R", "ai.tencentcloudapi.com", + "cvm.ap-guangzhou.ai.tencentcloudapi.com", + "cvm.ai.tencentcloudapi.com.cn", + "cvm.ai.tencentcloudapi.cn"), + new Family("Internal-R", "internal.tencentcloudapi.com", + "cvm.ap-guangzhou.internal.tencentcloudapi.com", + "cvm.internal.tencentcloudapi.com.cn", + "cvm.internal.tencentcloudapi.cn"), + }; + for (Family f : regionFamilies) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("region dns miss")); + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + @Test + public void testFailoverEnabledAtRuntimeHasNoEffect() throws Exception { + // setEnableDomainFailover(false) after constructor: interceptor is already installed. + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + client.getClientProfile().setEnableDomainFailover(false); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("dns miss")); + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name + ": interceptor is already installed, flag has no effect", + 1, transport.received.size()); + } + } + + // ================================================================= + // M7-M13: Failover IOException types (family loop) + // ================================================================= + + @Test public void testFailoverOnUnknownHostException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new UnknownHostException("dns miss")); + } + + @Test public void testFailoverOnSslPeerUnverifiedException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new SSLPeerUnverifiedException("cert mismatch")); + } + + @Test public void testFailoverOnSslHandshakeException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new SSLHandshakeException("tls fail")); + } + + @Test public void testFailoverOnConnectException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new ConnectException("connection refused")); + } + + @Test public void testFailoverOnNoRouteToHostException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new NoRouteToHostException("no route")); + } + + @Test public void testFailoverOnPortUnreachableException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new java.net.PortUnreachableException("port unreachable")); + } + + @Test public void testFailoverOnSocketTimeoutException() throws Exception { + for (Family f : FAMILIES) runFailoverFor(f, new SocketTimeoutException("read timed out")); + } + + private void runFailoverFor(Family f, IOException failure) throws Exception { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(failure); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name + ": one transport attempt per request", + 1, transport.received.size()); + assertEquals(f.name + ": first attempt hits origin", + f.originHost, transport.received.get(0).url().host()); + } + + // ================================================================= + // M14: Protocol-level failover (family loop) + // ================================================================= + + @Test + public void testNon200ResponseRecordsFailureWithoutRetry() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programResponse(503, "{\"Response\":{\"Error\":{}}}"); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M16a-M16b: TLD family rotation (AI/Internal don't cross families) + // ================================================================= + + @Test + public void testAiFamilyRotationStaysWithinFamily() throws Exception { + Family f = N2; + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(1, transport.received.size()); + assertEquals(f.firstFailover, transport.received.get(0).url().host()); + // Must be within ai.tencentcloudapi family, NOT plain cvm.tencentcloudapi.cn + assertTrue(transport.received.get(0).url().host().contains("ai.tencentcloudapi")); + } + + @Test + public void testInternalFamilyRotationStaysWithinFamily() throws Exception { + Family f = N3; + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(1, transport.received.size()); + assertEquals(f.firstFailover, transport.received.get(0).url().host()); + assertTrue(transport.received.get(0).url().host().contains("internal.tencentcloudapi")); + } + + @Test + public void testRegionPinnedHostFailoverDropsPrefix() throws Exception { + Family f = new Family("R", "tencentcloudapi.com", + "cvm.ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.com.cn", + "cvm.tencentcloudapi.cn"); + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(1, transport.received.size()); + assertEquals(f.firstFailover, transport.received.get(0).url().host()); + assertFalse(transport.received.get(0).url().host().contains("ap-guangzhou")); + } + + // ================================================================= + // M17: API response delivered after failover (family loop) + // ================================================================= + + @Test + public void testApiResponseDeliveredAfterFailover() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programJsonOk("{\"Response\":{\"TotalCount\":42,\"InstanceSet\":[],\"RequestId\":\"req-xyz\"}}"); + + DescribeInstancesResponse resp = client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, Long.valueOf(42), resp.getTotalCount()); + assertEquals(f.name, "req-xyz", resp.getRequestId()); + assertEquals(f.name, f.firstFailover, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M18: Sustained failure opens breaker (family loop) + // ================================================================= + + @Test + public void testBreakerOpensAfterSustainedFailure() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + + for (int i = 0; i < 5; i++) { + transport.programFailure(new UnknownHostException("fail " + i)); + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + } + assertEquals(f.name, 5, transport.received.size()); + + assertFalse(f.name + ": origin breaker should be Open", + failoverInterceptorOf(client).breakerFor(f.originHost).allow().allowed); + + // Next request short-circuits origin, goes to first failover. + transport.received.clear(); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.firstFailover, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M19: Open breaker short-circuits host (family loop) + // ================================================================= + + @Test + public void testOpenBreakerShortCircuitsHost() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name + ": should skip origin and hit first failover", + f.firstFailover, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M20: Open → HalfOpen after cooldown (family loop) + // ================================================================= + + @Test + public void testBreakerTransitionsOpenToHalfOpenAfterCooldown() throws Exception { + long shortTimeoutMs = 100; + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + CircuitBreaker breaker = tripBreakerFor(client, f.originHost, shortTimeoutMs); + assertFalse(f.name, breaker.allow().allowed); + + Thread.sleep(shortTimeoutMs + 50); + CircuitBreaker.Token probe = breaker.allow(); + assertTrue(f.name + ": should permit HalfOpen probe", probe.allowed); + } + } + + // ================================================================= + // M21: HalfOpen probe success → Closed (family loop) + // ================================================================= + + @Test + public void testBreakerReClosesAfterHalfOpenSuccess() throws Exception { + long shortTimeoutMs = 100; + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + CircuitBreaker breaker = tripBreakerFor(client, f.originHost, shortTimeoutMs); + TransportStub transport = installStub(client); + + Thread.sleep(shortTimeoutMs + 50); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + + for (int i = 0; i < 10; i++) { + assertTrue(f.name + ": should be Closed after HalfOpen success", + breaker.allow().allowed); + } + } + } + + // ================================================================= + // M22: HalfOpen probe failure → re-Open (family loop) + // ================================================================= + + @Test + public void testBreakerReOpensWhenHalfOpenProbeFails() throws Exception { + long shortTimeoutMs = 100; + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + CircuitBreaker breaker = tripBreakerFor(client, f.originHost, shortTimeoutMs); + TransportStub transport = installStub(client); + + Thread.sleep(shortTimeoutMs + 50); + transport.programFailure(new UnknownHostException("still down")); + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + + assertFalse(f.name + ": should re-Open after HalfOpen failure", + breaker.allow().allowed); + + // Next request short-circuits origin again. + transport.received.clear(); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.firstFailover, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M23: All breakers open → fallback to origin host (family loop) + // ================================================================= + + @Test + public void testAllBreakersOpenFallsBackToOriginHost() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripAllBreakersFor(client, f, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M23a: Candidate rotation follows family TLD order, not just the + // first candidate. Trip origin AND firstFailover's breakers, leaving + // only secondFailover closed — the request must land on secondFailover, + // proving buildCandidateHosts walks the family array in order + // ((tldIdx+1)%len, (tldIdx+2)%len, ...) rather than stopping/looping + // incorrectly after the first entry. + // ================================================================= + + @Test + public void testFailoverAdvancesToSecondCandidateWhenFirstIsAlsoOpen() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + tripBreakerFor(client, f.firstFailover, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name + ": origin and first candidate open, must advance to second", + f.secondFailover, transport.received.get(0).url().host()); + } + } + + // Same idea, but verified directly against selectHost's returned + // Candidate rather than through a full DescribeInstances round-trip — + // pins down the exact rotation order independent of request signing. + @Test + public void testSelectHostWalksCandidatesInFamilyOrder() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + EndpointFailoverInterceptor interceptor = failoverInterceptorOf(client); + Request req = new Request.Builder() + .url("https://" + f.originHost + "/") + .header("Host", f.originHost) + .build(); + + // All breakers closed: origin wins. + assertEquals(f.name, f.originHost, interceptor.selectHost(req).host); + + // Origin open: first candidate (tldIdx+1) wins. + tripBreakerFor(client, f.originHost, 60_000); + assertEquals(f.name, f.firstFailover, interceptor.selectHost(req).host); + + // Origin + first open: second candidate (tldIdx+2) wins. + tripBreakerFor(client, f.firstFailover, 60_000); + assertEquals(f.name, f.secondFailover, interceptor.selectHost(req).host); + + // All three open: falls back to origin. + tripBreakerFor(client, f.secondFailover, 60_000); + assertEquals(f.name, f.originHost, interceptor.selectHost(req).host); + } + } + + // ================================================================= + // M24: One transport attempt per request (family loop) + // ================================================================= + + @Test + public void testEndpointFailureSurfacesAttemptFailure() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("dns miss " + f.name)); + + TencentCloudSDKException sdkEx = null; + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException e) { + sdkEx = e; + } + IOException primary = unwrapToIOException(sdkEx); + assertTrue(f.name, primary.getMessage().contains("dns miss")); + assertEquals(f.name, 0, primary.getSuppressed().length); + assertEquals(f.name, 1, transport.received.size()); + } + } + + // ================================================================= + // M25: Failure preserves original exception type and message (family loop) + // ================================================================= + + @Test + public void testFailurePreservesAttemptCauseType() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new ConnectException("connect fail " + f.name)); + + TencentCloudSDKException sdkEx = null; + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException e) { + sdkEx = e; + } + IOException primary = unwrapToIOException(sdkEx); + assertTrue(f.name, primary instanceof ConnectException); + assertEquals(f.name, 0, primary.getSuppressed().length); + assertEquals(f.name, 1, transport.received.size()); + } + } + + // ================================================================= + // M26: Breaker skip mixed with real failure (family loop) + // ================================================================= + + @Test + public void testFailureMixesPriorBreakerSkipsWithRealFailure() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programFailure(new SSLHandshakeException("tls fail")); + + TencentCloudSDKException sdkEx = null; + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException e) { + sdkEx = e; + } + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name + ": origin skipped, hits first failover", + f.firstFailover, transport.received.get(0).url().host()); + + IOException primary = unwrapToIOException(sdkEx); + assertTrue(f.name, primary instanceof SSLHandshakeException); + assertEquals(f.name, 0, primary.getSuppressed().length); + } + } + + // ================================================================= + // M27: Failure does not pollute next request (family loop) + // ================================================================= + + @Test + public void testFailoverDoesNotPolluteNextRequest() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + + transport.programFailure(new UnknownHostException("run1 " + f.name)); + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + transport.received.clear(); + + transport.programFailure(new UnknownHostException("run2 " + f.name)); + TencentCloudSDKException sdkEx = null; + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException e) { + sdkEx = e; + } + IOException primary = unwrapToIOException(sdkEx); + assertEquals(f.name, 0, primary.getSuppressed().length); + assertTrue(f.name, primary.getMessage().contains("run2")); + assertFalse(f.name, primary.getMessage().contains("run1")); + } + } + + // ================================================================= + // M28: Breaker state isolated across origin hosts (family loop) + // ================================================================= + + @Test + public void testBreakerStateIsolatedAcrossOriginHosts() throws Exception { + // Trip breaker for one family, verify it doesn't affect another family. + // We test isolation between N1 and N2, and between N1 and N3. + Family[][] pairs = {{N1, N2}, {N1, N3}}; + for (Family[] pair : pairs) { + Family a = pair[0]; + Family b = pair[1]; + + CvmClient clientA = newClient(a); + tripBreakerFor(clientA, a.originHost, 60_000); + + CvmClient clientB = newClient(b); + TransportStub transportB = installStub(clientB); + transportB.programOk(); + clientB.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(b.name + " should be unaffected by " + a.name, + 1, transportB.received.size()); + assertEquals(b.name, b.originHost, transportB.received.get(0).url().host()); + } + } + + // ================================================================= + // M29: TC3 V3 re-sign (POST) (family loop) + // ================================================================= + + @Test + public void testTc3ResignOnFailover() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + Request resigned = transport.received.get(0); + assertEquals(f.name, f.firstFailover, resigned.url().host()); + assertTrue(f.name, resigned.header("Authorization").contains("TC3-HMAC-SHA256")); + } + } + + // ================================================================= + // M29a: TC3 resign preserves body and content-type + // ================================================================= + + @Test + public void testTc3ResignPreservesBodyAndContentType() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + Request resigned = transport.received.get(0); + assertEquals(f.name, f.firstFailover, resigned.url().host()); + assertEquals(f.name, "application/json", resigned.header("Content-Type")); + byte[] body = bodyBytes(resigned); + assertTrue(f.name, body.length > 0); + } + } + + // ================================================================= + // M30: TC3 V3 GET re-sign (family loop) + // ================================================================= + + @Test + public void testTc3GetResignOnFailover() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setReqMethod(HttpProfile.REQ_GET); + profile.getHttpProfile().setEndpoint(f.originHost); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + Request resigned = transport.received.get(0); + assertEquals(f.name, f.firstFailover, resigned.url().host()); + assertEquals(f.name, "GET", resigned.method()); + assertTrue(f.name, resigned.header("Authorization").contains("TC3-HMAC-SHA256")); + } + } + + // ================================================================= + // M31: Hmac V1 re-sign (family loop) + // ================================================================= + + @Test + public void testHmacV1ResignOnFailover() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.setSignMethod(ClientProfile.SIGN_SHA1); + profile.getHttpProfile().setEndpoint(f.originHost); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + Request resigned = transport.received.get(0); + assertEquals(f.name, f.firstFailover, resigned.url().host()); + // Hmac V1 embeds Signature in body, not Authorization header. + byte[] body = bodyBytes(resigned); + assertTrue(f.name, new String(body).contains("Signature=")); + } + } + + // ================================================================= + // M32: SKIP V3 — rewrite Host only (family loop) + // ================================================================= + + @Test + public void testSkipSignV3OnFailoverRewritesHostWithoutResigning() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + OkHttpClient http = grabOkHttpClient(client); + Request raw = new Request.Builder() + .url("https://" + f.originHost + "/") + .header("Host", f.originHost) + .header("Authorization", "SKIP") + .header("X-TC-Action", "DescribeInstances") + .header("X-TC-Version", "2017-03-12") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + + Response resp = http.newCall(raw).execute(); + resp.close(); + assertEquals(f.name, 1, transport.received.size()); + Request resigned = transport.received.get(0); + assertEquals(f.name, f.firstFailover, resigned.url().host()); + assertEquals(f.name, f.firstFailover, resigned.header("Host")); + assertEquals(f.name, "SKIP", resigned.header("Authorization")); + } + } + + // ================================================================= + // M33: Re-sign uses current credential (family loop) + // ================================================================= + + @Test + public void testResignUsesCurrentCredential() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + client.setCredential(new Credential("AKIDNEW", "SKNEW")); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertTrue(f.name, transport.received.get(0).header("Authorization") + .contains("Credential=AKIDNEW/")); + } + } + + // ================================================================= + // M34: X-TC-Token rotation (family loop) + // ================================================================= + + @Test + public void testXtcTokenRotationOnFailover() throws Exception { + for (Family f : FAMILIES) { + Credential cred = new Credential("AKIDTEST", "SKTEST", "token-abc"); + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(f.originHost); + CvmClient client = new CvmClient(cred, "ap-guangzhou", profile); + tripBreakerFor(client, f.originHost, 60_000); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, "token-abc", transport.received.get(0).header("X-TC-Token")); + } + } + + // ================================================================= + // M34a: X-TC-Token dropped when cleared + // ================================================================= + + @Test + public void testResignDropsTokenWhenCleared() throws Exception { + for (Family f : FAMILIES) { + Credential cred = new Credential("AKIDTEST", "SKTEST", "token-abc"); + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(f.originHost); + CvmClient client = new CvmClient(cred, "ap-guangzhou", profile); + tripBreakerFor(client, f.originHost, 60_000); + // Clear the token before the failover request. + client.setCredential(new Credential("AKIDTEST", "SKTEST")); + TransportStub transport = installStub(client); + transport.programOk(); + + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name, 1, transport.received.size()); + assertNull(f.name, transport.received.get(0).header("X-TC-Token")); + } + } + + // ================================================================= + // M35: SSE — no failover (family loop) + // ================================================================= + + @Test + public void testSseStreamResponseIsNotJsonValidated() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programResponseWithCt(200, "data: hello\n\n", "text/event-stream"); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + } catch (Exception ignored) { } + assertEquals(f.name, 1, transport.received.size()); + } + } + + // ================================================================= + // M36: No Content-Type — no failover (family loop) + // ================================================================= + + @Test + public void testResponseWithoutContentTypeIsNotJsonValidated() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programResponseWithCt(200, "oops", null); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + } catch (Exception ignored) { } + assertEquals(f.name, 1, transport.received.size()); + } + } + + // ================================================================= + // M37: 200 + JSON business error — no failover (family loop) + // ================================================================= + + @Test + public void testBusinessSdkErrorDoesNotTriggerFailover() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programJsonOk( + "{\"Response\":{\"RequestId\":\"req-bad\",\"Error\":{" + + "\"Code\":\"AuthFailure.SignatureFailure\"," + + "\"Message\":\"signature wrong\"}}}"); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected business SDK exception"); + } catch (TencentCloudSDKException e) { + assertEquals(f.name, "AuthFailure.SignatureFailure", e.getErrorCode()); + assertEquals(f.name, "req-bad", e.getRequestId()); + } + assertEquals(f.name, 1, transport.received.size()); + } + } + + // ================================================================= + // M38: backupEndpoint failover behavior (family loop) + // ================================================================= + + @Test + public void testBackupEndpointFailover() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(f.originHost); + profile.setBackupEndpoint("backup.example.com"); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + TransportStub transport = installStub(client); + + // Origin succeeds. + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name + " origin", 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + + // Trip origin breaker, backup should be used. + tripBreakerFor(client, f.originHost, 60_000); + transport.received.clear(); + transport.programOk(); + client.DescribeInstances(new DescribeInstancesRequest()); + assertEquals(f.name + " backup", 1, transport.received.size()); + String servicePrefix = f.originHost.substring(0, f.originHost.indexOf('.')); + String backupHost = servicePrefix + ".backup.example.com"; + assertEquals(f.name, backupHost, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // M38a-M38d: backupEndpoint detail scenarios (family loop) + // ================================================================= + + @Test + public void testBackupEndpointOriginDnsMissDoesNotRetrySameRequest() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(f.originHost); + profile.setBackupEndpoint("backup.example.com"); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("origin dns miss")); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name + ": no same-request retry to backup", + 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + @Test + public void testBackupEndpointNonFailoverErrorPropagates() throws Exception { + for (Family f : FAMILIES) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(f.originHost); + profile.setBackupEndpoint("backup.example.com"); + CvmClient client = new CvmClient( + new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + TransportStub transport = installStub(client); + transport.programFailure(new IOException("generic I/O error")); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException e) { + assertTrue(f.name, e.getCause() instanceof IOException); + } + assertEquals(f.name, 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + @Test + public void testNoBackupEndpointDnsMissDoesNotRetrySameRequest() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programFailure(new UnknownHostException("dns miss")); + + try { + client.DescribeInstances(new DescribeInstancesRequest()); + fail(f.name + ": expected SDK exception"); + } catch (TencentCloudSDKException ignored) { } + assertEquals(f.name + ": no same-request retry", 1, transport.received.size()); + assertEquals(f.name, f.originHost, transport.received.get(0).url().host()); + } + } + + // ================================================================= + // Helpers + // ================================================================= + + private static CvmClient newClient(Family family) { + ClientProfile profile = new ClientProfile(); + profile.getHttpProfile().setEndpoint(family.originHost); + return new CvmClient(new Credential("AKIDTEST", "SKTEST"), "ap-guangzhou", profile); + } + + private static TransportStub installStub(AbstractClient client) { + TransportStub stub = new TransportStub(); + OkHttpClient orig = grabOkHttpClient(client); + setOkHttpClient(client, orig.newBuilder().addInterceptor(stub).build()); + return stub; + } + + private static OkHttpClient grabOkHttpClient(AbstractClient client) { + try { + Field f = AbstractClient.class.getDeclaredField("httpConnection"); + f.setAccessible(true); + HttpConnection conn = (HttpConnection) f.get(client); + return (OkHttpClient) conn.getHttpClient(); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + private static void setOkHttpClient(AbstractClient client, OkHttpClient http) { + try { + Field f = AbstractClient.class.getDeclaredField("httpConnection"); + f.setAccessible(true); + HttpConnection conn = (HttpConnection) f.get(client); + conn.setHttpClient(http); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + private static EndpointFailoverInterceptor failoverInterceptorOf(AbstractClient client) { + for (Interceptor it : grabOkHttpClient(client).interceptors()) { + if (it instanceof EndpointFailoverInterceptor) { + return (EndpointFailoverInterceptor) it; + } + } + throw new IllegalStateException("EndpointFailoverInterceptor not installed on client"); + } + + private static CircuitBreaker tripBreakerFor(AbstractClient client, String host, long timeoutMs) { + CircuitBreaker breaker = newBreaker(timeoutMs); + failoverInterceptorOf(client).putBreakerForTesting(host, breaker); + tripBreaker(breaker); + return breaker; + } + + private static void tripAllBreakersFor(AbstractClient client, Family f, long timeoutMs) { + for (String host : f.allTldHosts) { + tripBreakerFor(client, host, timeoutMs); + } + } + + private static CircuitBreaker newBreaker(long timeoutMs) { + CircuitBreaker.Setting setting = new CircuitBreaker.Setting(); + setting.timeoutMs = timeoutMs; + return new CircuitBreaker(setting); + } + + private static void tripBreaker(CircuitBreaker breaker) { + for (int i = 0; i < 6; i++) { + CircuitBreaker.Token t = breaker.allow(); + if (t.allowed) { + t.report(false); + } + } + } + + private static IOException unwrapToIOException(TencentCloudSDKException e) { + Throwable cause = e.getCause(); + assertNotNull("SDK exception must wrap an IOException, got null cause", cause); + assertTrue("expected IOException cause, got " + cause.getClass().getName(), + cause instanceof IOException); + return (IOException) cause; + } + + private static byte[] bodyBytes(Request req) throws IOException { + if (req.body() == null) { + return new byte[0]; + } + Buffer buf = new Buffer(); + req.body().writeTo(buf); + return buf.readByteArray(); + } + + // ================================================================= + // TransportStub + // ================================================================= + + private static final class TransportStub implements Interceptor { + final List received = new ArrayList(); + private final Queue programmed = new LinkedList(); + + void programFailure(IOException e) { + programmed.add(e); + } + + void programOk() { + programJsonOk("{\"Response\":{\"RequestId\":\"req-ok\"}}"); + } + + void programJsonOk(String json) { + programmed.add(new ProgrammedResponse(200, json, "application/json")); + } + + void programResponse(int code, String body) { + programmed.add(new ProgrammedResponse(code, body, "application/json")); + } + + void programResponseWithCt(int code, String body, String contentType) { + programmed.add(new ProgrammedResponse(code, body, contentType)); + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + received.add(request); + Object next = programmed.poll(); + if (next == null) { + throw new IllegalStateException( + "TransportStub got an unexpected request to " + + request.url() + " — no programmed outcome left"); + } + if (next instanceof IOException) { + throw (IOException) next; + } + ProgrammedResponse pr = (ProgrammedResponse) next; + Response.Builder b = new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(pr.code) + .message(pr.code == 200 ? "OK" : "Error"); + if (pr.contentType != null) { + b.header("Content-Type", pr.contentType); + b.body(ResponseBody.create(MediaType.parse(pr.contentType), pr.body)); + } else { + b.body(ResponseBody.create(null, pr.body)); + } + return b.build(); + } + + private static final class ProgrammedResponse { + final int code; + final String body; + final String contentType; + + ProgrammedResponse(int code, String body, String contentType) { + this.code = code; + this.body = body; + this.contentType = contentType; + } + } + } +} diff --git a/src/test/java/com/tencentcloudapi/common/RequestBuilderTest.java b/src/test/java/com/tencentcloudapi/common/RequestBuilderTest.java new file mode 100644 index 0000000000..0cb5a93a69 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/RequestBuilderTest.java @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS IS" + * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.tencentcloudapi.common; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import org.junit.Test; + +import java.io.IOException; +import java.net.URL; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests for {@link RequestBuilder}. + * + *

Covers the three signing paths (TC3, v1, skip), the URL/host separation + * ({@code withURL} decoupling the connection target from the signature host), + * default-host fallback when {@code withHost} is not called, and the {@code build()} + * validation failures. + */ +public class RequestBuilderTest { + + private static final String ORIGIN_HOST = "cvm.tencentcloudapi.com"; + private static final String FAILOVER_HOST = "cvm.internal.tencentcloudapi.com"; + private static final Credential CRED = new Credential("AKIDTEST", "SKTEST"); + + // ================================================================= + // Helpers + // ================================================================= + + /** Builds a signed origin request so tests start from a realistic input. */ + private static Request signedOriginRequest() throws IOException, TencentCloudSDKException { + return new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(ORIGIN_HOST) + .build(); + } + + private static Request newPostRequest(String urlHost, String headerHost) { + return new Request.Builder() + .url("https://" + urlHost + "/") + .header("Host", headerHost) + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + } + + /** GET request with a query string (for v1 sign tests). */ + private static Request newGetRequest(String urlHost, String headerHost) { + return new Request.Builder() + .url("https://" + urlHost + "/?Action=DescribeInstances&Version=2017-03-12") + .header("Host", headerHost) + .get() + .build(); + } + + private static Request newFormPostRequest(String urlHost, String headerHost) { + return new Request.Builder() + .url("https://" + urlHost + "/") + .header("Host", headerHost) + .header("Content-Type", "application/x-www-form-urlencoded") + .post(RequestBody.create(MediaType.parse("application/x-www-form-urlencoded"), + "Action=DescribeInstances&Version=2017-03-12".getBytes())) + .build(); + } + + private static Request newSkipRequest(String urlHost) { + return new Request.Builder() + .url("https://" + urlHost + "/") + .header("Host", urlHost) + .header("Authorization", "SKIP") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + } + + // ================================================================= + // build() validation + // ================================================================= + + @Test + public void testBuildThrowsWhenCredentialMissing() throws IOException { + try { + new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(FAILOVER_HOST) + .build(); + fail("expected TencentCloudSDKException"); + } catch (TencentCloudSDKException e) { + assertTrue(e.getMessage().contains("credential")); + } + } + + @Test + public void testBuildThrowsWhenSignMethodMissing() throws IOException { + try { + new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withHost(FAILOVER_HOST) + .build(); + fail("expected TencentCloudSDKException"); + } catch (TencentCloudSDKException e) { + assertTrue(e.getMessage().contains("sign method")); + } + } + + // ================================================================= + // TC3 signing + host rewrite + // ================================================================= + + // withUrlHost + withHost rewrites both URL host and Host header to the target. + @Test + public void testWithHostRewritesUrlAndHeader() throws Exception { + Request out = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withUrlHost(FAILOVER_HOST) + .withHost(FAILOVER_HOST) + .build(); + + assertEquals(FAILOVER_HOST, out.url().host()); + assertEquals(443, out.url().port()); + assertEquals(FAILOVER_HOST, out.header("Host")); + assertNotNull(out.header("Authorization")); + assertNotNull(out.header("X-TC-Timestamp")); + } + + // Re-signing for a different host produces a different signature. + @Test + public void testReSignProducesDifferentAuthorization() throws Exception { + Request origin = signedOriginRequest(); + String originAuth = origin.header("Authorization"); + + Request resigned = new RequestBuilder(origin) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(FAILOVER_HOST) + .build(); + + assertNotEquals(originAuth, resigned.header("Authorization")); + assertEquals(FAILOVER_HOST, resigned.header("Host")); + } + + // Token in credential is propagated to X-TC-Token. + @Test + public void testTokenPropagatedToHeader() throws Exception { + Credential withToken = new Credential("AKIDTEST", "SKTEST", "token-value"); + Request out = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(withToken) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(FAILOVER_HOST) + .build(); + assertEquals("token-value", out.header("X-TC-Token")); + } + + // Empty token removes the X-TC-Token header. + @Test + public void testEmptyTokenRemovesHeader() throws Exception { + Request origin = new Request.Builder() + .url("https://" + ORIGIN_HOST + "/") + .header("Host", ORIGIN_HOST) + .header("X-TC-Token", "stale") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + Request out = new RequestBuilder(origin) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(FAILOVER_HOST) + .build(); + assertNull(out.header("X-TC-Token")); + } + + @Test + public void testExplicitServiceUsedInTc3CredentialScope() throws Exception { + Request out = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost("gateway.example.com") + .withService("cvm") + .build(); + + assertTrue(out.header("Authorization").contains("/cvm/tc3_request")); + } + + // Unsigned-payload flag is reflected in the canonical request hash. + @Test + public void testUnsignedPayloadChangesAuthorization() throws Exception { + Request signed = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(FAILOVER_HOST) + .build(); + Request unsigned = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withUnsignedPayload(true) + .withHost(FAILOVER_HOST) + .build(); + assertNotEquals(signed.header("Authorization"), unsigned.header("Authorization")); + } + + // ================================================================= + // withURL — URL/connect host decoupled from signing host + // ================================================================= + + // withURL overrides the connection target without changing the signature. + @Test + public void testWithURLOverridesConnectTarget() throws Exception { + URL connectUrl = new URL("https", "10.0.0.1", 8080, "/"); + Request out = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(ORIGIN_HOST) + .withURL(connectUrl) + .build(); + + // URL points to the instance IP:port. + assertEquals("10.0.0.1", out.url().host()); + assertEquals(8080, out.url().port()); + // Host header + signature are for the business domain. + assertEquals(ORIGIN_HOST, out.header("Host")); + assertNotNull(out.header("Authorization")); + } + + // The signature computed with withURL must match one computed with only + // withHost on the same signing host — i.e. withURL does not influence the + // signature, only the connection target. + @Test + public void testWithURLDoesNotAffectSignature() throws Exception { + Request noUrl = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(ORIGIN_HOST) + .build(); + Request withUrl = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .withHost(ORIGIN_HOST) + .withURL(new URL("https", "10.0.0.1", 8080, "/")) + .build(); + assertEquals(noUrl.header("Authorization"), withUrl.header("Authorization")); + assertEquals(noUrl.header("X-TC-Timestamp"), withUrl.header("X-TC-Timestamp")); + } + + // ================================================================= + // Default host fallback (no withHost) + // ================================================================= + + // When withHost is not called, the Host header / URL host are preserved + // from the original request. The request is still re-signed for that host. + @Test + public void testNoWithHostPreservesOriginHost() throws Exception { + Request out = new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .build(); + assertEquals(ORIGIN_HOST, out.url().host()); + assertEquals(ORIGIN_HOST, out.header("Host")); + assertNotNull(out.header("Authorization")); + } + + // When withHost is not called and Host header is absent, the URL host is + // used as the signing host. + @Test + public void testNoWithHostFallsBackToUrlHostWhenHeaderAbsent() throws Exception { + Request origin = new Request.Builder() + .url("https://" + ORIGIN_HOST + "/") + .header("Content-Type", "application/json") + .post(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + Request out = new RequestBuilder(origin) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_TC3_256) + .build(); + assertEquals(ORIGIN_HOST, out.header("Host")); + assertNotNull(out.header("Authorization")); + } + + // ================================================================= + // v1 signing (HmacSHA1 / HmacSHA256) + // ================================================================= + + @Test + public void testV1SignGetAppendsSignatureToQuery() throws Exception { + Request out = new RequestBuilder(newGetRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_SHA1) + .withUrlHost(FAILOVER_HOST) + .withHost(FAILOVER_HOST) + .build(); + String query = out.url().query(); + assertNotNull(query); + assertTrue(query.contains("Signature=")); + assertTrue(query.contains("SecretId=")); + assertEquals(FAILOVER_HOST, out.url().host()); + } + + @Test + public void testV1SignPostAppendsSignatureToBody() throws Exception { + Request out = new RequestBuilder(newFormPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_SHA256) + .withUrlHost(FAILOVER_HOST) + .withHost(FAILOVER_HOST) + .build(); + // Body is a form-encoded string ending with Signature=... + assertEquals(FAILOVER_HOST, out.url().host()); + assertNotNull(out.body()); + } + + @Test + public void testV1SignUnsupportedMethodThrows() throws IOException { + Request origin = new Request.Builder() + .url("https://" + ORIGIN_HOST + "/") + .header("Host", ORIGIN_HOST) + .put(RequestBody.create(MediaType.parse("application/json"), "{}".getBytes())) + .build(); + try { + new RequestBuilder(origin) + .withCredential(CRED) + .withSignMethod(ClientProfile.SIGN_SHA1) + .withHost(FAILOVER_HOST) + .build(); + fail("expected TencentCloudSDKException"); + } catch (TencentCloudSDKException e) { + assertTrue(e.getMessage().contains("Method only support")); + } + } + + // ================================================================= + // Sign skip path + // ================================================================= + + // Authorization: SKIP → buildSkip path, no signature computed. + @Test + public void testSkipV3StripsHostHeader() throws Exception { + Request out = new RequestBuilder(newSkipRequest(ORIGIN_HOST)) + .withCredential(CRED) + .withUrlHost(FAILOVER_HOST) + .withHost(FAILOVER_HOST) + .build(); + assertEquals(FAILOVER_HOST, out.header("Host")); + // No real Authorization was added — still "SKIP". + assertEquals("SKIP", out.header("Authorization")); + } + + @Test + public void testUnsupportedSignMethodThrows() throws IOException { + try { + new RequestBuilder(newPostRequest(ORIGIN_HOST, ORIGIN_HOST)) + .withCredential(CRED) + .withSignMethod("bogus") + .withHost(FAILOVER_HOST) + .build(); + fail("expected TencentCloudSDKException"); + } catch (TencentCloudSDKException e) { + assertTrue(e.getMessage().contains("invalid or not supported")); + } + } +} diff --git a/src/test/java/com/tencentcloudapi/common/http/HttpConnectionTest.java b/src/test/java/com/tencentcloudapi/common/http/HttpConnectionTest.java new file mode 100644 index 0000000000..3e47f05647 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/http/HttpConnectionTest.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2018 Tencent. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.tencentcloudapi.common.http; + +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import okhttp3.Headers; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.Test; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; +import java.io.IOException; +import java.net.Proxy; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests for {@link HttpConnection}. + * + *

Uses an OkHttp application interceptor stub to capture the {@link Request} + * that {@link HttpConnection} builds (verifying url/method/body/headers) and + * to short-circuit the call with a canned {@link Response} — no real network + * is involved. + */ +public class HttpConnectionTest { + + // ================================================================= + // Constructor / timeouts + // ================================================================= + + @Test + public void testConstructorAppliesTimeouts() { + HttpConnection conn = new HttpConnection(3, 7, 11); + OkHttpClient client = (OkHttpClient) conn.getHttpClient(); + // OkHttp exposes timeouts in milliseconds. + assertEquals(3000, client.connectTimeoutMillis()); + assertEquals(7000, client.readTimeoutMillis()); + assertEquals(11000, client.writeTimeoutMillis()); + } + + // ================================================================= + // Interceptors + // ================================================================= + + @Test + public void testAddInterceptorsPreservesOrder() { + HttpConnection conn = new HttpConnection(1, 1, 1); + Interceptor a = new NoopInterceptor(); + Interceptor b = new NoopInterceptor(); + conn.addInterceptors(a); + conn.addInterceptors(b); + List list = conn.getInterceptors(); + // Application interceptors are appended in add order. + assertTrue("first interceptor should be a", list.contains(a)); + assertTrue("second interceptor should be b", list.contains(b)); + assertEquals(a, list.get(list.size() - 2)); + assertEquals(b, list.get(list.size() - 1)); + } + + // ================================================================= + // Proxy / SSL / HostnameVerifier configuration + // ================================================================= + + @Test + public void testSetProxyAppliesToClient() { + HttpConnection conn = new HttpConnection(1, 1, 1); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new java.net.InetSocketAddress("127.0.0.1", 8888)); + conn.setProxy(proxy); + OkHttpClient client = (OkHttpClient) conn.getHttpClient(); + assertEquals(proxy, client.proxy()); + } + + @Test + public void testSetHostnameVerifierAppliesToClient() { + HttpConnection conn = new HttpConnection(1, 1, 1); + HostnameVerifier verifier = new HostnameVerifier() { + @Override + public boolean verify(String hostname, SSLSession session) { + return true; + } + }; + conn.setHostnameVerifier(verifier); + OkHttpClient client = (OkHttpClient) conn.getHttpClient(); + assertSame(verifier, client.hostnameVerifier()); + } + + /** + * Configuration calls chain: each {@code newBuilder().build()} carries + * forward previously set config. Verify proxy survives a subsequent + * hostname-verifier set. + */ + @Test + public void testConfigChainingPreservesPriorSettings() { + HttpConnection conn = new HttpConnection(1, 1, 1); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new java.net.InetSocketAddress("127.0.0.1", 8888)); + conn.setProxy(proxy); + conn.setHostnameVerifier(new HostnameVerifier() { + @Override + public boolean verify(String h, SSLSession s) { return true; } + }); + OkHttpClient client = (OkHttpClient) conn.getHttpClient(); + assertEquals("proxy must survive a later newBuilder().build()", + proxy, client.proxy()); + assertNotNull("hostnameVerifier must also be set", + client.hostnameVerifier()); + } + + // ================================================================= + // getRequest + // ================================================================= + + @Test + public void testGetRequestBuildsGetWithUrl() throws Exception { + CapturingInterceptor cap = new CapturingInterceptor(); + HttpConnection conn = newConnectionWith(cap); + conn.getRequest("https://example.com/path?q=1"); + assertNotNull(cap.seen); + assertEquals("GET", cap.seen.method()); + assertEquals("https://example.com/path?q=1", cap.seen.url().toString()); + } + + @Test + public void testGetRequestWithHeadersCarriesHeaders() throws Exception { + CapturingInterceptor cap = new CapturingInterceptor(); + HttpConnection conn = newConnectionWith(cap); + Headers headers = new Headers.Builder() + .add("X-TC-Action", "DescribeInstances") + .add("X-TC-Region", "ap-guangzhou") + .build(); + conn.getRequest("https://example.com/", headers); + assertEquals("DescribeInstances", cap.seen.header("X-TC-Action")); + assertEquals("ap-guangzhou", cap.seen.header("X-TC-Region")); + } + + @Test + public void testGetRequestIllegalUrlThrowsTencentCloudSdkException() { + HttpConnection conn = new HttpConnection(1, 1, 1); + try { + conn.getRequest("not a url"); + fail("expected TencentCloudSDKException for illegal url"); + } catch (TencentCloudSDKException e) { + assertTrue("message should mention IllegalArgumentException, got: " + e.getMessage(), + e.getMessage().contains("IllegalArgumentException")); + } catch (IOException e) { + fail("expected TencentCloudSDKException, got IOException: " + e); + } + } + + // ================================================================= + // postRequest + // ================================================================= + + @Test + public void testPostRequestStringBodyDefaultsFormUrlencoded() throws Exception { + CapturingInterceptor cap = new CapturingInterceptor(); + HttpConnection conn = newConnectionWith(cap); + conn.postRequest("https://example.com/", "a=1&b=2"); + assertEquals("POST", cap.seen.method()); + assertEquals("https://example.com/", cap.seen.url().toString()); + MediaType ct = cap.seen.body().contentType(); + assertNotNull(ct); + assertEquals("application/x-www-form-urlencoded", ct.type() + "/" + ct.subtype()); + } + + @Test + public void testPostRequestStringBodyWithHeadersUsesContentType() throws Exception { + CapturingInterceptor cap = new CapturingInterceptor(); + HttpConnection conn = newConnectionWith(cap); + Headers headers = new Headers.Builder() + .add("Content-Type", "application/json") + .add("X-TC-Action", "CallJson") + .build(); + conn.postRequest("https://example.com/", "{\"k\":\"v\"}", headers); + assertEquals("POST", cap.seen.method()); + MediaType ct = cap.seen.body().contentType(); + assertNotNull(ct); + assertEquals("application/json", ct.type() + "/" + ct.subtype()); + assertEquals("CallJson", cap.seen.header("X-TC-Action")); + } + + @Test + public void testPostRequestByteBodyWithHeaders() throws Exception { + CapturingInterceptor cap = new CapturingInterceptor(); + HttpConnection conn = newConnectionWith(cap); + Headers headers = new Headers.Builder() + .add("Content-Type", "application/octet-stream") + .build(); + byte[] body = {1, 2, 3, 4}; + conn.postRequest("https://example.com/", body, headers); + assertEquals("POST", cap.seen.method()); + MediaType ct = cap.seen.body().contentType(); + assertNotNull(ct); + assertEquals("application/octet-stream", ct.type() + "/" + ct.subtype()); + // Body length preserved. + assertEquals(4, cap.seen.body().contentLength()); + } + + @Test + public void testPostRequestIllegalUrlThrowsTencentCloudSdkException() { + HttpConnection conn = new HttpConnection(1, 1, 1); + try { + conn.postRequest("::not a url::", "body"); + fail("expected TencentCloudSDKException for illegal url"); + } catch (TencentCloudSDKException e) { + assertTrue(e.getMessage().contains("IllegalArgumentException")); + } catch (IOException e) { + fail("expected TencentCloudSDKException, got IOException: " + e); + } + } + + // ================================================================= + // doRequest + // ================================================================= + + @Test + public void testDoRequestExecutesAndReturnsResponse() throws Exception { + CapturingInterceptor cap = new CapturingInterceptor(); + HttpConnection conn = newConnectionWith(cap); + Request req = new Request.Builder().url("https://example.com/").build(); + Response resp = conn.doRequest(req); + assertNotNull(resp); + assertEquals(200, resp.code()); + assertNotNull(cap.seen); + assertEquals("https://example.com/", cap.seen.url().toString()); + resp.close(); + } + + // ================================================================= + // setHttpClient / getHttpClient round-trip + // ================================================================= + + @Test + public void testSetGetHttpClientRoundTrip() { + HttpConnection conn = new HttpConnection(1, 1, 1); + OkHttpClient custom = new OkHttpClient(); + conn.setHttpClient(custom); + assertSame(custom, conn.getHttpClient()); + } + + // ================================================================= + // Helpers + // ================================================================= + + /** Builds an HttpConnection and inserts the capturing interceptor as the last app interceptor. */ + private static HttpConnection newConnectionWith(CapturingInterceptor cap) { + HttpConnection conn = new HttpConnection(1, 1, 1); + conn.addInterceptors(cap); + return conn; + } + + /** A no-op interceptor that just proceeds. */ + private static final class NoopInterceptor implements Interceptor { + @Override + public Response intercept(Chain chain) throws IOException { + return chain.proceed(chain.request()); + } + } + + /** + * Captures the request handed to {@code chain.proceed} and returns a canned + * 200 response, short-circuiting the network. + */ + private static final class CapturingInterceptor implements Interceptor { + Request seen; + + @Override + public Response intercept(Chain chain) throws IOException { + seen = chain.request(); + return new Response.Builder() + .request(seen) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(ResponseBody.create(null, "")) + .build(); + } + } +}