From f4c8f561a53c77b03219d783846fb8fe367d3127 Mon Sep 17 00:00:00 2001 From: sesky4 Date: Fri, 19 Jun 2026 19:55:38 +0800 Subject: [PATCH] feat(common): domain failover via HTTP interceptor with circuit breakers --- .../common/AbstractClient.java | 149 +- .../common/CircuitBreaker.java | 9 + .../common/EndpointFailoverInterceptor.java | 607 +++++++ .../common/SSEResponseModel.java | 10 +- .../common/profile/ClientProfile.java | 29 + .../provider/DefaultCredentialsProvider.java | 2 - .../EndpointFailoverInterceptorTest.java | 1555 +++++++++++++++++ 7 files changed, 2274 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java create mode 100644 src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java diff --git a/src/main/java/com/tencentcloudapi/common/AbstractClient.java b/src/main/java/com/tencentcloudapi/common/AbstractClient.java index f182117def..fe48a77c04 100644 --- a/src/main/java/com/tencentcloudapi/common/AbstractClient.java +++ b/src/main/java/com/tencentcloudapi/common/AbstractClient.java @@ -88,8 +88,8 @@ public abstract class AbstractClient { // Handles HTTP connections. private HttpConnection httpConnection; - // Circuit breaker for handling region failures. - private CircuitBreaker regionBreaker; + // Endpoint failover interceptor, stored so breaker can be injected later. + private EndpointFailoverInterceptor failoverInterceptor; /** * Constructor for AbstractClient with default client profile. @@ -136,7 +136,10 @@ public AbstractClient( this.httpConnection.addInterceptors(this.log); this.trySetProxy(this.httpConnection); this.trySetSSLSocketFactory(this.httpConnection); - this.trySetRegionBreaker(); + if (this.profile.isEnableDomainFailover()) { + this.failoverInterceptor = new EndpointFailoverInterceptor(this); + this.httpConnection.addInterceptors(this.failoverInterceptor); + } this.trySetHostnameVerifier(this.httpConnection); this.trySetHttpClient(); warmup(); @@ -435,13 +438,6 @@ private void trySetHostnameVerifier(HttpConnection conn) { } } - 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) { @@ -453,6 +449,9 @@ private void trySetHttpClient() { * Executes an API request and returns the raw string response. * Handles circuit breaking for region failover. * + /** + * 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. @@ -461,31 +460,15 @@ private void trySetHttpClient() { 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 " + @@ -496,29 +479,16 @@ protected String internalRequest(AbstractModel request, String actionName) 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, @@ -530,7 +500,6 @@ protected String internalRequest(AbstractModel request, String actionName) /** * 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. @@ -541,27 +510,13 @@ protected String internalRequest(AbstractModel request, String actionName) */ 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 processResponseSSE(resp, typeOfT); } - return processResponseJson(resp, typeOfT, breakerToken); + return processResponseJson(resp, typeOfT); } catch (IOException e) { - // Network failure: report to circuit breaker and throw exception. - if (breakerToken != null) { - breakerToken.report(false); - } throw new TencentCloudSDKException("", e); } } @@ -569,39 +524,48 @@ protected T internalRequest(AbstractModel request, String actionName, Class< /** * 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. + * @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, CircuitBreaker.Token breakerToken) throws TencentCloudSDKException { + protected T processResponseSSE(Response resp, Class typeOfT) 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; } + /** + * 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 breakerToken The circuit breaker token. - * @param The type of the response object. + * @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, CircuitBreaker.Token breakerToken) throws TencentCloudSDKException { + protected T processResponseJson(Response resp, Class typeOfT) throws TencentCloudSDKException { String body; try { body = resp.body().string(); @@ -623,29 +587,31 @@ protected T processResponseJson(Response resp, Class typeOfT, CircuitBrea 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; } + /** + * 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. * @@ -1087,11 +1053,28 @@ public Object retry(AbstractModel req, int retryTimes) throws TencentCloudSDKExc 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() { - return regionBreaker; + return failoverInterceptor != null ? failoverInterceptor.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) { - this.regionBreaker = regionBreaker; + if (failoverInterceptor != null) { + failoverInterceptor.setRegionBreaker(regionBreaker); + } } + } diff --git a/src/main/java/com/tencentcloudapi/common/CircuitBreaker.java b/src/main/java/com/tencentcloudapi/common/CircuitBreaker.java index 344d7a9ea4..00c3ee0c89 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. * 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..27c6846aa8 --- /dev/null +++ b/src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java @@ -0,0 +1,607 @@ +/* + * 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.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import okhttp3.*; +import okio.Buffer; + +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLPeerUnverifiedException; +import java.io.IOException; +import java.io.StringReader; +import java.io.UnsupportedEncodingException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; +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.cn", + "ai.tencentcloudapi.com.cn", + }, + { + "internal.tencentcloudapi.com", + "internal.tencentcloudapi.cn", + "internal.tencentcloudapi.com.cn", + }, + { + "tencentcloudapi.com", + "tencentcloudapi.cn", + "tencentcloudapi.com.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(); + + /** + * The circuit breaker instance set by the user via + * {@link AbstractClient#setRegionBreaker}. Stored here so + * {@link AbstractClient#getRegionBreaker} can return it. + * When non-null, its settings are used for all per-host breakers + * created by this interceptor. + */ + 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(); + + Candidate c = candidateFor(originHost); + if (c == null) { + return chain.proceed(request); + } + + try { + 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 candidate host, or null to pass through. */ + private Candidate candidateFor(String originHost) { + if (backupEndpoint != null) { + CircuitBreaker.Token token = breakerFor(originHost).allow(); + if (token.allowed) { + return new Candidate(originHost, token); + } + String backupHost = serviceOf(originHost) + "." + backupEndpoint; + token = breakerFor(backupHost).allow(); + if (token.allowed) { + return new Candidate(backupHost, token); + } + return null; + } + Match match = matchFamily(originHost); + if (match == null) { + return null; + } + String[] family = match.family; + for (int offset = 0; offset < family.length; offset++) { + int tldIdx = (match.tldIdx + offset) % family.length; + String host = match.prefix + "." + family[tldIdx]; + CircuitBreaker.Token token = breakerFor(host).allow(); + if (token.allowed) { + return new Candidate(host, token); + } + } + return null; + } + + 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 new RequestResigner(client, request).resignFor(targetHost); + } + + private 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 m.prefix + "." + m.family[newTldIdx]; + } + + private static String serviceOf(String host) { + int dot = host.indexOf('.'); + return dot < 0 ? host : host.substring(0, dot); + } + + // Failure classification. + + // These errors indicate the host is unreachable or compromised, not just + // a transient application error. + private static boolean shouldFailover(IOException e) { + return e instanceof UnknownHostException + || e instanceof SSLPeerUnverifiedException + || e instanceof SSLHandshakeException + || e instanceof ConnectException + || e instanceof NoRouteToHostException + || e instanceof PortUnreachableException + || 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 that the response is healthy (200 + valid JSON body for JSON + * content types). The body is buffered before returning so downstream code + * can still read it. Non-JSON bodies (e.g. SSE, binary) are not inspected. + */ + 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); + } + if (!isJsonContent(resp)) { + return resp; + } + ResponseBody body = resp.body(); + if (body == null) { + resp.close(); + throw new UnhealthyResponseException("response has no body"); + } + MediaType mt = body.contentType(); + byte[] bytes; + try { + bytes = body.bytes(); + } catch (IOException e) { + resp.close(); + throw new UnhealthyResponseException( + "failed to read response body for JSON validation: " + e.getMessage()); + } + Response rebuilt = resp.newBuilder() + .body(ResponseBody.create(mt, bytes)) + .build(); + if (!isValidJson(new String(bytes, StandardCharsets.UTF_8))) { + rebuilt.close(); + throw new UnhealthyResponseException("response body is not valid JSON"); + } + return rebuilt; + } + + private static boolean isJsonContent(Response resp) { + String ct = resp.header("Content-Type"); + if (ct == null) { + return false; + } + return ct.toLowerCase(Locale.ROOT).contains("application/json"); + } + + private static boolean isValidJson(String s) { + try { + JsonReader reader = new JsonReader(new StringReader(s)); + reader.setLenient(false); + reader.skipValue(); + return reader.peek() == JsonToken.END_DOCUMENT; + } catch (IOException e) { + return false; + } + } + + // Request re-signing for an alternate host. + + // Reads the body once and dispatches to the right signing method so the + // caller only needs resignFor(host). + private static final class RequestResigner { + private final AbstractClient client; + private final Request original; + private final String httpMethod; + private final byte[] payload; + + RequestResigner(AbstractClient client, Request original) throws IOException { + this.client = client; + this.original = original; + this.httpMethod = original.method(); + this.payload = readRequestBody(original); + } + + Request resignFor(String targetHost) throws TencentCloudSDKException, IOException { + String sm = client.getClientProfile().getSignMethod(); + boolean skipSignV3 = ClientProfile.SIGN_TC3_256.equals(sm) + && "SKIP".equals(original.header("Authorization")); + if (skipSignV3) { + return rewriteSkipSignV3(targetHost); + } + if (ClientProfile.SIGN_TC3_256.equals(sm)) { + return resignV3(targetHost); + } + if (ClientProfile.SIGN_SHA1.equals(sm) || ClientProfile.SIGN_SHA256.equals(sm)) { + return resignV1(targetHost); + } + throw new TencentCloudSDKException( + "Signature method " + sm + " is invalid or not supported yet."); + } + + // No signature needed — just rewrite the host. + private Request rewriteSkipSignV3(String targetHost) { + Headers.Builder hb = copyHeadersExcluding(); + hb.add("Host", targetHost); + return rebuildRequest(targetHost, hb.build()); + } + + private Request resignV3(String targetHost) throws TencentCloudSDKException { + Credential credential = client.getCredential(); + ClientProfile profile = client.getClientProfile(); + String contentType = original.header("Content-Type"); + if (contentType == null) { + contentType = "application/x-www-form-urlencoded"; + } + + String canonicalUri = original.url().encodedPath(); + if (canonicalUri == null || canonicalUri.isEmpty()) { + canonicalUri = "/"; + } + String canonicalQueryString = canonicalQueryStringFromUrl(original.url(), httpMethod); + String canonicalHeaders = "content-type:" + contentType + "\nhost:" + targetHost + "\n"; + String signedHeaders = "content-type;host"; + String hashedRequestPayload = profile.isUnsignedPayload() + ? Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8)) + : Sign.sha256Hex(payload); + String canonicalRequest = httpMethod + "\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 = targetHost.split("\\.")[0]; + String credentialScope = date + "/" + service + "/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, service); + 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 = copyHeadersExcluding("Authorization", "X-TC-Timestamp"); + hb.add("Host", targetHost); + hb.add("Authorization", authorization); + hb.add("X-TC-Timestamp", timestamp); + String token = credential.getToken(); + if (token != null && !token.isEmpty()) { + hb.set("X-TC-Token", token); + } else { + hb.removeAll("X-TC-Token"); + } + return rebuildRequest(targetHost, hb.build()); + } + + private Request resignV1(String targetHost) throws TencentCloudSDKException { + Credential credential = client.getCredential(); + ClientProfile profile = client.getClientProfile(); + + Map params; + if (HttpProfile.REQ_GET.equalsIgnoreCase(httpMethod)) { + params = decodeQueryParams(original.url()); + } else if (HttpProfile.REQ_POST.equalsIgnoreCase(httpMethod)) { + params = decodeFormParams(new String(payload, StandardCharsets.UTF_8)); + } else { + throw new TencentCloudSDKException("Method only support (GET, POST) for Hmac sign"); + } + params.remove("Signature"); + if (credential.getSecretId() != null && !credential.getSecretId().isEmpty()) { + params.put("SecretId", credential.getSecretId()); + } + if (credential.getToken() != null && !credential.getToken().isEmpty()) { + params.put("Token", credential.getToken()); + } else { + params.remove("Token"); + } + + String plainText = Sign.makeSignPlainText( + new TreeMap(params), + httpMethod, targetHost, original.url().encodedPath()); + String signature = Sign.sign( + credential.getSecretKey(), plainText, profile.getSignMethod()); + + StringBuilder body = new StringBuilder(); + try { + for (Map.Entry entry : params.entrySet()) { + body.append(URLEncoder.encode(entry.getKey(), "utf-8")) + .append("=") + .append(URLEncoder.encode(entry.getValue(), "utf-8")) + .append("&"); + } + body.append("Signature=").append(URLEncoder.encode(signature, "utf-8")); + } catch (UnsupportedEncodingException e) { + throw new TencentCloudSDKException("", e); + } + + HttpUrl newUrl = original.url().newBuilder().host(targetHost).build(); + Request.Builder rb = original.newBuilder(); + if (HttpProfile.REQ_GET.equalsIgnoreCase(httpMethod)) { + rb.url(newUrl.newBuilder().encodedQuery(body.toString()).build()).get(); + } else { + rb.url(newUrl).post(RequestBody.create( + MediaType.parse("application/x-www-form-urlencoded"), + body.toString())); + } + if (original.header("Host") != null) { + rb.header("Host", targetHost); + } + return rb.build(); + } + + // helpers + + private Headers.Builder copyHeadersExcluding(String... excludes) { + Headers.Builder hb = new Headers.Builder(); + Headers headers = original.headers(); + outer: + for (int i = 0, n = headers.size(); i < n; i++) { + String name = headers.name(i); + if (name.equalsIgnoreCase("Host")) { + continue; + } + for (String e : excludes) { + if (name.equalsIgnoreCase(e)) { + continue outer; + } + } + hb.add(name, headers.value(i)); + } + return hb; + } + + private Request rebuildRequest(String targetHost, Headers headers) { + HttpUrl newUrl = original.url().newBuilder().host(targetHost).build(); + Request.Builder rb = original.newBuilder().url(newUrl).headers(headers); + if (HttpProfile.REQ_POST.equalsIgnoreCase(httpMethod)) { + String contentType = original.header("Content-Type"); + rb.post(RequestBody.create( + contentType == null ? null : MediaType.parse(contentType), + payload)); + } else if (HttpProfile.REQ_GET.equalsIgnoreCase(httpMethod)) { + rb.get(); + } + return rb.build(); + } + + private static byte[] readRequestBody(Request request) throws IOException { + RequestBody body = request.body(); + if (body == null) { + return new byte[0]; + } + Buffer buffer = new Buffer(); + body.writeTo(buffer); + return buffer.readByteArray(); + } + + // TC3 canonical query string: sorted, URL-encoded key=value pairs. + private static String canonicalQueryStringFromUrl(HttpUrl url, String method) + throws TencentCloudSDKException { + if (HttpProfile.REQ_POST.equalsIgnoreCase(method)) { + return ""; + } + TreeMap sorted = new TreeMap(); + for (int i = 0, n = url.querySize(); i < n; i++) { + String value = url.queryParameterValue(i); + sorted.put(url.queryParameterName(i), value == null ? "" : value); + } + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : sorted.entrySet()) { + try { + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(e.getKey()).append("=") + .append(URLEncoder.encode(e.getValue(), "UTF8")); + } catch (UnsupportedEncodingException ex) { + throw new TencentCloudSDKException("UTF8 is 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; + } + } +} 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/profile/ClientProfile.java b/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java index c26389200d..93c003a947 100644 --- a/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java +++ b/src/main/java/com/tencentcloudapi/common/profile/ClientProfile.java @@ -67,6 +67,19 @@ public class ClientProfile { // 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". @@ -211,4 +224,20 @@ public String getBackupEndpoint() { 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/provider/DefaultCredentialsProvider.java b/src/main/java/com/tencentcloudapi/common/provider/DefaultCredentialsProvider.java index 09a44e2139..2d7c77d66f 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 { 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..3ea0158269 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java @@ -0,0 +1,1555 @@ +/* + * 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) + *
  9. 200 + invalid JSON body + *
  10. 200 + empty body + *
+ * + *

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.cn", + "cvm.tencentcloudapi.com.cn"); + + private static final Family N2 = new Family( + "AI", "ai.tencentcloudapi.com", + "hunyuan.ai.tencentcloudapi.com", + "hunyuan.ai.tencentcloudapi.cn", + "hunyuan.ai.tencentcloudapi.com.cn"); + + private static final Family N3 = new Family( + "Internal", "internal.tencentcloudapi.com", + "cvm.internal.tencentcloudapi.com", + "cvm.internal.tencentcloudapi.cn", + "cvm.internal.tencentcloudapi.com.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("hunyuan.ai.tencentcloudapi.com")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("hunyuan.ai.tencentcloudapi.cn")); + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("hunyuan.ai.tencentcloudapi.com.cn")); + // Region-pinned. + assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("hunyuan.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.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com", 1)); + assertEquals("cvm.tencentcloudapi.com.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com", 2)); + + assertEquals("hunyuan.ai.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("hunyuan.ai.tencentcloudapi.com", 0)); + assertEquals("hunyuan.ai.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("hunyuan.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("hunyuan.ai.tencentcloudapi.cn"); + assertNotNull(m); + assertEquals("hunyuan", 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]); + } + + // ================================================================= + // M2a: hostWithTld — boundary origins (.cn, .com.cn, region-pinned) + // ================================================================= + + @Test + public void testHostWithTldFromCnAndComCnOrigins() { + // From .cn origin (tldIdx=1): idx 0 -> .com, idx 1 -> .cn + assertEquals("cvm.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.cn", 0)); + assertEquals("cvm.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.cn", 1)); + + // From .com.cn origin (tldIdx=2): idx 0 -> .com, idx 1 -> .cn + assertEquals("cvm.tencentcloudapi.com", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com.cn", 0)); + assertEquals("cvm.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.tencentcloudapi.com.cn", 1)); + } + + @Test + public void testHostWithTldPreservesRegionInPrefix() { + assertEquals("cvm.ap-guangzhou.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("cvm.ap-guangzhou.tencentcloudapi.com", 1)); + assertEquals("hunyuan.ap-guangzhou.ai.tencentcloudapi.cn", + EndpointFailoverInterceptor.hostWithTld("hunyuan.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.ap-guangzhou.tencentcloudapi.cn", + "cvm.ap-guangzhou.tencentcloudapi.com.cn"), + new Family("AI-R", "ai.tencentcloudapi.com", + "hunyuan.ap-guangzhou.ai.tencentcloudapi.com", + "hunyuan.ap-guangzhou.ai.tencentcloudapi.cn", + "hunyuan.ap-guangzhou.ai.tencentcloudapi.com.cn"), + new Family("Internal-R", "internal.tencentcloudapi.com", + "cvm.ap-guangzhou.internal.tencentcloudapi.com", + "cvm.ap-guangzhou.internal.tencentcloudapi.cn", + "cvm.ap-guangzhou.internal.tencentcloudapi.com.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-M16: 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()); + } + } + + @Test + public void testInvalidJsonBodyRecordsFailure() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + transport.programJsonOk("not json at all"); + + 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 testEmptyBodyRecordsFailure() throws Exception { + for (Family f : FAMILIES) { + CvmClient client = newClient(f); + TransportStub transport = installStub(client); + // 200 with JSON content-type but empty body — not valid JSON. + transport.programJsonOk(""); + + 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 hunyuan.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 testRegionPinnedHostFailoverPreservesPrefix() throws Exception { + Family f = new Family("R", "tencentcloudapi.com", + "cvm.ap-guangzhou.tencentcloudapi.com", + "cvm.ap-guangzhou.tencentcloudapi.cn", + "cvm.ap-guangzhou.tencentcloudapi.com.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()); + // Region prefix must be preserved: cvm.ap-guangzhou.* not cvm.* + assertTrue(transport.received.get(0).url().host().startsWith("cvm.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()); + } + } + + // ================================================================= + // 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; charset=utf-8", 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; + } + } + } +}