diff --git a/src/main/java/com/tencentcloudapi/common/AbstractClient.java b/src/main/java/com/tencentcloudapi/common/AbstractClient.java
index f182117def..afa7d63f6e 100644
--- a/src/main/java/com/tencentcloudapi/common/AbstractClient.java
+++ b/src/main/java/com/tencentcloudapi/common/AbstractClient.java
@@ -134,6 +134,9 @@ public AbstractClient(
this.profile.getHttpProfile().getWriteTimeout()
);
this.httpConnection.addInterceptors(this.log);
+ if (this.profile.getHttpProfile().getDomainFailover()) {
+ this.httpConnection.addInterceptors(new EndpointFailoverInterceptor(this));
+ }
this.trySetProxy(this.httpConnection);
this.trySetSSLSocketFactory(this.httpConnection);
this.trySetRegionBreaker();
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..fa4b8c423b
--- /dev/null
+++ b/src/main/java/com/tencentcloudapi/common/EndpointFailoverInterceptor.java
@@ -0,0 +1,689 @@
+/*
+ * Copyright (c) 2018 Tencent. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package com.tencentcloudapi.common;
+
+import com.tencentcloudapi.common.exception.TencentCloudSDKException;
+import com.tencentcloudapi.common.profile.ClientProfile;
+import com.tencentcloudapi.common.profile.HttpProfile;
+import okhttp3.*;
+import okio.Buffer;
+
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.*;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * OkHttp interceptor that implements TLD-level domain failover for Tencent
+ * Cloud API calls. When DNS resolution of the primary
+ * {@code *.tencentcloudapi.com} domain fails, the interceptor automatically
+ * rewrites the request against the next known backup TLD ({@code .cn},
+ * {@code .com.cn}) and re-signs it using the owning
+ * {@link AbstractClient}'s live credential and profile, then retries.
+ *
+ * All signing inputs (method, URL, headers, body) are recovered directly from
+ * the outgoing {@link Request}; no per-request tag is required. Credentials
+ * and profile are read live on each retry, so credential rotation is honoured.
+ *
+ * Failover state is JVM-global and opportunistic: per-TLD circuit breakers
+ * suppress repeated attempts against a failing TLD; after the configured
+ * breaker timeout (default {@value #DEFAULT_BREAKER_TIMEOUT_MS} ms) the
+ * breaker half-opens and the next request probes that TLD again.
+ *
+ * Internal: instantiated by {@link AbstractClient}; users opt out via
+ * {@link com.tencentcloudapi.common.profile.HttpProfile#setDomainFailover(boolean)}.
+ */
+class EndpointFailoverInterceptor implements Interceptor {
+
+ /**
+ * All known TLDs. The failover order rotates based on the user's original endpoint.
+ */
+ static final String[] KNOWN_TLDS = new String[]{
+ "tencentcloudapi.com",
+ "tencentcloudapi.cn",
+ "tencentcloudapi.com.cn"
+ };
+
+ /**
+ * CircuitBreaker timeout (ms) controlling how long an Open breaker stays
+ * Open before transitioning to HalfOpen and probing the TLD again.
+ * Default: 60 s.
+ */
+ static final long DEFAULT_BREAKER_TIMEOUT_MS = 60 * 1000;
+
+ private final AbstractClient client;
+ private final long breakerTimeoutMs;
+
+ /**
+ * Per-host failover state, keyed by the ORIGINAL host. Shared across all
+ * AbstractClient instances in the JVM.
+ */
+ static final ConcurrentHashMap STATE =
+ new ConcurrentHashMap();
+
+ /**
+ * Creates an interceptor bound to {@code client} with the default breaker timeout (60 s).
+ */
+ EndpointFailoverInterceptor(AbstractClient client) {
+ this.client = client;
+ this.breakerTimeoutMs = DEFAULT_BREAKER_TIMEOUT_MS;
+ }
+
+ /**
+ * Visible for testing. Resets all failover state.
+ */
+ static void resetStateForTesting() {
+ STATE.clear();
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Request request = chain.request();
+
+ if (!eligibleForFailover(request)) {
+ return chain.proceed(request);
+ }
+
+ Failover failover = new Failover(request);
+
+ for (String candidate : failover.candidates()) {
+ Response response = failover.attempt(candidate, chain);
+ if (response != null) {
+ return response;
+ }
+ }
+
+ throw failover.exhausted();
+ }
+
+ /**
+ * Stateful helper that drives a single intercept. Keeps {@link #intercept}
+ * readable as a narrative while hiding the per-TLD circuit breaker, re-sign
+ * and DNS-failure bookkeeping.
+ */
+ private final class Failover {
+ private final Request request;
+ private final String originHost;
+ private final int originIdx;
+ private final FailoverState state;
+ /**
+ * One entry per attempted (or breaker-skipped) candidate, in attempt
+ * order. Used by {@link #exhausted()} to surface every TLD's failure
+ * via Throwable.addSuppressed, so the caller's log shows all 3 root
+ * causes instead of just the last one.
+ */
+ private final List attemptFailures = new ArrayList();
+
+ private Failover(Request request) {
+ this.request = request;
+ this.originHost = request.url().host();
+ this.originIdx = tldIndexOf(originHost);
+ this.state = getOrCreateState(originHost);
+ }
+
+ /**
+ * Host candidates in preferred try order, with the original TLD reprobed once its cooldown expires.
+ */
+ List candidates() {
+ List hosts = new ArrayList(KNOWN_TLDS.length);
+ for (int tldIdx : buildTryOrder(state, originIdx)) {
+ hosts.add(hostForTld(tldIdx));
+ }
+ return hosts;
+ }
+
+ /**
+ * Attempts a single request against {@code host}. Returns the response
+ * on success, or {@code null} if the caller should keep trying (breaker
+ * open, or DNS / network-reachability failure this attempt). Failures
+ * unrelated to host reachability propagate.
+ */
+ Response attempt(String host, Chain chain) throws IOException {
+ int tldIdx = tldIndexOf(host);
+ CircuitBreaker.Token token = state.breakers[tldIdx].allow();
+ if (!token.allowed) {
+ attemptFailures.add(new IOException(
+ "skipped " + host + ": circuit breaker open"));
+ return null;
+ }
+ Request attempt;
+ try {
+ attempt = rewriteFor(host);
+ } catch (TencentCloudSDKException e) {
+ throw new IOException("Failed to re-sign request for failover: " + e.getMessage(), e);
+ }
+ try {
+ Response response = chain.proceed(attempt);
+ token.report(true);
+ state.currentIndex = tldIdx;
+ if (tldIdx == originIdx) {
+ state.clearOriginProbe();
+ }
+ return response;
+ } catch (IOException e) {
+ if (!shouldFailover(e)) {
+ throw e;
+ }
+ token.report(false);
+ if (tldIdx == originIdx) {
+ state.scheduleOriginProbe(breakerTimeoutMs);
+ }
+ attemptFailures.add(new IOException(
+ "attempt against " + host + " failed: " + e.getClass().getSimpleName()
+ + ": " + e.getMessage(), e));
+ return null;
+ }
+ }
+
+ /**
+ * Every candidate either failed or was short-circuited. Returns the
+ * last attempt's exception as the primary cause and attaches every
+ * other attempt's exception as a suppressed throwable, so all three
+ * TLDs' root causes are visible in a single stack trace dump.
+ */
+ IOException exhausted() {
+ if (attemptFailures.isEmpty()) {
+ // Defensive: candidates() never returns empty for a known TLD,
+ // but guard against future refactors leaving this branch live.
+ return new IOException("Endpoint failover produced no attempts for " + originHost);
+ }
+ int last = attemptFailures.size() - 1;
+ IOException primary = attemptFailures.get(last);
+ for (int i = 0; i < last; i++) {
+ primary.addSuppressed(attemptFailures.get(i));
+ }
+ return primary;
+ }
+
+ private Request rewriteFor(String host) throws TencentCloudSDKException, IOException {
+ return rewriteForTld(request, originHost, originIdx, tldIndexOf(host));
+ }
+
+ private String hostForTld(int tldIdx) {
+ return tldIdx == originIdx
+ ? originHost
+ : substituteTld(originHost, KNOWN_TLDS[originIdx], KNOWN_TLDS[tldIdx]);
+ }
+ }
+
+ /**
+ * A request is eligible for TLD failover when the target host is a
+ * recognised Tencent Cloud API domain.
+ */
+ private boolean eligibleForFailover(Request request) {
+ return tldIndexOf(request.url().host()) >= 0;
+ }
+
+ /**
+ * Order: last-known-working TLD first, unless the original TLD is due for a
+ * recovery probe. Once that cooldown expires, the original TLD moves to the
+ * front so traffic can automatically return to the user's configured domain.
+ */
+ private int[] buildTryOrder(FailoverState state, int originIdx) {
+ int preferredIdx = state.currentIndex >= 0 ? state.currentIndex : originIdx;
+ int n = KNOWN_TLDS.length;
+ int[] order = new int[n];
+ boolean[] added = new boolean[n];
+ int pos = 0;
+
+ if (originIdx != preferredIdx && state.shouldProbeOrigin()) {
+ order[pos++] = originIdx;
+ added[originIdx] = true;
+ }
+ if (!added[preferredIdx]) {
+ order[pos++] = preferredIdx;
+ added[preferredIdx] = true;
+ }
+ for (int i = 0; i < n; i++) {
+ if (!added[i]) {
+ order[pos++] = i;
+ }
+ }
+ return order;
+ }
+
+ private FailoverState getOrCreateState(String originalHost) {
+ FailoverState s = STATE.get(originalHost);
+ if (s != null) {
+ return s;
+ }
+ FailoverState created = new FailoverState(breakerTimeoutMs);
+ FailoverState prev = STATE.putIfAbsent(originalHost, created);
+ return prev != null ? prev : created;
+ }
+
+ /**
+ * Build a request targeting {@code KNOWN_TLDS[tldIndex]}. If the target TLD
+ * equals the origin, the original (already-signed) request is returned as-is.
+ * Otherwise the request is re-signed for the new host by recovering the signing
+ * inputs from the original Request and reading credentials live from {@link #client}.
+ */
+ private Request rewriteForTld(Request original, String originalHost, int originIdx, int tldIndex)
+ throws TencentCloudSDKException, IOException {
+ if (tldIndex == originIdx) {
+ return original;
+ }
+ String targetHost = substituteTld(originalHost, KNOWN_TLDS[originIdx], KNOWN_TLDS[tldIndex]);
+ if (targetHost.equals(originalHost)) {
+ return original;
+ }
+
+ String signMethod = client.getClientProfile().getSignMethod();
+ if (isSkipSignV3Request(original, signMethod)) {
+ return rewriteSkipSignV3(original, targetHost);
+ }
+ if (ClientProfile.SIGN_TC3_256.equals(signMethod)) {
+ return resignV3(original, targetHost);
+ }
+ if (ClientProfile.SIGN_SHA1.equals(signMethod) || ClientProfile.SIGN_SHA256.equals(signMethod)) {
+ return resignV1(original, targetHost);
+ }
+ throw new TencentCloudSDKException(
+ "Signature method " + signMethod + " is invalid or not supported yet.");
+ }
+
+ private static boolean isSkipSignV3Request(Request original, String signMethod) {
+ return ClientProfile.SIGN_TC3_256.equals(signMethod)
+ && "SKIP".equals(original.header("Authorization"));
+ }
+
+ private Request rewriteSkipSignV3(Request original, String targetHost) throws IOException {
+ String httpMethod = original.method();
+ String contentType = original.header("Content-Type");
+ byte[] payload = readRequestBody(original);
+
+ Headers.Builder hb = new Headers.Builder();
+ Headers origHeaders = original.headers();
+ for (int i = 0; i < origHeaders.size(); i++) {
+ String name = origHeaders.name(i);
+ if (name.equalsIgnoreCase("Host")) {
+ continue;
+ }
+ hb.add(name, origHeaders.value(i));
+ }
+ hb.add("Host", targetHost);
+
+ HttpUrl newUrl = original.url().newBuilder().host(targetHost).build();
+ Request.Builder rb = original.newBuilder()
+ .url(newUrl)
+ .headers(hb.build());
+ if (HttpProfile.REQ_POST.equalsIgnoreCase(httpMethod)) {
+ rb.post(RequestBody.create(MediaType.parse(contentType), payload));
+ } else if (HttpProfile.REQ_GET.equalsIgnoreCase(httpMethod)) {
+ rb.get();
+ }
+ return rb.build();
+ }
+
+ private Request resignV3(Request original, String targetHost)
+ throws TencentCloudSDKException, IOException {
+ Credential credential = client.getCredential();
+ ClientProfile profile = client.getClientProfile();
+
+ String httpMethod = original.method();
+ String contentType = original.header("Content-Type");
+ if (contentType == null) {
+ contentType = "application/x-www-form-urlencoded";
+ }
+
+ byte[] payload = readRequestBody(original);
+
+ 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;
+ if (profile.isUnsignedPayload()) {
+ hashedRequestPayload = Sign.sha256Hex("UNSIGNED-PAYLOAD".getBytes(StandardCharsets.UTF_8));
+ } else {
+ hashedRequestPayload = 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 hashedCanonicalRequest = Sign.sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8));
+ String stringToSign = "TC3-HMAC-SHA256\n" + timestamp + "\n"
+ + credentialScope + "\n" + hashedCanonicalRequest;
+
+ String secretId = credential.getSecretId();
+ String secretKey = credential.getSecretKey();
+ byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date);
+ byte[] secretService = Sign.hmac256(secretDate, service);
+ byte[] secretSigning = Sign.hmac256(secretService, "tc3_request");
+ String signature = DatatypeConverter
+ .printHexBinary(Sign.hmac256(secretSigning, stringToSign))
+ .toLowerCase();
+ String authorization = "TC3-HMAC-SHA256 "
+ + "Credential=" + secretId + "/" + credentialScope + ", "
+ + "SignedHeaders=" + signedHeaders + ", "
+ + "Signature=" + signature;
+
+ // Preserve all original headers EXCEPT the ones we regenerate, and substitute them.
+ Headers.Builder hb = new Headers.Builder();
+ Headers origHeaders = original.headers();
+ for (int i = 0; i < origHeaders.size(); i++) {
+ String name = origHeaders.name(i);
+ if (name.equalsIgnoreCase("Host")
+ || name.equalsIgnoreCase("Authorization")
+ || name.equalsIgnoreCase("X-TC-Timestamp")) {
+ continue;
+ }
+ hb.add(name, origHeaders.value(i));
+ }
+ hb.add("Host", targetHost);
+ hb.add("Authorization", authorization);
+ hb.add("X-TC-Timestamp", timestamp);
+ // Token may have rotated on the credential — re-inject if present.
+ String token = credential.getToken();
+ if (token != null && !token.isEmpty()) {
+ hb.set("X-TC-Token", token);
+ } else {
+ hb.removeAll("X-TC-Token");
+ }
+
+ HttpUrl newUrl = original.url().newBuilder().host(targetHost).build();
+
+ Request.Builder rb = original.newBuilder()
+ .url(newUrl)
+ .headers(hb.build());
+ // Body is re-attached below so the rebuilt Request.body() is non-null even though
+ // original.newBuilder() already preserves it; keeping this explicit defends against
+ // future OkHttp behavior changes.
+ if (HttpProfile.REQ_POST.equalsIgnoreCase(httpMethod)) {
+ rb.post(RequestBody.create(MediaType.parse(contentType), payload));
+ } else if (HttpProfile.REQ_GET.equalsIgnoreCase(httpMethod)) {
+ rb.get();
+ }
+ return rb.build();
+ }
+
+ /**
+ * Recovers the canonical query string from a URL for TC3 signing. TC3 requires
+ * sorted, URL-encoded {@code key=value} pairs; this matches what
+ * {@code AbstractClient.getCanonicalQueryString} produces (which now sorts via TreeMap).
+ */
+ private static String canonicalQueryStringFromUrl(HttpUrl url, String method)
+ throws TencentCloudSDKException {
+ if (HttpProfile.REQ_POST.equalsIgnoreCase(method)) {
+ return "";
+ }
+ TreeMap sorted = new TreeMap();
+ int size = url.querySize();
+ for (int i = 0; i < size; i++) {
+ String name = url.queryParameterName(i);
+ String value = url.queryParameterValue(i);
+ if (value == null) {
+ value = "";
+ }
+ sorted.put(name, value);
+ }
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry e : sorted.entrySet()) {
+ try {
+ String v = URLEncoder.encode(e.getValue(), "UTF8");
+ if (sb.length() > 0) {
+ sb.append("&");
+ }
+ sb.append(e.getKey()).append("=").append(v);
+ } catch (UnsupportedEncodingException ex) {
+ throw new TencentCloudSDKException("UTF8 is not supported.", ex);
+ }
+ }
+ return sb.toString();
+ }
+
+ 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();
+ }
+
+ private Request resignV1(Request original, String targetHost)
+ throws TencentCloudSDKException, IOException {
+ Credential credential = client.getCredential();
+ ClientProfile profile = client.getClientProfile();
+ String reqMethod = original.method();
+
+ Map params;
+ if (HttpProfile.REQ_GET.equalsIgnoreCase(reqMethod)) {
+ params = decodeQueryParams(original.url());
+ } else if (HttpProfile.REQ_POST.equalsIgnoreCase(reqMethod)) {
+ byte[] bytes = readRequestBody(original);
+ params = decodeFormParams(new String(bytes, StandardCharsets.UTF_8));
+ } else {
+ throw new TencentCloudSDKException("Method only support (GET, POST) for Hmac sign");
+ }
+ params.remove("Signature");
+
+ // Refresh credential-derived params in case they rotated.
+ 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),
+ reqMethod,
+ 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(reqMethod)) {
+ HttpUrl urlWithQuery = newUrl.newBuilder().encodedQuery(body.toString()).build();
+ rb.url(urlWithQuery).get();
+ } else {
+ rb.url(newUrl).post(RequestBody.create(
+ MediaType.parse("application/x-www-form-urlencoded"),
+ body.toString()));
+ }
+ // Preserve Host header if present, pointing at the new host.
+ if (original.header("Host") != null) {
+ rb.header("Host", targetHost);
+ }
+ return rb.build();
+ }
+
+ private static Map decodeQueryParams(HttpUrl url) {
+ LinkedHashMap map = new LinkedHashMap();
+ int size = url.querySize();
+ for (int i = 0; i < size; i++) {
+ String name = url.queryParameterName(i);
+ String value = url.queryParameterValue(i);
+ if (value == null) {
+ value = "";
+ }
+ map.put(name, 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;
+ }
+
+ /**
+ * Failures we treat as "host unreachable / suspicious" and worth retrying
+ * against another TLD: outright DNS misses, TLS verification failures
+ * (a strong DNS-tampering signal), and connect/timeout/route errors that
+ * can mean the resolved IP is a black hole. SocketTimeoutException covers
+ * both connect and read timeouts; CircuitBreaker absorbs false positives
+ * for genuinely slow servers.
+ */
+ 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;
+ }
+
+ static int tldIndexOf(String host) {
+ if (host == null) {
+ return -1;
+ }
+ for (int i = 0; i < KNOWN_TLDS.length; i++) {
+ String suffix = "." + KNOWN_TLDS[i];
+ if (host.endsWith(suffix)) {
+ String prefix = host.substring(0, host.length() - suffix.length());
+ if (!prefix.isEmpty() && !prefix.startsWith(".") && !prefix.endsWith(".")) {
+ return i;
+ }
+ }
+ }
+ return -1;
+ }
+
+ static boolean isKnownTencentCloudHost(String host) {
+ return tldIndexOf(host) >= 0;
+ }
+
+ static String substituteTld(String host, String fromTld, String toTld) {
+ if (host == null) {
+ return null;
+ }
+ String suffix = "." + fromTld;
+ if (host.endsWith(suffix)) {
+ String prefix = host.substring(0, host.length() - suffix.length());
+ if (prefix.isEmpty()) {
+ return toTld;
+ }
+ return stripRegionalLabel(prefix) + "." + toTld;
+ }
+ if (host.equals(fromTld)) {
+ return toTld;
+ }
+ return host;
+ }
+
+ private static String stripRegionalLabel(String prefix) {
+ int lastDot = prefix.lastIndexOf('.');
+ if (lastDot < 0) {
+ return prefix;
+ }
+ String lastLabel = prefix.substring(lastDot + 1);
+ if (!looksLikeRegionLabel(lastLabel)) {
+ return prefix;
+ }
+ return prefix.substring(0, lastDot);
+ }
+
+ private static boolean looksLikeRegionLabel(String label) {
+ return label != null && (label.startsWith("ap-")
+ || label.startsWith("na-")
+ || label.startsWith("eu-")
+ || label.startsWith("sa-")
+ || label.startsWith("af-")
+ || label.startsWith("me-"));
+ }
+
+ /**
+ * Per-originalHost failover state. Holds one {@link CircuitBreaker} per TLD
+ * plus the last-known-working TLD index.
+ */
+ static final class FailoverState {
+ final CircuitBreaker[] breakers;
+ /**
+ * Index in {@link #KNOWN_TLDS} of the last TLD that served a successful request; -1 until first success.
+ */
+ volatile int currentIndex = -1;
+ /**
+ * Timestamp after which the original TLD should be reprobed; -1 when no reprobe is pending.
+ */
+ volatile long originProbeAfterMs = -1;
+
+ FailoverState(long breakerTimeoutMs) {
+ int n = KNOWN_TLDS.length;
+ this.breakers = new CircuitBreaker[n];
+ for (int i = 0; i < n; i++) {
+ CircuitBreaker.Setting s = new CircuitBreaker.Setting();
+ s.timeoutMs = breakerTimeoutMs;
+ this.breakers[i] = new CircuitBreaker(s);
+ }
+ }
+
+ void scheduleOriginProbe(long delayMs) {
+ this.originProbeAfterMs = System.currentTimeMillis() + delayMs;
+ }
+
+ void clearOriginProbe() {
+ this.originProbeAfterMs = -1;
+ }
+
+ boolean shouldProbeOrigin() {
+ return this.originProbeAfterMs >= 0 && System.currentTimeMillis() >= this.originProbeAfterMs;
+ }
+ }
+}
diff --git a/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java b/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java
index f480adc3c6..88a21cb737 100644
--- a/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java
+++ b/src/main/java/com/tencentcloudapi/common/profile/HttpProfile.java
@@ -107,6 +107,15 @@ public class HttpProfile {
*/
private Object httpClient;
+ /**
+ * Whether to enable TLD-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 and SkipSign requests are passed
+ * through unchanged.
+ */
+ private boolean domainFailover = true;
+
/**
* Default constructor for HttpProfile.
* Initializes default values for the HTTP profile configuration.
@@ -413,4 +422,20 @@ public Object getHttpClient() {
public void setHttpClient(Object client) {
httpClient = client;
}
+
+ /**
+ * @return true if TLD-level domain failover is enabled (default), false otherwise.
+ */
+ public boolean getDomainFailover() {
+ return this.domainFailover;
+ }
+
+ /**
+ * Enable or disable TLD-level domain failover. See {@link #domainFailover}.
+ *
+ * @param enabled true to enable (default), false to disable.
+ */
+ public void setDomainFailover(boolean enabled) {
+ this.domainFailover = enabled;
+ }
}
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..8919ceb419
--- /dev/null
+++ b/src/test/java/com/tencentcloudapi/common/EndpointFailoverInterceptorTest.java
@@ -0,0 +1,1007 @@
+/*
+ * 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.Response;
+import okhttp3.ResponseBody;
+import org.junit.Before;
+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.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+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}.
+ *
+ *
All behavior tests drive a real {@link CvmClient} with the standard
+ * profile/credential flow exactly the way users construct one — so the
+ * full pipeline (sign → log interceptor → failover interceptor → HTTP)
+ * runs end-to-end. Network is short-circuited by injecting a
+ * {@link TransportStub} interceptor at the tail of the OkHttpClient inside
+ * {@link HttpConnection}; the stub plays back scripted DNS misses, TLS
+ * failures, timeouts, and JSON success bodies per attempt.
+ *
+ *
The pure-helper tests at the top exercise package-private static
+ * methods directly — no client / no pipeline needed.
+ */
+public class EndpointFailoverInterceptorTest {
+
+ @Before
+ public void resetState() {
+ EndpointFailoverInterceptor.resetStateForTesting();
+ }
+
+ // =================================================================
+ // Pure helper tests
+ // =================================================================
+
+ @Test
+ public void testIsKnownTencentCloudHost() {
+ assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.com"));
+ assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.ap-shanghai.tencentcloudapi.com"));
+ assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("hunyuan.ai.ap-guangzhou.tencentcloudapi.com"));
+ assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("hunyuan.ai.tencentcloudapi.com"));
+ assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.cn"));
+ assertTrue(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.com.cn"));
+ assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("example.com"));
+ assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("cvm.tencentcloudapi.woa.com"));
+ assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost("proxy.internal"));
+ assertFalse(EndpointFailoverInterceptor.isKnownTencentCloudHost(null));
+ }
+
+ @Test
+ public void testSubstituteTldDropsRegionalLabel() {
+ assertEquals(
+ "cvm.tencentcloudapi.com.cn",
+ EndpointFailoverInterceptor.substituteTld(
+ "cvm.tencentcloudapi.com", "tencentcloudapi.com", "tencentcloudapi.com.cn"));
+ assertEquals(
+ "cvm.tencentcloudapi.cn",
+ EndpointFailoverInterceptor.substituteTld(
+ "cvm.ap-shanghai.tencentcloudapi.com", "tencentcloudapi.com", "tencentcloudapi.cn"));
+ assertEquals(
+ "hunyuan.ai.tencentcloudapi.com.cn",
+ EndpointFailoverInterceptor.substituteTld(
+ "hunyuan.ai.ap-guangzhou.tencentcloudapi.com",
+ "tencentcloudapi.com",
+ "tencentcloudapi.com.cn"));
+ }
+
+ @Test
+ public void testSubstituteTldNoMatchReturnsInput() {
+ assertEquals(
+ "example.com",
+ EndpointFailoverInterceptor.substituteTld(
+ "example.com", "tencentcloudapi.com", "tencentcloudapi.cn"));
+ }
+
+ // =================================================================
+ // Behavior tests via real CvmClient + injected transport stub
+ // =================================================================
+
+ // ---- Pass-through paths ----
+
+ @Test
+ public void testPassThroughForUnknownHost() throws Exception {
+ // Override endpoint to a non-Tencent host — interceptor must be inert.
+ ClientProfile profile = new ClientProfile();
+ profile.getHttpProfile().setEndpoint("example.com");
+ CvmClient client = newCvm(profile);
+ TransportStub transport = installStub(client);
+ transport.programOk();
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(1, transport.received.size());
+ assertEquals("example.com", transport.received.get(0).url().host());
+ }
+
+ @Test
+ public void testFailoverFromComEndpoint() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ DescribeInstancesResponse resp = client.DescribeInstances(new DescribeInstancesRequest());
+ assertNotNull(resp);
+ assertEquals(2, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(1).url().host());
+ // Resigned request must carry Host header tracking new URL host.
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(1).header("Host"));
+ // Authorization recomputed for new host.
+ assertNotEquals(
+ transport.received.get(0).header("Authorization"),
+ transport.received.get(1).header("Authorization"));
+ }
+
+ @Test
+ public void testFailoverFromCnEndpoint() throws Exception {
+ ClientProfile profile = new ClientProfile();
+ profile.getHttpProfile().setEndpoint("cvm.tencentcloudapi.cn");
+ CvmClient client = newCvm(profile);
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(0).url().host());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(1).url().host());
+ }
+
+ @Test
+ public void testFailoverDropsRegionalLabelFromHost() throws Exception {
+ ClientProfile profile = new ClientProfile();
+ profile.getHttpProfile().setEndpoint("cvm.ap-guangzhou.tencentcloudapi.com");
+ CvmClient client = newCvm(profile);
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+ assertEquals("cvm.ap-guangzhou.tencentcloudapi.com",
+ transport.received.get(0).url().host());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(1).url().host());
+ }
+
+ @Test
+ public void testKnownDomainFailsOverEvenWhenFailoverDisabledAtRuntime() throws Exception {
+ // setDomainFailover(false) AFTER ctor — the interceptor was already
+ // installed at ctor time, so flipping the flag later cannot remove it.
+ // Documents the actual (slightly surprising) behavior.
+ CvmClient client = newCvm();
+ client.getClientProfile().getHttpProfile().setDomainFailover(false);
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+ }
+
+ // ---- shouldFailover branch coverage ----
+
+ @Test
+ public void testFailoverOnSslHandshakeException() throws Exception {
+ runSingleFailureScenario(new SSLHandshakeException("tls handshake failed"));
+ }
+
+ @Test
+ public void testFailoverOnSslPeerUnverifiedException() throws Exception {
+ runSingleFailureScenario(new SSLPeerUnverifiedException("cert mismatch"));
+ }
+
+ @Test
+ public void testFailoverOnConnectException() throws Exception {
+ runSingleFailureScenario(new ConnectException("connection refused"));
+ }
+
+ @Test
+ public void testFailoverOnNoRouteToHostException() throws Exception {
+ runSingleFailureScenario(new NoRouteToHostException("no route"));
+ }
+
+ @Test
+ public void testFailoverOnSocketTimeoutException() throws Exception {
+ runSingleFailureScenario(new SocketTimeoutException("read timed out"));
+ }
+
+ private void runSingleFailureScenario(IOException firstFailure) throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(firstFailure);
+ transport.programOk();
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(1).url().host());
+ }
+
+ // ---- Non-failover IOException must propagate without retry ----
+
+ @Test
+ public void testGenericIOExceptionPropagatesWithoutFailover() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new IOException("some unrelated I/O error"));
+
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception");
+ } catch (TencentCloudSDKException e) {
+ // SDK wraps the IOException as cause.
+ Throwable cause = unwrapToIOException(e);
+ assertEquals("some unrelated I/O error", cause.getMessage());
+ }
+ assertEquals("must not retry on non-failover IOException", 1, transport.received.size());
+ }
+
+ // ---- HTTP body / status reaches caller intact after failover ----
+
+ @Test
+ public void testApiResponseDeliveredAfterFailover() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programJsonOk("{\"Response\":{\"TotalCount\":42,\"InstanceSet\":[],\"RequestId\":\"req-xyz\"}}");
+
+ DescribeInstancesResponse resp = client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(Long.valueOf(42), resp.getTotalCount());
+ assertEquals("req-xyz", resp.getRequestId());
+ }
+
+ // ---- 5xx server response is not a failover trigger ----
+
+ @Test
+ public void test5xxResponseDoesNotTriggerFailover() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programResponse(503, "service unavailable");
+
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception for 503");
+ } catch (TencentCloudSDKException e) {
+ assertTrue("message should mention non-200 code, got: " + e.getMessage(),
+ e.getMessage().contains("503"));
+ }
+ assertEquals(1, transport.received.size());
+ }
+
+ // ---- TC3 resign preserves body / content-type / signing scope ----
+
+ @Test
+ public void testTC3ResignPreservesBodyAndContentType() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ DescribeInstancesRequest req = new DescribeInstancesRequest();
+ req.setLimit(10L);
+ req.setOffset(0L);
+ req.setInstanceIds(new String[]{"ins-aaa", "ins-bbb"});
+ client.DescribeInstances(req);
+
+ Request first = transport.received.get(0);
+ Request resigned = transport.received.get(1);
+
+ // Same body bytes round-trip through resign.
+ assertArrayEquals(bodyBytes(first), bodyBytes(resigned));
+ assertEquals(first.header("Content-Type"), resigned.header("Content-Type"));
+
+ // Authorization rebound for new host scope.
+ assertNotEquals(first.header("Authorization"), resigned.header("Authorization"));
+ assertTrue(resigned.header("Authorization").startsWith("TC3-HMAC-SHA256 "));
+ assertTrue(resigned.header("Authorization").contains("/cvm/tc3_request"));
+ }
+
+ // ---- X-TC-Token rotation visible to resigned request ----
+
+ @Test
+ public void testResignReflectsRotatedToken() throws Exception {
+ CvmClient client = newCvm();
+ client.setCredential(new Credential("AKIDTEST", "SKTEST", "tok-v1"));
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ // Mid-flight credential rotation: hook a one-shot interceptor that
+ // swaps the token after the first request goes out, before the
+ // failover interceptor resigns for the backup TLD.
+ AtomicTokenSwapper swapper = new AtomicTokenSwapper(client, "tok-v2");
+ installInterceptorBefore(client, swapper);
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals("tok-v1", transport.received.get(0).header("X-TC-Token"));
+ assertEquals("tok-v2", transport.received.get(1).header("X-TC-Token"));
+ }
+
+ @Test
+ public void testResignDropsTokenWhenCleared() throws Exception {
+ CvmClient client = newCvm();
+ client.setCredential(new Credential("AKIDTEST", "SKTEST", "tok-v1"));
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ // Rotate creds to one without a token between attempts.
+ AtomicTokenSwapper clearer = new AtomicTokenSwapper(client, null);
+ installInterceptorBefore(client, clearer);
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals("tok-v1", transport.received.get(0).header("X-TC-Token"));
+ assertNull("token must be removed on resign when credential drops it",
+ transport.received.get(1).header("X-TC-Token"));
+ }
+
+ // ---- Hmac (V1) resign preserves all params; signature rebuilt for new host ----
+
+ @Test
+ public void testHmacResignPreservesQueryParams() throws Exception {
+ ClientProfile profile = new ClientProfile();
+ profile.setSignMethod(ClientProfile.SIGN_SHA256);
+ profile.getHttpProfile().setReqMethod(HttpProfile.REQ_GET);
+ CvmClient client = newCvm(profile);
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+
+ Request resigned = transport.received.get(1);
+ assertEquals("cvm.tencentcloudapi.cn", resigned.url().host());
+ assertEquals("DescribeInstances", resigned.url().queryParameter("Action"));
+ assertEquals("2017-03-12", resigned.url().queryParameter("Version"));
+ assertEquals("ap-guangzhou", resigned.url().queryParameter("Region"));
+ assertEquals("AKIDTEST", resigned.url().queryParameter("SecretId"));
+ assertEquals("HmacSHA256", resigned.url().queryParameter("SignatureMethod"));
+ // Signature replaced, not appended.
+ List sigs = resigned.url().queryParameterValues("Signature");
+ assertEquals("must have exactly one Signature param", 1, sigs.size());
+ assertNotEquals(transport.received.get(0).url().queryParameter("Signature"),
+ resigned.url().queryParameter("Signature"));
+ }
+
+ // ---- Aggregation: every TLD failure surfaces in a single exception ----
+
+ @Test
+ public void testAllBackupTldsFailAggregatesEveryAttemptFailure() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("first dns miss"));
+ transport.programFailure(new UnknownHostException("second dns miss"));
+ transport.programFailure(new UnknownHostException("third dns miss"));
+
+ TencentCloudSDKException sdkEx = null;
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception");
+ } catch (TencentCloudSDKException e) {
+ sdkEx = e;
+ }
+
+ IOException primary = unwrapToIOException(sdkEx);
+ assertTrue(primary.getMessage().contains("cvm.tencentcloudapi.com.cn"));
+ assertTrue(primary.getMessage().contains("third dns miss"));
+ assertTrue(primary.getCause() instanceof UnknownHostException);
+ assertEquals("third dns miss", primary.getCause().getMessage());
+
+ Throwable[] suppressed = primary.getSuppressed();
+ assertEquals(2, suppressed.length);
+ assertTrue(suppressed[0].getMessage().contains("cvm.tencentcloudapi.com"));
+ assertTrue(suppressed[0].getCause() instanceof UnknownHostException);
+ assertEquals("first dns miss", suppressed[0].getCause().getMessage());
+ assertTrue(suppressed[1].getMessage().contains("cvm.tencentcloudapi.cn"));
+ assertTrue(suppressed[1].getCause() instanceof UnknownHostException);
+ assertEquals("second dns miss", suppressed[1].getCause().getMessage());
+
+ assertEquals(3, transport.received.size());
+ }
+
+ @Test
+ public void testAggregatedFailurePreservesPerAttemptCauseTypes() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss .com"));
+ transport.programFailure(new SSLHandshakeException("tls fail .cn"));
+ transport.programFailure(new ConnectException("connect fail .com.cn"));
+
+ TencentCloudSDKException sdkEx = null;
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception");
+ } catch (TencentCloudSDKException e) {
+ sdkEx = e;
+ }
+
+ IOException primary = unwrapToIOException(sdkEx);
+ assertTrue(primary.getCause() instanceof ConnectException);
+ Throwable[] suppressed = primary.getSuppressed();
+ assertEquals(2, suppressed.length);
+ assertTrue(suppressed[0].getCause() instanceof UnknownHostException);
+ assertTrue(suppressed[1].getCause() instanceof SSLHandshakeException);
+ }
+
+ @Test
+ public void testAggregatedFailureMixesBreakerSkipsWithRealFailures() throws Exception {
+ // Pre-open .com breaker, then drive a request where .cn and .com.cn
+ // both fail at transport. .com is short-circuited (placeholder, no
+ // cause); the other two contribute real cause chains.
+ CvmClient client = newCvm();
+ EndpointFailoverInterceptor.FailoverState state =
+ new EndpointFailoverInterceptor.FailoverState(60_000);
+ EndpointFailoverInterceptor.STATE.put("cvm.tencentcloudapi.com", state);
+ tripBreaker(state.breakers[0]); // .com Open
+
+ TransportStub transport = installStub(client);
+ // candidates() with currentIndex=-1 (.com origin still preferred but
+ // breaker open) → order: .com (skipped), .cn, .com.cn.
+ transport.programFailure(new SSLHandshakeException("cn tls fail"));
+ transport.programFailure(new ConnectException("com.cn connect fail"));
+
+ TencentCloudSDKException sdkEx = null;
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception");
+ } catch (TencentCloudSDKException e) {
+ sdkEx = e;
+ }
+
+ // .com never reached transport.
+ assertEquals(2, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(0).url().host());
+ assertEquals("cvm.tencentcloudapi.com.cn", transport.received.get(1).url().host());
+
+ IOException primary = unwrapToIOException(sdkEx);
+ // Last attempt = ConnectException on .com.cn.
+ assertTrue(primary.getCause() instanceof ConnectException);
+
+ // Suppressed[0] = .com breaker placeholder; Suppressed[1] = .cn TLS failure.
+ Throwable[] suppressed = primary.getSuppressed();
+ assertEquals(2, suppressed.length);
+ assertTrue(suppressed[0].getMessage().contains("cvm.tencentcloudapi.com"));
+ assertTrue(suppressed[0].getMessage().contains("circuit breaker open"));
+ assertNull("breaker-skip placeholder has no cause", suppressed[0].getCause());
+ assertTrue(suppressed[1].getCause() instanceof SSLHandshakeException);
+ }
+
+ @Test
+ public void testAggregatedFailureWhenPrimaryIsBreakerSkip() throws Exception {
+ // Pre-open .cn and .com.cn — only .com reaches transport. Suppressed
+ // and primary both contain the breaker-skip placeholders.
+ CvmClient client = newCvm();
+ EndpointFailoverInterceptor.FailoverState state =
+ new EndpointFailoverInterceptor.FailoverState(60_000);
+ EndpointFailoverInterceptor.STATE.put("cvm.tencentcloudapi.com", state);
+ tripBreaker(state.breakers[1]);
+ tripBreaker(state.breakers[2]);
+
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("com dns fail"));
+
+ TencentCloudSDKException sdkEx = null;
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception");
+ } catch (TencentCloudSDKException e) {
+ sdkEx = e;
+ }
+
+ assertEquals(1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+
+ IOException primary = unwrapToIOException(sdkEx);
+ // Last attempt = .com.cn breaker skip → no cause.
+ assertNull(primary.getCause());
+ assertTrue(primary.getMessage().contains("cvm.tencentcloudapi.com.cn"));
+ assertTrue(primary.getMessage().contains("circuit breaker open"));
+
+ Throwable[] suppressed = primary.getSuppressed();
+ assertEquals(2, suppressed.length);
+ assertTrue(suppressed[0].getCause() instanceof UnknownHostException);
+ assertEquals("com dns fail", suppressed[0].getCause().getMessage());
+ assertNull(suppressed[1].getCause());
+ assertTrue(suppressed[1].getMessage().contains("cvm.tencentcloudapi.cn"));
+ }
+
+ @Test
+ public void testFailoverDoesNotPolluteNextRequestAttemptFailures() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+
+ // Run 1: 1 fail + 1 success.
+ transport.programFailure(new UnknownHostException("run1 fail"));
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ transport.received.clear();
+
+ // Run 2: all fail. Suppressed must contain ONLY run-2 failures.
+ transport.programFailure(new UnknownHostException("run2 com fail"));
+ transport.programFailure(new UnknownHostException("run2 cn fail"));
+ transport.programFailure(new UnknownHostException("run2 com.cn fail"));
+ TencentCloudSDKException sdkEx = null;
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception");
+ } catch (TencentCloudSDKException e) {
+ sdkEx = e;
+ }
+ IOException primary = unwrapToIOException(sdkEx);
+ assertEquals(2, primary.getSuppressed().length);
+ for (Throwable s : primary.getSuppressed()) {
+ assertFalse("must not leak run-1 failure into run-2 aggregation: " + s.getMessage(),
+ s.getMessage().contains("run1"));
+ }
+ assertTrue(primary.getMessage().contains("run2"));
+ }
+
+ // ---- All breakers open: aggregated, zero transport hits ----
+
+ @Test
+ public void testAllBreakersOpenThrowsAggregatedWithoutProbing() throws Exception {
+ CvmClient client = newCvm();
+ EndpointFailoverInterceptor.FailoverState state =
+ new EndpointFailoverInterceptor.FailoverState(60_000);
+ EndpointFailoverInterceptor.STATE.put("cvm.tencentcloudapi.com", state);
+ for (CircuitBreaker breaker : state.breakers) {
+ tripBreaker(breaker);
+ }
+
+ TransportStub transport = installStub(client);
+ TencentCloudSDKException sdkEx = null;
+ try {
+ client.DescribeInstances(new DescribeInstancesRequest());
+ fail("expected SDK exception when every breaker is open");
+ } catch (TencentCloudSDKException e) {
+ sdkEx = e;
+ }
+
+ IOException primary = unwrapToIOException(sdkEx);
+ assertTrue(primary.getMessage().contains("circuit breaker open"));
+ assertEquals(2, primary.getSuppressed().length);
+ for (Throwable s : primary.getSuppressed()) {
+ assertTrue(s.getMessage().contains("circuit breaker open"));
+ }
+ assertEquals("must not send any request when every breaker is open",
+ 0, transport.received.size());
+ }
+
+ // ---- Breaker lifecycle: real traffic drives Closed → Open → HalfOpen → Closed ----
+
+ @Test
+ public void testBreakerOpensAfterSustainedRealFailure() throws Exception {
+ // Drive the .com breaker entirely through the public API: 5 attempts
+ // where .com always fails DNS and .cn always succeeds. .cn never
+ // touches .com's breaker, so .com accumulates 5/5 failures (≥maxFailNum=5,
+ // 100%≥maxFailPercentage=0.75) and trips Open. After that, the next
+ // request must skip .com without sending it to transport.
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+
+ for (int i = 0; i < 5; i++) {
+ transport.programFailure(new UnknownHostException("real fail " + i));
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ // Force origin reprobe so .com is hit again next loop.
+ EndpointFailoverInterceptor.FailoverState s =
+ EndpointFailoverInterceptor.STATE.get("cvm.tencentcloudapi.com");
+ s.originProbeAfterMs = 0;
+ }
+ assertEquals(10, transport.received.size());
+
+ // Sanity: state exists, breaker[0] (.com) is Open.
+ EndpointFailoverInterceptor.FailoverState state =
+ EndpointFailoverInterceptor.STATE.get("cvm.tencentcloudapi.com");
+ assertNotNull(state);
+ assertFalse(".com breaker should be Open after 5/5 failures",
+ state.breakers[0].allow().allowed);
+
+ // Next request: .com short-circuited, goes straight to .cn.
+ transport.received.clear();
+ transport.programOk();
+ // Force origin reprobe again — irrelevant here because breaker is
+ // Open and short-circuits regardless of probe ordering.
+ state.originProbeAfterMs = 0;
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals("Open breaker must short-circuit .com without transport hit",
+ 1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(0).url().host());
+ }
+
+ @Test
+ public void testBreakerTransitionsOpenToHalfOpenAfterCooldown() throws Exception {
+ // Pre-place a FailoverState with a *short* breaker timeout so we don't
+ // have to sleep 60 s. Trip its .com breaker Open, wait for cooldown,
+ // then verify the next attempt is allowed (HalfOpen) and reaches
+ // transport against .com again.
+ long shortTimeoutMs = 100;
+ EndpointFailoverInterceptor.FailoverState state =
+ new EndpointFailoverInterceptor.FailoverState(shortTimeoutMs);
+ EndpointFailoverInterceptor.STATE.put("cvm.tencentcloudapi.com", state);
+ tripBreaker(state.breakers[0]);
+ assertFalse("breaker should be Open immediately after trip",
+ state.breakers[0].allow().allowed);
+
+ // Wait past cooldown — Open → HalfOpen on next allow().
+ Thread.sleep(shortTimeoutMs + 50);
+ CircuitBreaker.Token probeToken = state.breakers[0].allow();
+ assertTrue("breaker should permit a probe (HalfOpen) after cooldown elapses",
+ probeToken.allowed);
+ // Don't report — leave HalfOpen for the next test scenario; here we
+ // only care that the cooldown transition worked.
+ }
+
+ @Test
+ public void testBreakerReClosesAfterHalfOpenSuccessAndStaysClosed() throws Exception {
+ // Full lifecycle through the public API:
+ // Closed → Open (sustained failure)
+ // Open → HalfOpen (cooldown elapses)
+ // HalfOpen → Closed (probe succeeds; default maxRequests=0 means
+ // one success closes the breaker)
+ // After that the .com breaker should permit unlimited traffic.
+ long shortTimeoutMs = 100;
+ EndpointFailoverInterceptor.FailoverState state =
+ new EndpointFailoverInterceptor.FailoverState(shortTimeoutMs);
+ EndpointFailoverInterceptor.STATE.put("cvm.tencentcloudapi.com", state);
+
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+
+ // Open .com via direct breaker manipulation (faster than 5 real loops).
+ tripBreaker(state.breakers[0]);
+ assertFalse(state.breakers[0].allow().allowed);
+
+ // Wait past cooldown to permit HalfOpen probe.
+ Thread.sleep(shortTimeoutMs + 50);
+
+ // Force origin reprobe so .com is the first candidate; respond OK.
+ // candidates() puts .com first, breaker is HalfOpen → permits probe →
+ // success reports to breaker → Closed.
+ state.originProbeAfterMs = 0;
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+
+ // Breaker must be Closed now — multiple back-to-back allow() calls
+ // should all succeed without short-circuiting.
+ for (int i = 0; i < 10; i++) {
+ assertTrue("breaker should be Closed after HalfOpen success, attempt " + i,
+ state.breakers[0].allow().allowed);
+ }
+
+ // End-to-end: a fresh request should reach transport on .com without
+ // failover, since the breaker is Closed and origin probe was cleared.
+ transport.received.clear();
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+ }
+
+ @Test
+ public void testBreakerReOpensWhenHalfOpenProbeFails() throws Exception {
+ // Open → HalfOpen → Open: a single failure during HalfOpen reverts
+ // to Open. The interceptor must surface that failure and on the next
+ // request short-circuit again.
+ long shortTimeoutMs = 100;
+ EndpointFailoverInterceptor.FailoverState state =
+ new EndpointFailoverInterceptor.FailoverState(shortTimeoutMs);
+ EndpointFailoverInterceptor.STATE.put("cvm.tencentcloudapi.com", state);
+
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+
+ tripBreaker(state.breakers[0]);
+ Thread.sleep(shortTimeoutMs + 50);
+
+ // HalfOpen probe: .com first, fails again → re-Open. .cn succeeds.
+ state.originProbeAfterMs = 0;
+ transport.programFailure(new UnknownHostException("still down"));
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(1).url().host());
+
+ // .com breaker must be Open again immediately (not waiting for the
+ // failure threshold — HalfOpen reverts to Open on a single failure).
+ assertFalse("HalfOpen failure must re-Open the breaker",
+ state.breakers[0].allow().allowed);
+
+ // Next request short-circuits .com again.
+ transport.received.clear();
+ state.originProbeAfterMs = 0;
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(0).url().host());
+ }
+
+ // ---- Followup ordering: known-working TLD preferred; origin reprobed after cooldown ----
+
+ @Test
+ public void testFollowupRequestUsesKnownWorkingTld() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+
+ transport.programFailure(new UnknownHostException("first dns miss"));
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+
+ transport.received.clear();
+ // Ample programmed outcomes — if the interceptor wrongly reprobes
+ // .com it will consume more than one and the assertion catches it.
+ transport.programOk();
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.cn", transport.received.get(0).url().host());
+ }
+
+ @Test
+ public void testFollowupRequestReprobesOriginalTldAfterCooldown() throws Exception {
+ CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+
+ transport.programFailure(new UnknownHostException("first dns miss"));
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+
+ EndpointFailoverInterceptor.FailoverState state =
+ EndpointFailoverInterceptor.STATE.get("cvm.tencentcloudapi.com");
+ assertNotNull(state);
+ state.originProbeAfterMs = 0; // simulate cooldown elapsed
+
+ transport.received.clear();
+ transport.programOk();
+ transport.programOk();
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(1, transport.received.size());
+ assertEquals("cvm.tencentcloudapi.com", transport.received.get(0).url().host());
+ assertEquals("origin probe must clear cooldown after a successful reprobe",
+ -1, state.originProbeAfterMs);
+ }
+
+ // ---- Resigned request must use rotated SecretId/Key ----
+
+ @Test
+ public void testResignPicksUpRotatedCredential() throws Exception {
+ final CvmClient client = newCvm();
+ TransportStub transport = installStub(client);
+ transport.programFailure(new UnknownHostException("dns miss"));
+ transport.programOk();
+
+ // Swap creds mid-flight (between original sign and resign).
+ installInterceptorBefore(client, new Interceptor() {
+ private boolean swapped = false;
+ @Override public Response intercept(Chain chain) throws IOException {
+ if (!swapped) {
+ swapped = true;
+ client.setCredential(new Credential("AKIDNEW", "SKNEW"));
+ }
+ return chain.proceed(chain.request());
+ }
+ });
+
+ client.DescribeInstances(new DescribeInstancesRequest());
+ assertEquals(2, transport.received.size());
+ // First request signed with old creds, resign with new ones.
+ assertTrue(transport.received.get(0).header("Authorization").contains("Credential=AKIDTEST/"));
+ assertTrue(transport.received.get(1).header("Authorization").contains("Credential=AKIDNEW/"));
+ }
+
+ // =================================================================
+ // Helpers
+ // =================================================================
+
+ private static CvmClient newCvm() {
+ return newCvm(new ClientProfile());
+ }
+
+ private static CvmClient newCvm(ClientProfile profile) {
+ return new CvmClient(
+ new Credential("AKIDTEST", "SKTEST"),
+ "ap-guangzhou",
+ profile);
+ }
+
+ /**
+ * Reaches into the CvmClient's HttpConnection and rebuilds its OkHttpClient
+ * with {@code stub} appended as the terminal interceptor, so all in-flight
+ * traffic is short-circuited to the stub instead of hitting the network.
+ * Returns the stub for scripting.
+ */
+ private static TransportStub installStub(AbstractClient client) {
+ TransportStub stub = new TransportStub();
+ OkHttpClient orig = grabOkHttpClient(client);
+ setOkHttpClient(client, orig.newBuilder().addInterceptor(stub).build());
+ return stub;
+ }
+
+ /**
+ * Adds an interceptor BEFORE the existing chain so it sees the request
+ * before the failover interceptor. Used for mid-flight credential rotation
+ * scenarios where the test needs to mutate state between attempts.
+ */
+ private static void installInterceptorBefore(AbstractClient client, Interceptor it) {
+ OkHttpClient orig = grabOkHttpClient(client);
+ OkHttpClient.Builder b = new OkHttpClient.Builder()
+ .connectTimeout(orig.connectTimeoutMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
+ .readTimeout(orig.readTimeoutMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
+ .writeTimeout(orig.writeTimeoutMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
+ .addInterceptor(it);
+ for (Interceptor existing : orig.interceptors()) {
+ b.addInterceptor(existing);
+ }
+ setOkHttpClient(client, b.build());
+ }
+
+ 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 void tripBreaker(CircuitBreaker breaker) {
+ // CircuitBreaker.Setting.maxFailNum=5, maxFailPercentage=0.75 by
+ // default — 6 consecutive failures guarantee Open state.
+ for (int i = 0; i < 6; i++) {
+ CircuitBreaker.Token t = breaker.allow();
+ if (t.allowed) {
+ t.report(false);
+ }
+ }
+ }
+
+ /**
+ * SDK wraps the failover IOException as the cause of a TencentCloudSDKException.
+ * Walk one level down to get the IOException that carries primary message
+ * and suppressed entries.
+ */
+ 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];
+ }
+ okio.Buffer buf = new okio.Buffer();
+ req.body().writeTo(buf);
+ return buf.readByteArray();
+ }
+
+ /** Mid-flight token rotator used by token-rotation tests. Swaps once. */
+ private static final class AtomicTokenSwapper implements Interceptor {
+ private final AbstractClient client;
+ private final String newToken;
+ private boolean swapped = false;
+
+ AtomicTokenSwapper(AbstractClient client, String newToken) {
+ this.client = client;
+ this.newToken = newToken;
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ if (!swapped) {
+ swapped = true;
+ Credential cur = client.getCredential();
+ client.setCredential(newToken == null
+ ? new Credential(cur.getSecretId(), cur.getSecretKey())
+ : new Credential(cur.getSecretId(), cur.getSecretKey(), newToken));
+ }
+ return chain.proceed(chain.request());
+ }
+ }
+
+ /**
+ * Terminal interceptor that replaces the network. Tests script a queue of
+ * outcomes (IOException / Response). Records every request that reaches it.
+ */
+ private static final class TransportStub implements Interceptor {
+ final List received = new ArrayList();
+ private final Queue