From 03981322e4e1036069d5f56eaf22e153f86f3a35 Mon Sep 17 00:00:00 2001 From: laughingyear Date: Tue, 12 May 2026 19:55:49 +0800 Subject: [PATCH 1/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- core/CMakeLists.txt | 5 + .../tencentcloud/core/AbstractClient.h | 145 +++++++- .../tencentcloud/core/CircuitBreaker.h | 101 ++++++ .../tencentcloud/core/DomainFailoverManager.h | 94 +++++ .../tencentcloud/core/profile/ClientProfile.h | 17 +- .../core/profile/RegionBreakerProfile.h | 105 ++++++ core/src/AbstractClient.cpp | 202 ++++++++++- core/src/CircuitBreaker.cpp | 212 ++++++++++++ core/src/DomainFailoverManager.cpp | 211 ++++++++++++ core/src/http/HttpClient.cpp | 53 +++ core/src/profile/ClientProfile.cpp | 20 ++ example/cvm/v20170312/CMakeLists.txt | 9 +- example/cvm/v20170312/DomainFailover.cpp | 326 ++++++++++++++++++ test/function_test/core/CMakeLists.txt | 3 + .../core/Core_CircuitBreaker_Ft.cpp | 158 +++++++++ .../core/Core_DomainFailoverManager_Ft.cpp | 218 ++++++++++++ .../core/Core_Profile_DomainFailover_Ft.cpp | 83 +++++ 17 files changed, 1941 insertions(+), 21 deletions(-) create mode 100644 core/include/tencentcloud/core/CircuitBreaker.h create mode 100644 core/include/tencentcloud/core/DomainFailoverManager.h create mode 100644 core/include/tencentcloud/core/profile/RegionBreakerProfile.h create mode 100644 core/src/CircuitBreaker.cpp create mode 100644 core/src/DomainFailoverManager.cpp create mode 100644 example/cvm/v20170312/DomainFailover.cpp create mode 100644 test/function_test/core/Core_CircuitBreaker_Ft.cpp create mode 100644 test/function_test/core/Core_DomainFailoverManager_Ft.cpp create mode 100644 test/function_test/core/Core_Profile_DomainFailover_Ft.cpp diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 00b68d8c4a..0d5650f3cf 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -20,9 +20,11 @@ set(core_public_header include/tencentcloud/core/AbstractClient.h include/tencentcloud/core/AbstractModel.h include/tencentcloud/core/AsyncCallerContext.h + include/tencentcloud/core/CircuitBreaker.h include/tencentcloud/core/CommonClient.h include/tencentcloud/core/Config.h include/tencentcloud/core/Credential.h + include/tencentcloud/core/DomainFailoverManager.h include/tencentcloud/core/Error.h include/tencentcloud/core/Executor.h include/tencentcloud/core/NetworkProxy.h @@ -43,6 +45,7 @@ set(core_public_header_http set(core_public_header_profile include/tencentcloud/core/profile/ClientProfile.h include/tencentcloud/core/profile/HttpProfile.h + include/tencentcloud/core/profile/RegionBreakerProfile.h ) set(core_public_header_utils @@ -57,8 +60,10 @@ set(core_src src/AbstractClient.cpp src/AbstractModel.cpp src/AsyncCallerContext.cpp + src/CircuitBreaker.cpp src/CommonClient.cpp src/Credential.cpp + src/DomainFailoverManager.cpp src/Executor.cpp src/NetworkProxy.cpp src/Sign.cpp diff --git a/core/include/tencentcloud/core/AbstractClient.h b/core/include/tencentcloud/core/AbstractClient.h index 8a41339433..a401a22629 100644 --- a/core/include/tencentcloud/core/AbstractClient.h +++ b/core/include/tencentcloud/core/AbstractClient.h @@ -21,12 +21,38 @@ #include #include #include +#include +#include #include "AbstractModel.h" #include +#include #include +#include namespace TencentCloud { + /// Abstract base for all generated service clients. + /// + /// Thread safety & lifetime contract: + /// + /// 1. Set*() methods (SetClientProfile, SetCredential, + /// SetNetworkProxy, SetHeader, SetRegion) are NOT thread-safe + /// with concurrent DoRequest / DoRequestAsync calls. Configure + /// the client fully before issuing requests from multiple + /// threads. + /// + /// 2. DoRequestAsync() dispatches work on HttpClient's background + /// worker; its completion callback captures `this` and touches + /// m_endpointBreakers via ReportResult(). Correctness of those + /// callbacks relies on the invariant that by the time + /// ~AbstractClient() proceeds past `delete m_httpClient`, the + /// worker has been joined and no callback is still running. + /// The destructor is written to preserve this ordering; DO + /// NOT: + /// * reorder the dtor to release breakers before deleting + /// m_httpClient; + /// * replace m_httpClient with any lifetime management that + /// lets it outlive AbstractClient. class AbstractClient { public: @@ -72,6 +98,76 @@ namespace TencentCloud HttpClient *m_httpClient; std::string m_service; std::map m_headers; + + // Region failover (domain-level circuit breaker). + // + // There are 4 endpoints in the fallback chain, but only the + // first 3 need a circuit breaker -- the last one is the bottom + // fallback and never needs to "fail over" to anything: + // + // breaker[0] : primary endpoint (user-supplied) + // breaker[1] : BackupEndpoint (fallback_index=0; skipped if not set) + // breaker[2] : 1st TLD fallback (fallback_index=1) + // (no breaker): 2nd TLD fallback (fallback_index=2) <- last resort + // + // TLD ring: .com → .com.cn → .cn → .com → ... + // Examples (primary = .com, no BackupEndpoint): + // breaker[0]: cvm.ap-shanghai.tencentcloudapi.com + // breaker[1]: (skipped, BackupEndpoint empty) + // breaker[2]: cvm.tencentcloudapi.com.cn + // bottom: cvm.tencentcloudapi.cn + // + // Semantics: breaker[i] Open means "the endpoint currently + // handled by this breaker is unhealthy -- the next request + // should skip past it to endpoint i+1". When all three + // breakers are Open, requests go to the bottom TLD (no breaker + // needed since there is no further fallback). + // + // For any single request, at most ONE breaker is Allow()ed == + // true, and that breaker is the only one that receives a + // Report() call. Breakers whose Allow() returned false are not + // reported; they recover via their own Open-to-HalfOpen + // timeout. + // + // Size == DomainFailoverManager::GetBreakerSlotCount() (== 3). + // + // Stored as raw pointers (new[]/delete[] in Init/Release) to + // preserve AbstractClient's implicit copyability -- adding + // unique_ptr would make the class non-copyable and break + // backward compatibility even though no one actually copies + // a Client instance. The ownership is clear: AbstractClient + // creates them in the ctor and destroys them in the dtor. + CircuitBreaker **m_endpointBreakers; + int m_endpointBreakerCount; + + void InitRegionBreakers(); + void ReleaseRegionBreakers(); + + /// Pick the endpoint to use for this request. + /// |out_allowed_breaker_idx| is set to the index (0..2) of the + /// breaker that returned Allow() == true (the breaker + /// associated with the endpoint this request will use), or -1 + /// in the following cases: + /// - region failover disabled (no breaker consulted) + /// - non-TencentCloud primary endpoint (breakers bypassed) + /// - all three breakers are Open, so the request falls + /// through to the bottom TLD endpoint (the last in the + /// ring) which has no breaker to report to + /// In any of these cases, ReportResult() is a no-op. + std::string SelectEndpoint(const std::string &primary_endpoint, + int &out_allowed_breaker_idx); + + /// Report the request outcome to the single breaker that + /// Allow()ed this request. |allowed_breaker_idx| == -1 is a + /// no-op (see SelectEndpoint for when that happens). + void ReportResult(int allowed_breaker_idx, bool success); + + static bool IsFailoverTriggering(const std::string &error_code); + + HttpClient::HttpResponseOutcome DoRequestWithEndpoint( + const std::string &actionName, const std::string &body, + std::map &headers, + const std::string &endpoint); }; } @@ -81,25 +177,33 @@ void TencentCloud::AbstractClient::DoRequestAsync( { using RequestOutcome = Outcome; const auto& http_profile = m_clientProfile.GetHttpProfile(); - std::string endpoint = http_profile.GetEndpoint(); - if (endpoint.empty()) + std::string primary_endpoint = http_profile.GetEndpoint(); + if (primary_endpoint.empty()) { - endpoint = m_endpoint; + primary_endpoint = m_endpoint; } - std::string::size_type pos = endpoint.find_first_of('.'); + // Pick an endpoint for this request based on circuit breaker state. + int allowed_breaker_idx = -1; + std::string resolved_endpoint = SelectEndpoint(primary_endpoint, allowed_breaker_idx); + + std::string::size_type pos = resolved_endpoint.find_first_of('.'); if (pos != std::string::npos) - m_service = endpoint.substr(0, pos); + m_service = resolved_endpoint.substr(0, pos); else { m_service = "unknown"; - Core::Error err("ClientError", "endpoint `" + endpoint + "` is not valid"); + // Endpoint is syntactically invalid and cannot be used. + // Release the HalfOpen probe slot SelectEndpoint reserved, so + // invalid-endpoint paths don't leak breaker probe capacity. + ReportResult(allowed_breaker_idx, /*success=*/false); + Core::Error err("ClientError", "endpoint `" + resolved_endpoint + "` is not valid"); handler(req, RequestOutcome(err)); return; } Url url; - url.SetHost(endpoint); + url.SetHost(resolved_endpoint); HttpProfile::Scheme scheme = http_profile.GetProtocol(); if (scheme == HttpProfile::Scheme::HTTP) url.SetScheme("http"); @@ -145,14 +249,39 @@ void TencentCloud::AbstractClient::DoRequestAsync( m_httpClient->SetReqTimeout(http_profile.GetReqTimeout() * 1000); m_httpClient->SetConnectTimeout(http_profile.GetConnectTimeout() * 1000); - m_httpClient->SendRequestAsync(http_req, [req, handler](HttpClient::HttpResponseOutcome http_resp) + // Align with the synchronous path: honor user-provided CA and + // resolve-IP settings so that async requests respect HTTPS custom + // trust anchors and forced DNS resolution. + m_httpClient->SetCaInfo(http_profile.GetCaInfo()); + m_httpClient->SetCaPath(http_profile.GetCaPath()); + m_httpClient->SetResolveIp(http_profile.GetResolveIp()); + + // Capture |allowed_breaker_idx| (a plain int) so the async + // callback can Report() via AbstractClient's method. + // + // Capturing `this` is safe because of the lifetime contract + // documented on the AbstractClient class: ~AbstractClient() deletes + // m_httpClient first, which joins the async worker and guarantees + // no callback is running before m_endpointBreakers is released. + // Breaking that contract (e.g. reordering the dtor, or sharing + // HttpClient across longer-lived owners) turns this capture into a + // dangling-this bug. + m_httpClient->SendRequestAsync(http_req, + [this, req, handler, allowed_breaker_idx]( + HttpClient::HttpResponseOutcome http_resp) mutable { if (!http_resp.IsSuccess()) { + const auto& error_code = http_resp.GetError().GetErrorCode(); + if (IsFailoverTriggering(error_code)) { + ReportResult(allowed_breaker_idx, /*success=*/false); + } handler(req, RequestOutcome(http_resp.GetError())); return; } + ReportResult(allowed_breaker_idx, /*success=*/true); + std::string http_resp_body{http_resp.GetResult().Body(), http_resp.GetResult().BodySize()}; Resp resp{}; diff --git a/core/include/tencentcloud/core/CircuitBreaker.h b/core/include/tencentcloud/core/CircuitBreaker.h new file mode 100644 index 0000000000..eb82621390 --- /dev/null +++ b/core/include/tencentcloud/core/CircuitBreaker.h @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#ifndef TENCENTCLOUD_CORE_CIRCUITBREAKER_H_ +#define TENCENTCLOUD_CORE_CIRCUITBREAKER_H_ + +#include +#include + +#include + +namespace TencentCloud +{ + +/// Three-state circuit breaker for region failover. +/// +/// Closed -> traffic allowed; failures counted in a sliding window +/// Open -> traffic rejected; after |timeout| enter HalfOpen +/// HalfOpen -> at most |max_requests| concurrent probes; enough +/// successes close it, any failure reopens it +/// +/// Usage: +/// if (breaker.Allow()) { +/// bool ok = SendRequest(); +/// breaker.Report(ok); // MUST pair with every Allow() == true +/// } +class CircuitBreaker +{ +public: + enum class State + { + kClosed, + kOpen, + kHalfOpen, + }; + + CircuitBreaker(); + explicit CircuitBreaker(const RegionBreakerProfile &profile); + + // Holds a std::mutex -- non-copyable, non-movable. + CircuitBreaker(const CircuitBreaker &) = delete; + CircuitBreaker &operator=(const CircuitBreaker &) = delete; + + /// Returns true iff a new request should be allowed to proceed. + /// On true, the caller MUST subsequently call Report() with the + /// outcome; otherwise HalfOpen probe slots leak. + bool Allow(); + + /// Report the outcome of a request that got Allow() == true. + void Report(bool success); + + /// Observational. + State GetState(); + +private: + void LazyAdvanceStateLocked(); + void TransitionToLocked(State new_state); + bool ReadyToOpenLocked() const; + int64_t NowMs() const; + + std::mutex m_mutex; + State m_state; + + // Counters within the current state window; reset on any state + // transition and on Closed-window expiry. + int m_total; + int m_failures; + int m_consecutiveFailures; + + // HalfOpen-only: number of probes Allow()ed but not yet Report()ed. + // Bounded by |m_maxRequests| to avoid flooding a fragile endpoint. + int m_halfOpenInFlight; + + // Absolute timestamp (ms) at which the current window/timeout + // expires. 0 means "no auto timeout" (HalfOpen). + int64_t m_expiryMs; + + // Immutable configuration (derived from RegionBreakerProfile). + int m_maxFailNum; + double m_maxFailPercent; + int64_t m_windowIntervalMs; + int64_t m_timeoutMs; + int m_maxRequests; +}; + +} // namespace TencentCloud + +#endif // TENCENTCLOUD_CORE_CIRCUITBREAKER_H_ diff --git a/core/include/tencentcloud/core/DomainFailoverManager.h b/core/include/tencentcloud/core/DomainFailoverManager.h new file mode 100644 index 0000000000..d3b6688d16 --- /dev/null +++ b/core/include/tencentcloud/core/DomainFailoverManager.h @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#ifndef TENCENTCLOUD_CORE_DOMAINFAILOVERMANAGER_H_ +#define TENCENTCLOUD_CORE_DOMAINFAILOVERMANAGER_H_ + +#include +#include + +namespace TencentCloud +{ + +/// Pure utility that constructs fallback endpoint candidates. +/// +/// Design: +/// Primary endpoint (index -1): the user-supplied endpoint. +/// Fallback sequence: +/// index 0: user-configured BackupEndpoint (e.g. ap-guangzhou.tencentcloudapi.com) +/// index 1: next TLD in ring after primary's TLD (region stripped) +/// index 2: second TLD in ring after primary's TLD (region stripped) +/// +/// TLD ring order: .com → .com.cn → .cn → .com → ... +/// Examples: +/// primary .com → index 1 = .com.cn, index 2 = .cn +/// primary .com.cn → index 1 = .cn, index 2 = .com +/// primary .cn → index 1 = .com, index 2 = .com.cn +/// +/// When constructing TLD fallback, the region segment is stripped so that +/// "cvm.ap-shanghai.tencentcloudapi.com" -> "cvm.tencentcloudapi.com.cn". +/// The ".ai" style middle segment is preserved: +/// "hunyuan.ai.ap-shanghai.tencentcloudapi.com" -> "hunyuan.ai.tencentcloudapi.com.cn". +/// +/// This class is stateless; state (closed/open/halfopen) lives in CircuitBreaker. +class DomainFailoverManager { + public: + /// Number of breaker slots needed for the failover chain. + /// 3 slots: primary(breaker[0]) + BackupEndpoint(breaker[1]) + + /// first TLD fallback(breaker[2]). The last TLD in the ring is the + /// bottom fallback and has no breaker. + static int GetBreakerSlotCount(); + + /// Build a fallback endpoint for |fallback_index| given the primary + /// |original_endpoint| and user-configured |backup_endpoint|. + /// + /// Returns empty string if |fallback_index| is out of range or if + /// |original_endpoint| is not a TencentCloud domain. + static std::string GetFallbackEndpoint(const std::string &original_endpoint, + const std::string &backup_endpoint, + int fallback_index); + + /// True iff |endpoint| targets tencentcloudapi.com / .com.cn / .cn . + static bool IsTencentCloudDomain(const std::string &endpoint); + + /// Extract the longest matching TLD suffix from |endpoint|, returning + /// one of "tencentcloudapi.com.cn", "tencentcloudapi.com", + /// "tencentcloudapi.cn", or "" if none match. + static std::string ExtractTld(const std::string &endpoint); + + /// Extract the first segment (service name) from |endpoint|. + /// e.g. "hunyuan.ai.tencentcloudapi.com" -> "hunyuan". + static std::string ExtractService(const std::string &endpoint); + + /// Extract the "middle" segment between the service and the TLD. + /// TencentCloud API domains have exactly two shapes: + /// {service}[.{region}].{tld} -> returns "" + /// {service}.ai[.{region}].{tld} -> returns "ai" + /// This recognizes ONLY the literal "ai" middle; any other shape + /// returns "". Region segments are not enumerated, so new regions + /// need no change here. + static std::string ExtractMiddleSegment(const std::string &endpoint, + const std::string &tld); + + private: + /// TLD ring: .com → .com.cn → .cn → (wraps to .com). + /// Used to compute the two non-primary TLD fallback candidates. + static const std::vector kTldRing; +}; + +} // namespace TencentCloud + +#endif // TENCENTCLOUD_CORE_DOMAINFAILOVERMANAGER_H_ diff --git a/core/include/tencentcloud/core/profile/ClientProfile.h b/core/include/tencentcloud/core/profile/ClientProfile.h index f1178672d8..100b877b93 100644 --- a/core/include/tencentcloud/core/profile/ClientProfile.h +++ b/core/include/tencentcloud/core/profile/ClientProfile.h @@ -18,6 +18,7 @@ #define TENCENTCLOUD_CORE_CLIENTPROFILE_H_ #include "HttpProfile.h" +#include "RegionBreakerProfile.h" namespace TencentCloud { @@ -43,7 +44,9 @@ namespace TencentCloud ClientProfile(const SignMethod &signMethod, const HttpProfile &httpProfile) : m_httpProfile(httpProfile), m_unsignedPayload(false), - m_signMethod(signMethod) + m_signMethod(signMethod), + m_disableRegionBreaker(true), + m_regionBreakerProfile() { } @@ -56,6 +59,16 @@ namespace TencentCloud void SetHttpProfile(const HttpProfile &httpProfile); HttpProfile GetHttpProfile() const; + /// Region-level failover control. + /// Disabled by default: users must opt in explicitly by calling + /// SetDisableRegionBreaker(false). Set to false to enable + /// region failover. + void SetDisableRegionBreaker(bool disabled); + bool GetDisableRegionBreaker() const; + + void SetRegionBreakerProfile(const RegionBreakerProfile &profile); + RegionBreakerProfile GetRegionBreakerProfile() const; + protected: void SetUnsignedPayload(bool flag); bool IsUnsignedPayload(); @@ -64,6 +77,8 @@ namespace TencentCloud HttpProfile m_httpProfile; bool m_unsignedPayload; SignMethod m_signMethod; + bool m_disableRegionBreaker; + RegionBreakerProfile m_regionBreakerProfile; }; } diff --git a/core/include/tencentcloud/core/profile/RegionBreakerProfile.h b/core/include/tencentcloud/core/profile/RegionBreakerProfile.h new file mode 100644 index 0000000000..316c45af18 --- /dev/null +++ b/core/include/tencentcloud/core/profile/RegionBreakerProfile.h @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#ifndef TENCENTCLOUD_CORE_REGIONBREAKERPROFILE_H_ +#define TENCENTCLOUD_CORE_REGIONBREAKERPROFILE_H_ + +#include + +namespace TencentCloud +{ + +/// Configuration for region-level circuit breaker (domain failover). +/// +/// Once the primary endpoint fails enough, the SDK automatically switches to +/// |backup_endpoint|; if that also fails it falls back to backup TLDs +/// (tencentcloudapi.com.cn -> tencentcloudapi.cn), each transition driven +/// by the same circuit breaker state machine. +class RegionBreakerProfile +{ +public: + RegionBreakerProfile() = default; + + /// @param backup_endpoint The user-specified backup endpoint, in the + /// form ".tencentcloudapi.com" or a full domain. If empty, + /// the BackupEndpoint layer is skipped and fallback proceeds to + /// TLD switching directly. + /// @param max_fail_num Max failure count in the window to trip the breaker. Default 5. + /// @param max_fail_percent Max failure rate in the window to trip the breaker. Default 0.75. + /// @param window_interval Sliding window size in seconds. Default 300. + /// @param timeout Seconds to remain Open before transitioning to HalfOpen. Default 60. + /// @param max_requests Two roles, both active only in HalfOpen state: + /// 1) upper bound on concurrent in-flight + /// probes -- once this many Allow() + /// calls have returned true without a + /// matching Report(), further Allow() + /// calls return false until one completes; + /// 2) total successful probes needed to + /// close the breaker: the transition + /// fires when (total - failures) >= + /// max_requests within the HalfOpen + /// episode. + /// Default 5. + RegionBreakerProfile(const std::string &backup_endpoint, + int max_fail_num, + double max_fail_percent, + int window_interval, + int timeout, + int max_requests) + : m_backupEndpoint(backup_endpoint), + m_maxFailNum(max_fail_num), + m_maxFailPercent(max_fail_percent), + m_windowInterval(window_interval), + m_timeout(timeout), + m_maxRequests(max_requests) + { + } + + void SetBackupEndpoint(const std::string &endpoint) { m_backupEndpoint = endpoint; } + std::string GetBackupEndpoint() const { return m_backupEndpoint; } + + void SetMaxFailNum(int n) { m_maxFailNum = n; } + int GetMaxFailNum() const { return m_maxFailNum; } + + void SetMaxFailPercent(double p) { m_maxFailPercent = p; } + double GetMaxFailPercent() const { return m_maxFailPercent; } + + void SetWindowInterval(int secs) { m_windowInterval = secs; } + int GetWindowInterval() const { return m_windowInterval; } + + void SetTimeout(int secs) { m_timeout = secs; } + int GetTimeout() const { return m_timeout; } + + /// See the constructor comment: max_requests controls BOTH the + /// HalfOpen concurrent-probe cap AND the successful-probe count + /// required to close the breaker. + void SetMaxRequests(int n) { m_maxRequests = n; } + int GetMaxRequests() const { return m_maxRequests; } + +private: + // Empty by default: when not explicitly set, the BackupEndpoint layer + // is skipped and fallback proceeds directly to TLD switching. + std::string m_backupEndpoint; + int m_maxFailNum = 5; + double m_maxFailPercent = 0.75; + int m_windowInterval = 300; + int m_timeout = 60; + int m_maxRequests = 5; +}; + +} // namespace TencentCloud + +#endif // TENCENTCLOUD_CORE_REGIONBREAKERPROFILE_H_ diff --git a/core/src/AbstractClient.cpp b/core/src/AbstractClient.cpp index 2ce94ac965..0ef10fb45f 100644 --- a/core/src/AbstractClient.cpp +++ b/core/src/AbstractClient.cpp @@ -40,13 +40,23 @@ AbstractClient::AbstractClient(const string &endpoint, const string &version, co m_sdkVersion(SDK_VERSION_PREFIX + TENCENTCLOUD_VERSION_STR), m_apiVersion(version), m_httpClient(new HttpClient()), - m_service("") + m_service(""), + m_endpointBreakers(nullptr), + m_endpointBreakerCount(0) { + InitRegionBreakers(); } AbstractClient::~AbstractClient() { + // Order matters: deleting m_httpClient joins its async worker, + // ensuring no DoRequestAsync callback is still running (and thus no + // callback is still holding `this` or about to touch + // m_endpointBreakers) by the time ReleaseRegionBreakers() runs. + // Do NOT reorder these two lines. See the lifetime note in + // AbstractClient.h. delete m_httpClient; + ReleaseRegionBreakers(); } void AbstractClient::SetNetworkProxy(const NetworkProxy &proxy) @@ -96,6 +106,7 @@ HttpClient::HttpResponseOutcome AbstractClient::MakeRequest(const AbstractModel& headers.insert(std::make_pair("Content-Type", "application/json")); return DoRequest(actionName, body, headers); } + HttpClient::HttpResponseOutcome AbstractClient::MakeRequestJson(const std::string &actionName, const std::string ¶ms) { std::map headers; @@ -108,15 +119,149 @@ HttpClient::HttpResponseOutcome AbstractClient::MakeRequestOctetStream(const std headers.insert(std::make_pair("Content-Type", "application/octet-stream")); return DoRequest(actionName, body, headers); } + typedef std::map OctetStreamHeadersMap; -HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &actionName, const std::string &body, std::map &headers) +void AbstractClient::InitRegionBreakers() { - HttpProfile httpProfile = m_clientProfile.GetHttpProfile(); - string endpoint = httpProfile.GetEndpoint(); - if (endpoint == "") - endpoint = m_endpoint; + // Only allocate breakers when region failover is enabled. + // When disabled, m_endpointBreakers stays nullptr and + // SelectEndpoint early-returns without touching them. + if (m_clientProfile.GetDisableRegionBreaker()) + { + return; + } + m_endpointBreakerCount = DomainFailoverManager::GetBreakerSlotCount(); + RegionBreakerProfile rb_profile = m_clientProfile.GetRegionBreakerProfile(); + m_endpointBreakers = new CircuitBreaker*[m_endpointBreakerCount]; + for (int i = 0; i < m_endpointBreakerCount; ++i) + { + m_endpointBreakers[i] = new CircuitBreaker(rb_profile); + } +} + +void AbstractClient::ReleaseRegionBreakers() +{ + if (m_endpointBreakers != nullptr) + { + for (int i = 0; i < m_endpointBreakerCount; ++i) + { + delete m_endpointBreakers[i]; + } + delete[] m_endpointBreakers; + m_endpointBreakers = nullptr; + m_endpointBreakerCount = 0; + } +} +std::string AbstractClient::SelectEndpoint(const std::string &primary_endpoint, + int &out_allowed_breaker_idx) +{ + out_allowed_breaker_idx = -1; + + // If region failover is disabled, always use the primary endpoint. + if (m_clientProfile.GetDisableRegionBreaker()) + { + return primary_endpoint; + } + // Guard: breakers were not allocated (e.g. failover was disabled + // at construction time but enabled at runtime via SetClientProfile). + if (m_endpointBreakers == nullptr || m_endpointBreakerCount == 0) + { + return primary_endpoint; + } + // Non-TencentCloud endpoints (e.g. custom proxy) bypass failover. + if (!DomainFailoverManager::IsTencentCloudDomain(primary_endpoint)) + { + return primary_endpoint; + } + + RegionBreakerProfile rb_profile = m_clientProfile.GetRegionBreakerProfile(); + std::string backup_ep = rb_profile.GetBackupEndpoint(); + + // Walk the fallback chain. At iteration |i| we: + // - compute |next| = the endpoint that |i|-th fallback target + // resolves to (or empty if that fallback is not configured, + // i.e. layer 0 with no BackupEndpoint); + // - consult m_endpointBreakers[i], which records the statistics + // of the endpoint currently held in |current|. + // + // If that breaker Allow()s, we stay on |current| and reply to the + // caller that m_endpointBreakers[i] is the one to Report() back + // to. Otherwise the breaker is Open -- |current| is unhealthy -- + // and we descend to |next|, consulting the next breaker on the + // next iteration. + // + // If all breakers end up Open, the loop completes without setting + // out_allowed_breaker_idx. |current| will be the last-resort + // bottom TLD endpoint (the last in the ring after primary's TLD) + // and out_allowed_breaker_idx stays -1: no breaker needs + // to hear about this request because .cn is the bottom and has no + // further fallback. + std::string current = primary_endpoint; + const int count = m_endpointBreakerCount; + for (int i = 0; i < count; ++i) + { + std::string next = DomainFailoverManager::GetFallbackEndpoint( + primary_endpoint, backup_ep, i); + if (next.empty()) + { + // This fallback slot is unused (e.g. layer 0 with no + // BackupEndpoint). Skip WITHOUT consulting breaker[i]; + // it stays idle for this request. The next iteration + // will consult breaker[i+1] for the |current| endpoint. + continue; + } + + if (m_endpointBreakers[i]->Allow()) + { + // Breaker i says: "the endpoint you are currently on is + // healthy -- stay." Remember this breaker so the outcome + // can be reported back to it. + out_allowed_breaker_idx = i; + break; + } + // Breaker i Open: |current| is unhealthy. Descend. + current = next; + } + return current; +} + +void AbstractClient::ReportResult(int allowed_breaker_idx, bool success) +{ + if (allowed_breaker_idx >= 0 && m_endpointBreakers != nullptr) + { + m_endpointBreakers[allowed_breaker_idx]->Report(success); + } +} + +bool AbstractClient::IsFailoverTriggering(const std::string &error_code) +{ + // Only network-level errors that are reliably attributable to the + // current endpoint warrant a failover transition: + // DnsError - CURLE_COULDNT_RESOLVE_HOST + // ConnectionError - CURLE_COULDNT_CONNECT + // SSLError - CURLE_PEER_FAILED_VERIFICATION (strong hint + // of DNS hijacking for TencentCloud domains) + // + // Explicitly NOT triggering failover: + // ServiceNetworkError - any HTTP non-2xx; may come from business + // errors (4xx), server faults (5xx), or throttling, and cannot + // be reliably attributed to a per-endpoint network problem. + // Matches HEAD's original behavior. + // NetworkError - e.g. CURLE_OPERATION_TIMEDOUT spans multiple + // stages; local client cert errors cannot be fixed by switching + // domain. + return error_code == "DnsError" || + error_code == "ConnectionError" || + error_code == "SSLError"; +} + +HttpClient::HttpResponseOutcome AbstractClient::DoRequestWithEndpoint( + const std::string &actionName, const std::string &body, + std::map &headers, + const std::string &endpoint) +{ string::size_type pos = endpoint.find_first_of("."); if (pos != string::npos) m_service = endpoint.substr(0, pos); @@ -126,6 +271,8 @@ HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &act return HttpClient::HttpResponseOutcome(Core::Error("ClientError", "endpoint `"+ endpoint + "` is not valid")); } + HttpProfile httpProfile = m_clientProfile.GetHttpProfile(); + Url url; url.SetHost(endpoint); HttpProfile::Scheme scheme = httpProfile.GetProtocol(); @@ -150,14 +297,14 @@ HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &act httpRequest.AddHeader("Connection", "Close"); if (headers.size() > 0) { - for(std::map::iterator iter = headers.begin(); iter != headers.end(); iter++) + for (std::map::iterator iter = headers.begin(); iter != headers.end(); ++iter) { httpRequest.AddHeader(iter->first, iter->second); } } if (m_headers.size() > 0) { - for(std::map::iterator iter = m_headers.begin(); iter != m_headers.end(); iter++) + for (std::map::iterator iter = m_headers.begin(); iter != m_headers.end(); ++iter) { httpRequest.AddHeader(iter->first, iter->second); } @@ -174,6 +321,43 @@ HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &act return m_httpClient->SendRequest(httpRequest); } +HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &actionName, const std::string &body, std::map &headers) +{ + HttpProfile httpProfile = m_clientProfile.GetHttpProfile(); + string primary_endpoint = httpProfile.GetEndpoint(); + if (primary_endpoint == "") + primary_endpoint = m_endpoint; + + // Pick an endpoint for this request based on circuit breaker state. + // No in-request retry: the breakers make the decision once per + // request. The outcome of that single request is then fed back to + // the breaker (success or failover-triggering error). + int allowed_breaker_idx = -1; + string target_endpoint = SelectEndpoint(primary_endpoint, allowed_breaker_idx); + + auto outcome = DoRequestWithEndpoint(actionName, body, headers, target_endpoint); + + if (outcome.IsSuccess()) + { + ReportResult(allowed_breaker_idx, /*success=*/true); + } + else + { + const auto &error_code = outcome.GetError().GetErrorCode(); + // ClientError here is raised by DoRequestWithEndpoint when the + // resolved endpoint is syntactically invalid. That endpoint is + // unusable, so release the HalfOpen probe slot Allow() reserved + // for this request -- otherwise repeated invalid-endpoint paths + // would leak probe capacity. + if (IsFailoverTriggering(error_code) || error_code == "ClientError") + { + ReportResult(allowed_breaker_idx, /*success=*/false); + } + } + + return outcome; +} + void AbstractClient::GenerateSignature(HttpRequest &request) { int64_t currentTime; @@ -213,4 +397,4 @@ void AbstractClient::GenerateSignature(HttpRequest &request) string authorization = "TC3-HMAC-SHA256 Credential=" + secretId + "/" + credentialScope + ", SignedHeaders=content-type;host" + ", Signature=" + signature; request.AddHeader("Authorization", authorization); -} \ No newline at end of file +} diff --git a/core/src/CircuitBreaker.cpp b/core/src/CircuitBreaker.cpp new file mode 100644 index 0000000000..69e5e1f385 --- /dev/null +++ b/core/src/CircuitBreaker.cpp @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#include + +#include + +using namespace TencentCloud; + +namespace +{ + // Trip condition tuning: consecutive-failure branch, independent + // of configured rate/count thresholds, to catch fast-failing + // endpoints that would otherwise not accumulate enough samples to + // trip the rate-based branch. + const int kConsecutiveFailureTripThreshold = 5; +} + +CircuitBreaker::CircuitBreaker() + : CircuitBreaker(RegionBreakerProfile()) +{ +} + +CircuitBreaker::CircuitBreaker(const RegionBreakerProfile &profile) + : m_state(State::kClosed), + m_total(0), + m_failures(0), + m_consecutiveFailures(0), + m_halfOpenInFlight(0), + m_expiryMs(0), + m_maxFailNum(profile.GetMaxFailNum()), + m_maxFailPercent(profile.GetMaxFailPercent()), + m_windowIntervalMs(static_cast(profile.GetWindowInterval()) * 1000), + m_timeoutMs(static_cast(profile.GetTimeout()) * 1000), + m_maxRequests(profile.GetMaxRequests()) +{ + // Start the first Closed-state counting window. + m_expiryMs = NowMs() + m_windowIntervalMs; +} + +int64_t CircuitBreaker::NowMs() const +{ + auto now = std::chrono::steady_clock::now(); + return std::chrono::duration_cast( + now.time_since_epoch()).count(); +} + +bool CircuitBreaker::Allow() +{ + std::lock_guard lock(m_mutex); + LazyAdvanceStateLocked(); + switch (m_state) + { + case State::kClosed: + return true; + case State::kOpen: + return false; + case State::kHalfOpen: + // Rate-limit concurrent probes during recovery. + if (m_halfOpenInFlight < m_maxRequests) + { + ++m_halfOpenInFlight; + return true; + } + return false; + } + return false; // unreachable +} + +void CircuitBreaker::Report(bool success) +{ + std::lock_guard lock(m_mutex); + LazyAdvanceStateLocked(); + + if (m_state == State::kHalfOpen) + { + // Release the probe slot this Report pairs with. + if (m_halfOpenInFlight > 0) + --m_halfOpenInFlight; + + if (!success) + { + // Any failure during probing reopens the breaker. + TransitionToLocked(State::kOpen); + return; + } + // Count successful probes; close once (total - failures) >= + // max_requests within this HalfOpen episode. + ++m_total; + int successes = m_total - m_failures; + if (successes >= m_maxRequests) + { + TransitionToLocked(State::kClosed); + } + return; + } + + if (m_state == State::kClosed) + { + ++m_total; + if (success) + { + m_consecutiveFailures = 0; + } + else + { + ++m_failures; + ++m_consecutiveFailures; + if (ReadyToOpenLocked()) + { + TransitionToLocked(State::kOpen); + } + } + return; + } + + // State::kOpen: Report with no Allow()==true is a caller bug, but + // be defensive and ignore. +} + +CircuitBreaker::State CircuitBreaker::GetState() +{ + std::lock_guard lock(m_mutex); + LazyAdvanceStateLocked(); + return m_state; +} + +void CircuitBreaker::LazyAdvanceStateLocked() +{ + int64_t now = NowMs(); + switch (m_state) + { + case State::kClosed: + // Sliding window expired: reset counters, start a new one. + if (m_expiryMs != 0 && now >= m_expiryMs) + { + m_total = 0; + m_failures = 0; + m_consecutiveFailures = 0; + m_expiryMs = now + m_windowIntervalMs; + } + break; + case State::kOpen: + // Cooling-off period elapsed: enter HalfOpen to probe. + if (m_expiryMs != 0 && now >= m_expiryMs) + { + TransitionToLocked(State::kHalfOpen); + } + break; + case State::kHalfOpen: + // Driven by request outcomes, no auto timeout. + break; + } +} + +void CircuitBreaker::TransitionToLocked(State new_state) +{ + m_state = new_state; + m_total = 0; + m_failures = 0; + m_consecutiveFailures = 0; + m_halfOpenInFlight = 0; + + int64_t now = NowMs(); + switch (m_state) + { + case State::kClosed: + m_expiryMs = now + m_windowIntervalMs; + break; + case State::kOpen: + m_expiryMs = now + m_timeoutMs; + break; + case State::kHalfOpen: + m_expiryMs = 0; + break; + } +} + +bool CircuitBreaker::ReadyToOpenLocked() const +{ + // Trip condition (two independent branches, either is sufficient): + // (failures >= max_fail_num AND failures/total >= max_fail_percent) + // OR + // (consecutive_failures >= kConsecutiveFailureTripThreshold) + if (m_total > 0) + { + double rate = static_cast(m_failures) / + static_cast(m_total); + if (m_failures >= m_maxFailNum && rate >= m_maxFailPercent) + { + return true; + } + } + if (m_consecutiveFailures >= kConsecutiveFailureTripThreshold) + { + return true; + } + return false; +} diff --git a/core/src/DomainFailoverManager.cpp b/core/src/DomainFailoverManager.cpp new file mode 100644 index 0000000000..8657d34bfd --- /dev/null +++ b/core/src/DomainFailoverManager.cpp @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#include + +using namespace TencentCloud; + +const std::vector DomainFailoverManager::kTldRing = { + "tencentcloudapi.com", + "tencentcloudapi.com.cn", + "tencentcloudapi.cn", +}; + +int DomainFailoverManager::GetBreakerSlotCount() { + // 3 breaker slots: primary(0) + BackupEndpoint(1) + 1st TLD fallback(2). + // The last TLD in the ring is the bottom fallback and has no breaker. + // Numerically: 1 + 1 + (ring_size - 2) = ring_size. + return static_cast(kTldRing.size()); +} + +std::string DomainFailoverManager::GetFallbackEndpoint( + const std::string &original_endpoint, + const std::string &backup_endpoint, + int fallback_index) { + if (!IsTencentCloudDomain(original_endpoint)) { + return ""; + } + if (fallback_index < 0) { + return ""; + } + + // Index 0: user-configured BackupEndpoint. + if (fallback_index == 0) { + if (backup_endpoint.empty()) { + return ""; + } + // Decide whether |backup_endpoint| is a "bare region" (e.g. + // "ap-guangzhou.tencentcloudapi.com") + // or a complete endpoint with its own service prefix (e.g. + // "cvm.ap-guangzhou.tencentcloudapi.com"). + // + // We do NOT enumerate region prefixes. Instead we simply count + // how many dotted segments sit in front of the TLD: + // - exactly ONE segment before the TLD -> "bare region": need + // to prepend the original endpoint's service (and .ai middle + // if any). + // - otherwise -> already complete: return as-is. + std::string backup_tld = ExtractTld(backup_endpoint); + if (backup_tld.empty()) { + // Backup is not a TencentCloud domain at all -- return as-is. + return backup_endpoint; + } + // Prefix (the part before ".{tld}"). + std::string prefix = + backup_endpoint.substr(0, backup_endpoint.size() - backup_tld.size() - 1); + bool bare_region = (prefix.find('.') == std::string::npos); + if (!bare_region) { + return backup_endpoint; + } + + std::string svc = ExtractService(original_endpoint); + if (svc.empty()) { + return backup_endpoint; + } + // Preserve ".ai" middle segment from the original endpoint if any. + // original="hunyuan.ai.ap-shanghai.tencentcloudapi.com" + // backup="ap-guangzhou.tencentcloudapi.com" + // -> "hunyuan.ai.ap-guangzhou.tencentcloudapi.com" + std::string tld = ExtractTld(original_endpoint); + std::string middle = ExtractMiddleSegment(original_endpoint, tld); + std::string result = svc; + if (!middle.empty()) { + result += "." + middle; + } + result += "." + backup_endpoint; + return result; + } + + // Index 1..2: TLD fallback, with region segment stripped. + // TLDs form a ring: .com → .com.cn → .cn → .com → ... + // Starting from the primary's own TLD, walk the ring and pick the + // next two distinct TLDs. fallback_index 1 gets the first, 2 the + // second. This guarantees no TLD is ever repeated regardless of + // which TLD the user started with. + int tld_step = fallback_index; // 1 or 2 + std::string original_tld = ExtractTld(original_endpoint); + if (original_tld.empty()) { + return ""; + } + + // Find the primary's position in the ring. + int ring_size = static_cast(kTldRing.size()); + int origin_pos = -1; + for (int i = 0; i < ring_size; ++i) { + if (kTldRing[i] == original_tld) { + origin_pos = i; + break; + } + } + if (origin_pos < 0) { + return ""; // Unknown TLD, should not happen for a valid TC domain. + } + + int target_pos = (origin_pos + tld_step) % ring_size; + const std::string &target_tld = kTldRing[target_pos]; + + std::string svc = ExtractService(original_endpoint); + std::string middle = ExtractMiddleSegment(original_endpoint, original_tld); + + // Build: {service}[.{middle}].{target_tld} + // Region segment is intentionally dropped. + std::string result = svc; + if (!middle.empty()) { + result += "." + middle; + } + result += "." + target_tld; + return result; +} + +namespace { + +// True iff |s| ends with |suffix|. +bool EndsWith(const std::string &s, const std::string &suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// Ordered by descending length: longer TLDs must be tested first. +const char *const kTencentCloudTldSuffixes[] = { + ".tencentcloudapi.com.cn", + ".tencentcloudapi.com", + ".tencentcloudapi.cn", +}; + +} // namespace + +bool DomainFailoverManager::IsTencentCloudDomain( + const std::string &endpoint) { + // Exact suffix match (with a leading '.') so that look-alikes such as + // "tencentcloudapi.company.example.com" are NOT misidentified. + for (const char *suffix : kTencentCloudTldSuffixes) { + if (EndsWith(endpoint, suffix)) { + return true; + } + } + return false; +} + +std::string DomainFailoverManager::ExtractTld(const std::string &endpoint) { + // Match the longest TLD first. Require a leading '.' so that + // "tencentcloudapi.comm" (typo) is not treated as ".com". + for (const char *suffix : kTencentCloudTldSuffixes) { + if (EndsWith(endpoint, suffix)) { + // suffix starts with '.'; skip it when returning. + return std::string(suffix + 1); + } + } + return ""; +} + +std::string DomainFailoverManager::ExtractService( + const std::string &endpoint) { + auto pos = endpoint.find('.'); + if (pos == std::string::npos) { + return endpoint; + } + return endpoint.substr(0, pos); +} + +std::string DomainFailoverManager::ExtractMiddleSegment( + const std::string &endpoint, + const std::string &tld) { + // Tencent Cloud API domains come in exactly two shapes: + // 1) {service}[.{region}].{tld} -- no middle segment + // 2) {service}.ai[.{region}].{tld} -- middle == "ai" + // So the "middle" is determined solely by whether the SECOND dotted + // segment equals the literal "ai". No region-prefix enumeration is + // needed, which means new regions (me-/cn-/...) require no changes. + auto dot1 = endpoint.find('.'); + if (dot1 == std::string::npos) { + return ""; + } + auto dot2 = endpoint.find('.', dot1 + 1); + if (dot2 == std::string::npos) { + return ""; + } + // The second segment must lie strictly before the TLD to be a + // middle (otherwise it IS the start of the TLD). + auto tld_pos = endpoint.rfind("." + tld); + if (tld_pos == std::string::npos || tld_pos < dot2) { + return ""; + } + std::string second = endpoint.substr(dot1 + 1, dot2 - dot1 - 1); + if (second == "ai") { + return "ai"; + } + return ""; +} diff --git a/core/src/http/HttpClient.cpp b/core/src/http/HttpClient.cpp index fdc09ab521..d6eeda2685 100644 --- a/core/src/http/HttpClient.cpp +++ b/core/src/http/HttpClient.cpp @@ -263,7 +263,28 @@ HttpClient::HttpResponseOutcome HttpClient::SendRequest(const HttpRequest &reque return HttpResponseOutcome(response); } + case CURLE_COULDNT_RESOLVE_HOST: + return HttpResponseOutcome(Core::Error("DnsError", std::string(errbuf))); + case CURLE_COULDNT_CONNECT: + return HttpResponseOutcome(Core::Error("ConnectionError", std::string(errbuf))); + case CURLE_PEER_FAILED_VERIFICATION: + // Server certificate verification failed (SAN/CN mismatch, expired, + // untrusted CA, etc.). This strongly indicates a server-side issue + // and is a high-confidence signal of DNS hijacking when the target + // is a TencentCloud domain. + return HttpResponseOutcome(Core::Error("SSLError", std::string(errbuf))); default: + // The following errors fall here and are intentionally not treated + // as ConnectionError / SSLError, because they are most likely + // caused by the client itself and cannot be fixed by switching + // region or TLD: + // - CURLE_OPERATION_TIMEDOUT (28): spans DNS/TCP/TLS/req/resp + // stages, cannot be reliably attributed. + // - CURLE_SSL_CONNECT_ERROR (35): TLS handshake failure due to + // protocol/cipher mismatch, usually a client build issue. + // - CURLE_SSL_CERTPROBLEM (58): local client certificate problem. + // - CURLE_SSL_CIPHER (59): requested cipher not supported by + // the local libcurl/OpenSSL build. return HttpResponseOutcome(Core::Error("NetworkError", std::string(errbuf))); } } @@ -442,7 +463,39 @@ void HttpClient::AsyncReqHandler() { AsyncReqContext* ctx; curl_easy_getinfo(msg->easy_handle, CURLINFO_PRIVATE, &ctx); + CURLcode result = msg->data.result; + if (result != CURLE_OK) + { + // Classify curl errors the same way as synchronous SendRequest + std::string err_msg(ctx->curl_err_buffer); + switch (result) { + case CURLE_COULDNT_RESOLVE_HOST: + ctx->completion_handler( + HttpResponseOutcome(Core::Error("DnsError", err_msg))); + break; + case CURLE_COULDNT_CONNECT: + ctx->completion_handler( + HttpResponseOutcome(Core::Error("ConnectionError", err_msg))); + break; + case CURLE_PEER_FAILED_VERIFICATION: + // Server certificate verification failed. + // High-confidence signal of DNS hijacking for + // TencentCloud domains. + ctx->completion_handler( + HttpResponseOutcome(Core::Error("SSLError", err_msg))); + break; + default: + // CURLE_OPERATION_TIMEDOUT, CURLE_SSL_CONNECT_ERROR, + // CURLE_SSL_CERTPROBLEM, CURLE_SSL_CIPHER and other + // errors fall here. They are mostly client-side + // issues and cannot be fixed by region/TLD switch. + ctx->completion_handler( + HttpResponseOutcome(Core::Error("NetworkError", err_msg))); + break; + } + } + else { int64_t response_code = 0; curl_easy_getinfo(msg->easy_handle, CURLINFO_RESPONSE_CODE, &response_code); diff --git a/core/src/profile/ClientProfile.cpp b/core/src/profile/ClientProfile.cpp index 056075d316..c46a53ed62 100644 --- a/core/src/profile/ClientProfile.cpp +++ b/core/src/profile/ClientProfile.cpp @@ -43,3 +43,23 @@ bool ClientProfile::IsUnsignedPayload() { return m_unsignedPayload; } + +void ClientProfile::SetDisableRegionBreaker(bool disabled) +{ + m_disableRegionBreaker = disabled; +} + +bool ClientProfile::GetDisableRegionBreaker() const +{ + return m_disableRegionBreaker; +} + +void ClientProfile::SetRegionBreakerProfile(const RegionBreakerProfile &profile) +{ + m_regionBreakerProfile = profile; +} + +RegionBreakerProfile ClientProfile::GetRegionBreakerProfile() const +{ + return m_regionBreakerProfile; +} diff --git a/example/cvm/v20170312/CMakeLists.txt b/example/cvm/v20170312/CMakeLists.txt index 46deadbddd..82a724a86c 100644 --- a/example/cvm/v20170312/CMakeLists.txt +++ b/example/cvm/v20170312/CMakeLists.txt @@ -5,7 +5,10 @@ set(CMAKE_CXX_STANDARD 11) add_executable(DescribeInstances DescribeInstances.cpp) add_executable(DescribeInstancesAsync DescribeInstancesAsync.cpp) -target_link_libraries(DescribeInstances cvm core) -target_link_libraries(DescribeInstancesAsync cvm core) +add_executable(DomainFailover DomainFailover.cpp) +target_link_libraries(DescribeInstances tencentcloud-sdk-cpp-cvm tencentcloud-sdk-cpp-core) +target_link_libraries(DescribeInstancesAsync tencentcloud-sdk-cpp-cvm tencentcloud-sdk-cpp-core) +target_link_libraries(DomainFailover tencentcloud-sdk-cpp-cvm tencentcloud-sdk-cpp-core) target_include_directories(DescribeInstances PRIVATE ../../../core/include ../../../cvm/include) -target_include_directories(DescribeInstancesAsync PRIVATE ../../../core/include ../../../cvm/include) \ No newline at end of file +target_include_directories(DescribeInstancesAsync PRIVATE ../../../core/include ../../../cvm/include) +target_include_directories(DomainFailover PRIVATE ../../../core/include ../../../cvm/include) \ No newline at end of file diff --git a/example/cvm/v20170312/DomainFailover.cpp b/example/cvm/v20170312/DomainFailover.cpp new file mode 100644 index 0000000000..17fcd3e984 --- /dev/null +++ b/example/cvm/v20170312/DomainFailover.cpp @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +/// +/// This example demonstrates region-level domain failover (aligned with +/// Python SDK's region_breaker) in the C++ SDK. +/// +/// When the primary endpoint keeps failing, a circuit breaker drives +/// automatic switching: +/// primary -> user-configured BackupEndpoint +/// -> tencentcloudapi.com.cn (TLD fallback) +/// -> tencentcloudapi.cn (TLD fallback) +/// +/// Run with: TENCENTCLOUD_SECRET_ID=xxx TENCENTCLOUD_SECRET_KEY=yyy ./DomainFailover +/// + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace TencentCloud; +using namespace TencentCloud::Cvm::V20170312; +using namespace TencentCloud::Cvm::V20170312::Model; +using namespace std; + +static void PrintOutcome(CvmClient::DescribeInstancesOutcome outcome) { + if (!outcome.IsSuccess()) { + cout << " Error: " << outcome.GetError().PrintAll() << endl; + } else { + auto rsp = outcome.GetResult(); + cout << " RequestId=" << rsp.GetRequestId() + << " TotalCount=" << rsp.GetTotalCount() << endl; + } + cout << endl; +} + +/// Example 1: Default behavior - region failover is DISABLED by default +/// (aligned with Python SDK). The SDK uses the primary endpoint only. +void DefaultDisabled() { + cout << "=== Example 1: Default (Region Failover Disabled) ===" << endl; + + const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); + const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); + Credential cred(sid ? sid : "", skey ? skey : ""); + + CvmClient client(cred, "ap-guangzhou"); + + DescribeInstancesRequest req; + req.SetOffset(0); + req.SetLimit(5); + PrintOutcome(client.DescribeInstances(req)); +} + +/// Example 2: Enable region failover. +/// Without an explicit BackupEndpoint, the BackupEndpoint layer is +/// skipped; the SDK falls back directly to .com.cn then .cn. +void EnableWithoutBackupEndpoint() { + cout << "=== Example 2: Region Failover (no BackupEndpoint, TLD only) ===" << endl; + + const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); + const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); + Credential cred(sid ? sid : "", skey ? skey : ""); + + ClientProfile clientProfile; + clientProfile.SetDisableRegionBreaker(false); // Enable failover + // RegionBreakerProfile left with default-empty BackupEndpoint + + CvmClient client(cred, "ap-guangzhou", clientProfile); + + DescribeInstancesRequest req; + req.SetOffset(0); + req.SetLimit(5); + PrintOutcome(client.DescribeInstances(req)); +} + +/// Example 3: Custom RegionBreakerProfile (configuration only). +void EnableWithCustomProfile() { + cout << "=== Example 3: Custom RegionBreakerProfile ===" << endl; + + const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); + const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); + Credential cred(sid ? sid : "", skey ? skey : ""); + + RegionBreakerProfile rb( + /*backup_endpoint=*/ "ap-beijing.tencentcloudapi.com", + /*max_fail_num=*/ 3, + /*max_fail_percent=*/0.5, + /*window_interval=*/ 60, + /*timeout=*/ 30, + /*max_requests=*/ 3); + + ClientProfile clientProfile; + clientProfile.SetDisableRegionBreaker(false); + clientProfile.SetRegionBreakerProfile(rb); + + HttpProfile httpProfile; + httpProfile.SetEndpoint("cvm.ap-shanghai.tencentcloudapi.com"); + httpProfile.SetReqTimeout(5); + clientProfile.SetHttpProfile(httpProfile); + + CvmClient client(cred, "ap-shanghai", clientProfile); + + DescribeInstancesRequest req; + req.SetOffset(0); + req.SetLimit(5); + PrintOutcome(client.DescribeInstances(req)); +} + +/// Example 4: Demonstrate actual failover in action. +/// +/// Sets the primary endpoint to a domain that passes the SDK's +/// IsTencentCloudDomain() check (ends with ".tencentcloudapi.com") +/// but uses a fabricated sub-domain that will fail with SSLError +/// (CURLE_PEER_FAILED_VERIFICATION) because the wildcard DNS record +/// resolves it to a server whose certificate SAN does not match. +/// +/// SSLError is one of the failover-triggering error codes (it is a +/// strong signal of DNS hijacking for legitimate TencentCloud domains). +/// The circuit breaker trips after consecutive SSLError failures, and +/// subsequent requests are routed to the BackupEndpoint (a real domain). +/// +/// Expected output: +/// Requests 1~5: SSLError (certificate SAN mismatch on fabricated domain) +/// Request 6+: Success (failover to backup endpoint) +void DemonstrateActualFailover() { + cout << "=== Example 4: Actual Failover Demonstration ===" << endl; + cout << " Primary endpoint: cvm.does-not-exist.tencentcloudapi.com (SSL will fail)" << endl; + cout << " Backup endpoint: ap-guangzhou.tencentcloudapi.com (will succeed)" << endl; + cout << endl; + + const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); + const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); + if (!sid || !skey) { + cout << " [SKIP] Set TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY to run this example." << endl; + cout << endl; + return; + } + Credential cred(sid, skey); + + // Configure breaker with low thresholds so failover triggers quickly. + RegionBreakerProfile rb( + /*backup_endpoint=*/ "ap-guangzhou.tencentcloudapi.com", + /*max_fail_num=*/ 5, + /*max_fail_percent=*/0.75, + /*window_interval=*/ 300, + /*timeout=*/ 60, + /*max_requests=*/ 3); + + ClientProfile clientProfile; + clientProfile.SetDisableRegionBreaker(false); + clientProfile.SetRegionBreakerProfile(rb); + + HttpProfile httpProfile; + // Fabricated sub-domain under tencentcloudapi.com: + // - DNS resolves (wildcard A record) -> passes IsTencentCloudDomain() + // - SSL certificate SAN does not match -> CURLE_PEER_FAILED_VERIFICATION + // - SDK maps to "SSLError" -> triggers failover + httpProfile.SetEndpoint("cvm.does-not-exist.tencentcloudapi.com"); + httpProfile.SetConnectTimeout(3); + httpProfile.SetReqTimeout(5); + clientProfile.SetHttpProfile(httpProfile); + + CvmClient client(cred, "ap-guangzhou", clientProfile); + + DescribeInstancesRequest req; + req.SetOffset(0); + req.SetLimit(1); + + // Send 10 requests to observe failover behavior. + // + // How to tell that the endpoint has switched from the output: + // + // - SSLError with message containing "does-not-exist" means the + // request is still hitting the PRIMARY endpoint (SSL cert SAN + // mismatch on the fabricated sub-domain). + // + // - Once the output changes from FAILED to SUCCESS, the circuit + // breaker has tripped (Closed -> Open) and the SDK is now + // routing requests to the BACKUP endpoint + // (cvm.ap-guangzhou.tencentcloudapi.com). + // + // - The transition point (last FAILED -> first SUCCESS) is + // exactly where the domain switch happened. + // + // For deeper debugging, enable curl verbose mode or add a log line + // in AbstractClient::DoRequest() after SelectEndpoint() returns: + // std::cerr << "[Failover] endpoint=" << target_endpoint << std::endl; + const int kTotalRequests = 10; + int fail_count = 0; + int success_count = 0; + + for (int i = 1; i <= kTotalRequests; ++i) { + cout << " Request #" << i << ": "; + auto outcome = client.DescribeInstances(req); + if (!outcome.IsSuccess()) { + ++fail_count; + cout << "FAILED - " << outcome.GetError().GetErrorCode() + << ": " << outcome.GetError().GetErrorMessage() << endl; + } else { + ++success_count; + cout << "SUCCESS - RequestId=" << outcome.GetResult().GetRequestId() << endl; + } + } + + cout << endl; + cout << " Summary: " << fail_count << " failed, " + << success_count << " succeeded (failover triggered after " + << fail_count << " failures)" << endl; + cout << endl; +} + +/// Example 5: Failover WITHOUT BackupEndpoint (TLD-only fallback). +/// +/// When BackupEndpoint is empty, the fallback chain skips index 0 and +/// goes directly to TLD switching: +/// primary (.com) -> .com.cn -> .cn +/// +/// Expected output: +/// Requests 1~5: SSLError (primary endpoint cert mismatch) +/// Request 6+: Success (failover to cvm.tencentcloudapi.com.cn) +void DemonstrateFailoverWithoutBackup() { + cout << "=== Example 5: Failover Without BackupEndpoint (TLD fallback) ===" << endl; + cout << " Primary endpoint: cvm.does-not-exist.tencentcloudapi.com (will fail)" << endl; + cout << " BackupEndpoint: (empty - skipped)" << endl; + cout << " Expected target: cvm.tencentcloudapi.com.cn" << endl; + cout << endl; + + const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); + const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); + if (!sid || !skey) { + cout << " [SKIP] Set TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY to run this example." << endl; + cout << endl; + return; + } + Credential cred(sid, skey); + + // No BackupEndpoint: leave RegionBreakerProfile with default empty string. + RegionBreakerProfile rb( + /*backup_endpoint=*/ "", + /*max_fail_num=*/ 5, + /*max_fail_percent=*/0.75, + /*window_interval=*/ 300, + /*timeout=*/ 60, + /*max_requests=*/ 3); + + ClientProfile clientProfile; + clientProfile.SetDisableRegionBreaker(false); + clientProfile.SetRegionBreakerProfile(rb); + + HttpProfile httpProfile; + // Fabricated sub-domain under .com: SSL cert mismatch triggers failover. + httpProfile.SetEndpoint("cvm.does-not-exist.tencentcloudapi.com"); + httpProfile.SetConnectTimeout(3); + httpProfile.SetReqTimeout(5); + clientProfile.SetHttpProfile(httpProfile); + + CvmClient client(cred, "ap-guangzhou", clientProfile); + + DescribeInstancesRequest req; + req.SetOffset(0); + req.SetLimit(1); + + // After 5 consecutive SSLError failures on the primary, the breaker + // opens. Since BackupEndpoint is empty (index 0 skipped), the SDK + // falls back to the next TLD in the ring: + // primary .com -> index 1 = .com.cn + // So requests should hit cvm.tencentcloudapi.com.cn + const int kTotalRequests = 10; + int fail_count = 0; + int success_count = 0; + + for (int i = 1; i <= kTotalRequests; ++i) { + cout << " Request #" << i << ": "; + auto outcome = client.DescribeInstances(req); + if (!outcome.IsSuccess()) { + ++fail_count; + cout << "FAILED - " << outcome.GetError().GetErrorCode() + << ": " << outcome.GetError().GetErrorMessage() << endl; + } else { + ++success_count; + cout << "SUCCESS - RequestId=" << outcome.GetResult().GetRequestId() << endl; + } + } + + cout << endl; + cout << " Summary: " << fail_count << " failed, " + << success_count << " succeeded" << endl; + cout << " (Without BackupEndpoint, failover goes to .com.cn TLD)" << endl; + cout << endl; +} + +int main() { + TencentCloud::InitAPI(); + + DefaultDisabled(); + EnableWithoutBackupEndpoint(); + EnableWithCustomProfile(); + DemonstrateActualFailover(); + DemonstrateFailoverWithoutBackup(); + + TencentCloud::ShutdownAPI(); + return 0; +} diff --git a/test/function_test/core/CMakeLists.txt b/test/function_test/core/CMakeLists.txt index 7c3ebce9a6..a1e5906f23 100644 --- a/test/function_test/core/CMakeLists.txt +++ b/test/function_test/core/CMakeLists.txt @@ -32,7 +32,10 @@ add_executable(core_ft Core_Http_HttpResponse_Ft.cpp Core_Profile_HttpProfile_Ft.cpp Core_Profile_ClientProfile_Ft.cpp + Core_Profile_DomainFailover_Ft.cpp Core_Http_ResolveIp_Ft.cpp + Core_CircuitBreaker_Ft.cpp + Core_DomainFailoverManager_Ft.cpp Main.cpp) target_link_libraries(core_ft tencentcloud-sdk-cpp-cbs tencentcloud-sdk-cpp-cvm tencentcloud-sdk-cpp-core) target_link_libraries(core_ft gtest gmock_main -lpthread -lm) diff --git a/test/function_test/core/Core_CircuitBreaker_Ft.cpp b/test/function_test/core/Core_CircuitBreaker_Ft.cpp new file mode 100644 index 0000000000..925fda7c7a --- /dev/null +++ b/test/function_test/core/Core_CircuitBreaker_Ft.cpp @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#include +#include + +#include +#include +#include + +using namespace TencentCloud; + +static RegionBreakerProfile MakeProfile(int max_fail_num, + double max_fail_percent, + int window_interval, + int timeout, + int max_requests) { + return RegionBreakerProfile("ap-guangzhou.tencentcloudapi.com", + max_fail_num, max_fail_percent, + window_interval, timeout, max_requests); +} + +// Drives |n| Allow/Report round-trips with the given outcome. Assumes +// every Allow() is granted (caller controls state). +static void DrainN(CircuitBreaker &cb, int n, bool success) { + for (int i = 0; i < n; ++i) { + ASSERT_TRUE(cb.Allow()); + cb.Report(success); + } +} + +TEST(CircuitBreakerTest, DefaultStateClosed) { + CircuitBreaker cb; + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); + EXPECT_TRUE(cb.Allow()); + cb.Report(true); // pair with the Allow above +} + +TEST(CircuitBreakerTest, StayClosedOnSuccess) { + CircuitBreaker cb; + DrainN(cb, 100, true); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); +} + +TEST(CircuitBreakerTest, TripOnFailNumAndFailRate) { + CircuitBreaker cb(MakeProfile(3, 0.75, 300, 60, 3)); + DrainN(cb, 3, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); +} + +TEST(CircuitBreakerTest, DoesNotTripWhenRateTooLow) { + CircuitBreaker cb(MakeProfile(3, 0.9, 300, 60, 3)); + DrainN(cb, 2, false); + DrainN(cb, 2, true); + DrainN(cb, 1, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); +} + +TEST(CircuitBreakerTest, DoesNotTripWhenCountTooLow) { + CircuitBreaker cb(MakeProfile(5, 0.5, 300, 60, 3)); + DrainN(cb, 4, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); + DrainN(cb, 1, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); +} + +TEST(CircuitBreakerTest, TripOnConsecutiveFailuresGreaterThanFive) { + // Even with an unreachable rate threshold, >=5 consecutive + // failures still trip the breaker via the consecutive-failure + // branch. + CircuitBreaker cb(MakeProfile(100, 1.1, 300, 60, 3)); + DrainN(cb, 5, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); +} + +TEST(CircuitBreakerTest, OpenStateDisallowsRequests) { + CircuitBreaker cb(MakeProfile(3, 0.75, 300, 60, 3)); + DrainN(cb, 3, false); + EXPECT_FALSE(cb.Allow()); +} + +TEST(CircuitBreakerTest, TransitionsToHalfOpenAfterTimeout) { + CircuitBreaker cb(MakeProfile(3, 0.75, 300, 1, 3)); + DrainN(cb, 3, false); + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kHalfOpen); + EXPECT_TRUE(cb.Allow()); + cb.Report(true); // pair with Allow +} + +TEST(CircuitBreakerTest, HalfOpenClosesAfterExceedingMaxRequestsSuccesses) { + // Requires (total - failures) >= max_requests to close. With + // max_requests = 3 that is the 3rd successful probe. + CircuitBreaker cb(MakeProfile(3, 0.75, 300, 1, 3)); + DrainN(cb, 3, false); + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + + EXPECT_TRUE(cb.Allow()); cb.Report(true); // 1 + EXPECT_TRUE(cb.Allow()); cb.Report(true); // 2 + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kHalfOpen); + EXPECT_TRUE(cb.Allow()); cb.Report(true); // 3 (>= max_requests) -> Closed + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); +} + +TEST(CircuitBreakerTest, HalfOpenReopensOnAnyFailure) { + CircuitBreaker cb(MakeProfile(3, 0.75, 300, 1, 3)); + DrainN(cb, 3, false); + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + EXPECT_TRUE(cb.Allow()); + cb.Report(false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); +} + +TEST(CircuitBreakerTest, ClosedWindowExpiryResetsCounters) { + // After |window_interval| the Closed-state counters reset. Earlier + // failures must not contribute to the trip condition. + CircuitBreaker cb(MakeProfile(3, 0.75, /*window_interval=*/1, 60, 3)); + DrainN(cb, 2, false); + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + // Next Allow() lazily advances the state and resets counters. + DrainN(cb, 2, false); // Only 2 failures in the new window. + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); +} + +// ===== HalfOpen concurrent-probe rate limit ===== + +TEST(CircuitBreakerTest, HalfOpenRateLimitsConcurrentProbes) { + // max_requests = 2: up to 2 probes may be in-flight at once in + // HalfOpen. A 3rd concurrent Allow() without any Report in between + // must be rejected. + CircuitBreaker cb(MakeProfile(3, 0.75, 300, 1, 2)); + DrainN(cb, 3, false); + std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + + EXPECT_TRUE(cb.Allow()); // slot 1 + EXPECT_TRUE(cb.Allow()); // slot 2 + EXPECT_FALSE(cb.Allow()); // budget exhausted + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kHalfOpen); + + // Release one slot via Report; budget becomes available again. + cb.Report(true); + EXPECT_TRUE(cb.Allow()); + cb.Report(true); // release + cb.Report(true); // release the 1st probe too +} diff --git a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp new file mode 100644 index 0000000000..5126761eda --- /dev/null +++ b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#include + +#include +#include + +using namespace TencentCloud; +using namespace std; + +TEST(DomainFailoverManagerTest, BreakerSlotCountIsThree) { + // primary + BackupEndpoint + .com.cn == 3 slots that need a + // breaker. The final .cn TLD is the bottom fallback and has no + // breaker. + EXPECT_EQ(DomainFailoverManager::GetBreakerSlotCount(), 3); +} + +TEST(DomainFailoverManagerTest, NonTencentCloudDomainReturnsEmpty) { + string result = DomainFailoverManager::GetFallbackEndpoint( + "my-proxy.corp.com", "ap-guangzhou.tencentcloudapi.com", 0); + EXPECT_EQ(result, ""); +} + +TEST(DomainFailoverManagerTest, OutOfRangeIndexReturnsEmpty) { + string primary = "cvm.tencentcloudapi.com"; + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + primary, "ap-guangzhou.tencentcloudapi.com", -1), + ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + primary, "ap-guangzhou.tencentcloudapi.com", 3), + ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + primary, "ap-guangzhou.tencentcloudapi.com", 999), + ""); +} + +// ===== Index 0: BackupEndpoint ===== + +TEST(DomainFailoverManagerTest, BackupEndpointPrependsService) { + // User-configured BackupEndpoint starts with a region; SDK must + // prepend the primary endpoint's service name. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 0); + EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(DomainFailoverManagerTest, BackupEndpointPrependsServiceForNonRegionalPrimary) { + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 0); + EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(DomainFailoverManagerTest, BackupEndpointPreservesAiMiddleSegment) { + string result = DomainFailoverManager::GetFallbackEndpoint( + "hunyuan.ai.ap-shanghai.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 0); + EXPECT_EQ(result, "hunyuan.ai.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(DomainFailoverManagerTest, BackupEndpointWithCompleteDomain) { + // If backup_endpoint already has a service prefix, keep it as-is. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com", + "cvm.ap-guangzhou.tencentcloudapi.com", + 0); + EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); +} + +// ===== Index 1: TLD ring fallback (next TLD after primary) ===== + +TEST(DomainFailoverManagerTest, TldFallbackFromComGoesToComCn) { + // Ring: .com → .com.cn → .cn. Primary .com → next = .com.cn. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 1); + EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); +} + +TEST(DomainFailoverManagerTest, TldFallbackFromComCnGoesToCn) { + // Ring: .com.cn → .cn → .com. Primary .com.cn → next = .cn. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com.cn", + "ap-guangzhou.tencentcloudapi.com", + 1); + EXPECT_EQ(result, "cvm.tencentcloudapi.cn"); +} + +TEST(DomainFailoverManagerTest, TldFallbackFromCnGoesToCom) { + // Ring: .cn → .com → .com.cn. Primary .cn → next = .com. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.cn", + "ap-guangzhou.tencentcloudapi.com", + 1); + EXPECT_EQ(result, "cvm.tencentcloudapi.com"); +} + +TEST(DomainFailoverManagerTest, TldFallbackPreservesAiMiddle) { + string result = DomainFailoverManager::GetFallbackEndpoint( + "hunyuan.ai.ap-shanghai.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 1); + EXPECT_EQ(result, "hunyuan.ai.tencentcloudapi.com.cn"); +} + +TEST(DomainFailoverManagerTest, TldFallbackFromNonRegionalPrimary) { + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 1); + EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); +} + +// ===== Index 2: TLD ring fallback (second TLD after primary) ===== + +TEST(DomainFailoverManagerTest, TldFallback2FromComGoesToCn) { + // Ring: .com → .com.cn → .cn. Primary .com → second = .cn. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 2); + EXPECT_EQ(result, "cvm.tencentcloudapi.cn"); +} + +TEST(DomainFailoverManagerTest, TldFallback2FromComCnGoesToCom) { + // Ring: .com.cn → .cn → .com. Primary .com.cn → second = .com. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com.cn", + "ap-guangzhou.tencentcloudapi.com", + 2); + EXPECT_EQ(result, "cvm.tencentcloudapi.com"); +} + +TEST(DomainFailoverManagerTest, TldFallback2FromCnGoesToComCn) { + // Ring: .cn → .com → .com.cn. Primary .cn → second = .com.cn. + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.cn", + "ap-guangzhou.tencentcloudapi.com", + 2); + EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); +} + +TEST(DomainFailoverManagerTest, TldFallback2PreservesAiMiddle) { + string result = DomainFailoverManager::GetFallbackEndpoint( + "hunyuan.ai.ap-shanghai.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 2); + EXPECT_EQ(result, "hunyuan.ai.tencentcloudapi.cn"); +} + +// ===== Helper methods ===== + +TEST(DomainFailoverManagerTest, IsTencentCloudDomain) { + EXPECT_TRUE(DomainFailoverManager::IsTencentCloudDomain("cvm.tencentcloudapi.com")); + EXPECT_TRUE(DomainFailoverManager::IsTencentCloudDomain("cvm.tencentcloudapi.com.cn")); + EXPECT_TRUE(DomainFailoverManager::IsTencentCloudDomain("cvm.tencentcloudapi.cn")); + EXPECT_FALSE(DomainFailoverManager::IsTencentCloudDomain("my-proxy.corp.com")); + EXPECT_FALSE(DomainFailoverManager::IsTencentCloudDomain("tencentcloud.com")); + // Suffix match must require a leading dot to avoid misidentifying + // look-alike / typo domains. + EXPECT_FALSE(DomainFailoverManager::IsTencentCloudDomain("tencentcloudapi.comm")); + EXPECT_FALSE(DomainFailoverManager::IsTencentCloudDomain("tencentcloudapi.company.example.com")); + EXPECT_FALSE(DomainFailoverManager::IsTencentCloudDomain("faketencentcloudapi.com")); +} + +TEST(DomainFailoverManagerTest, ExtractTld) { + EXPECT_EQ(DomainFailoverManager::ExtractTld("cvm.tencentcloudapi.com"), + "tencentcloudapi.com"); + EXPECT_EQ(DomainFailoverManager::ExtractTld("cvm.tencentcloudapi.com.cn"), + "tencentcloudapi.com.cn"); + EXPECT_EQ(DomainFailoverManager::ExtractTld("cvm.tencentcloudapi.cn"), + "tencentcloudapi.cn"); + EXPECT_EQ(DomainFailoverManager::ExtractTld("my-proxy.corp.com"), ""); + EXPECT_EQ(DomainFailoverManager::ExtractTld("tencentcloudapi.comm"), ""); + EXPECT_EQ(DomainFailoverManager::ExtractTld("tencentcloudapi.company.example.com"), ""); +} + +TEST(DomainFailoverManagerTest, ExtractService) { + EXPECT_EQ(DomainFailoverManager::ExtractService("cvm.tencentcloudapi.com"), "cvm"); + EXPECT_EQ(DomainFailoverManager::ExtractService("hunyuan.ai.tencentcloudapi.com"), + "hunyuan"); + EXPECT_EQ(DomainFailoverManager::ExtractService("no-dot-here"), "no-dot-here"); +} + +TEST(DomainFailoverManagerTest, ExtractMiddleSegmentForAiDomains) { + EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( + "hunyuan.ai.tencentcloudapi.com", "tencentcloudapi.com"), + "ai"); + EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( + "hunyuan.ai.ap-guangzhou.tencentcloudapi.com", + "tencentcloudapi.com"), + "ai"); + EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( + "cvm.tencentcloudapi.com", "tencentcloudapi.com"), + ""); + EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( + "cvm.ap-guangzhou.tencentcloudapi.com", "tencentcloudapi.com"), + ""); +} diff --git a/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp b/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp new file mode 100644 index 0000000000..caa999193b --- /dev/null +++ b/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#include +#include + +#include + +using namespace TencentCloud; + +TEST(ClientProfileDomainFailoverTest, RegionBreakerDisabledByDefault) { + // Region failover is opt-in: users must explicitly call + // SetDisableRegionBreaker(false) to enable it. + ClientProfile cp; + EXPECT_TRUE(cp.GetDisableRegionBreaker()); +} + +TEST(ClientProfileDomainFailoverTest, EnableDisableRegionBreaker) { + ClientProfile cp; + cp.SetDisableRegionBreaker(false); + EXPECT_FALSE(cp.GetDisableRegionBreaker()); + cp.SetDisableRegionBreaker(true); + EXPECT_TRUE(cp.GetDisableRegionBreaker()); +} + +TEST(ClientProfileDomainFailoverTest, DefaultRegionBreakerProfile) { + // Verify the default RegionBreakerProfile values. + // BackupEndpoint defaults to empty (user opts in explicitly). + ClientProfile cp; + RegionBreakerProfile rb = cp.GetRegionBreakerProfile(); + EXPECT_EQ(rb.GetBackupEndpoint(), ""); + EXPECT_EQ(rb.GetMaxFailNum(), 5); + EXPECT_DOUBLE_EQ(rb.GetMaxFailPercent(), 0.75); + EXPECT_EQ(rb.GetWindowInterval(), 300); + EXPECT_EQ(rb.GetTimeout(), 60); + EXPECT_EQ(rb.GetMaxRequests(), 5); +} + +TEST(ClientProfileDomainFailoverTest, SetCustomRegionBreakerProfile) { + RegionBreakerProfile rb( + /*backup_endpoint=*/ "ap-beijing.tencentcloudapi.com", + /*max_fail_num=*/ 3, + /*max_fail_percent=*/0.5, + /*window_interval=*/ 60, + /*timeout=*/ 30, + /*max_requests=*/ 3); + + ClientProfile cp; + cp.SetRegionBreakerProfile(rb); + + RegionBreakerProfile got = cp.GetRegionBreakerProfile(); + EXPECT_EQ(got.GetBackupEndpoint(), "ap-beijing.tencentcloudapi.com"); + EXPECT_EQ(got.GetMaxFailNum(), 3); + EXPECT_DOUBLE_EQ(got.GetMaxFailPercent(), 0.5); + EXPECT_EQ(got.GetWindowInterval(), 60); + EXPECT_EQ(got.GetTimeout(), 30); + EXPECT_EQ(got.GetMaxRequests(), 3); +} + +TEST(ClientProfileDomainFailoverTest, PreservedWithHttpProfileConstructor) { + HttpProfile hp; + hp.SetEndpoint("cvm.tencentcloudapi.com"); + ClientProfile cp(hp); + EXPECT_TRUE(cp.GetDisableRegionBreaker()); +} + +TEST(ClientProfileDomainFailoverTest, PreservedWithSignMethodConstructor) { + ClientProfile cp(ClientProfile::SignMethod::TC3_HMAC_SHA256); + EXPECT_TRUE(cp.GetDisableRegionBreaker()); +} From 046af3c6338b1837c5cb382c3ff79ad64981b06c Mon Sep 17 00:00:00 2001 From: laughingyear Date: Tue, 12 May 2026 20:26:24 +0800 Subject: [PATCH 2/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- core/src/AbstractClient.cpp | 7 +------ core/src/http/HttpClient.cpp | 17 ++++++++++++++++- example/cvm/v20170312/CMakeLists.txt | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/core/src/AbstractClient.cpp b/core/src/AbstractClient.cpp index 0ef10fb45f..ff1d7c0d02 100644 --- a/core/src/AbstractClient.cpp +++ b/core/src/AbstractClient.cpp @@ -344,12 +344,7 @@ HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &act else { const auto &error_code = outcome.GetError().GetErrorCode(); - // ClientError here is raised by DoRequestWithEndpoint when the - // resolved endpoint is syntactically invalid. That endpoint is - // unusable, so release the HalfOpen probe slot Allow() reserved - // for this request -- otherwise repeated invalid-endpoint paths - // would leak probe capacity. - if (IsFailoverTriggering(error_code) || error_code == "ClientError") + if (IsFailoverTriggering(error_code)) { ReportResult(allowed_breaker_idx, /*success=*/false); } diff --git a/core/src/http/HttpClient.cpp b/core/src/http/HttpClient.cpp index d6eeda2685..40fb40fa89 100644 --- a/core/src/http/HttpClient.cpp +++ b/core/src/http/HttpClient.cpp @@ -410,6 +410,10 @@ void HttpClient::AsyncReqHandler() if (!m_pendingReqs.empty()) { + // Collect failed requests; clean them up after the loop + // to avoid modifying m_pendingReqs during iteration. + std::vector failed_reqs; + for (const auto& async_req : m_pendingReqs) { err = curl_multi_add_handle(m_curlm, async_req->curl_handle); @@ -418,10 +422,21 @@ void HttpClient::AsyncReqHandler() std::cerr << "curl_multi_add_handle:" << curl_multi_strerror(err) << std::endl; async_req->completion_handler( HttpResponseOutcome(Core::Error("ClientError", curl_multi_strerror(err)))); + failed_reqs.push_back(async_req); } } m_pendingReqs.clear(); + // Release resources for requests that failed to add. + // Their curl handles were never added to m_curlm, so + // curl_multi_remove_handle is not needed. + for (auto* ctx : failed_reqs) + { + curl_slist_free_all(ctx->curl_header_buffer); + curl_easy_cleanup(ctx->curl_handle); + delete ctx; + } + int running_handles = 1; err = curl_multi_perform(m_curlm, &running_handles); if (err) @@ -548,7 +563,7 @@ size_t HttpClient::CurlReadHeader(char* ptr, size_t size, size_t nitems, void* u if (colon_pos == std::string::npos) { // invalid format - return nitems * size;; + return nitems * size; } const auto key = line.substr(0, colon_pos); diff --git a/example/cvm/v20170312/CMakeLists.txt b/example/cvm/v20170312/CMakeLists.txt index 82a724a86c..2886e52c5c 100644 --- a/example/cvm/v20170312/CMakeLists.txt +++ b/example/cvm/v20170312/CMakeLists.txt @@ -11,4 +11,4 @@ target_link_libraries(DescribeInstancesAsync tencentcloud-sdk-cpp-cvm tencentclo target_link_libraries(DomainFailover tencentcloud-sdk-cpp-cvm tencentcloud-sdk-cpp-core) target_include_directories(DescribeInstances PRIVATE ../../../core/include ../../../cvm/include) target_include_directories(DescribeInstancesAsync PRIVATE ../../../core/include ../../../cvm/include) -target_include_directories(DomainFailover PRIVATE ../../../core/include ../../../cvm/include) \ No newline at end of file +target_include_directories(DomainFailover PRIVATE ../../../core/include ../../../cvm/include) From 4062b09c0c614b8f8ca6a7d4fec1539ea9858222 Mon Sep 17 00:00:00 2001 From: laughingyear Date: Wed, 10 Jun 2026 15:14:27 +0800 Subject: [PATCH 3/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- .../tencentcloud/core/AbstractClient.h | 33 ++-- .../tencentcloud/core/DomainFailoverManager.h | 68 +++++--- .../tencentcloud/core/profile/ClientProfile.h | 7 +- .../core/profile/RegionBreakerProfile.h | 9 +- core/src/DomainFailoverManager.cpp | 115 +++++++++---- .../core/Core_DomainFailoverManager_Ft.cpp | 153 +++++++++++++++--- 6 files changed, 287 insertions(+), 98 deletions(-) diff --git a/core/include/tencentcloud/core/AbstractClient.h b/core/include/tencentcloud/core/AbstractClient.h index a401a22629..06d7d0801f 100644 --- a/core/include/tencentcloud/core/AbstractClient.h +++ b/core/include/tencentcloud/core/AbstractClient.h @@ -101,26 +101,31 @@ namespace TencentCloud // Region failover (domain-level circuit breaker). // - // There are 4 endpoints in the fallback chain, but only the - // first 3 need a circuit breaker -- the last one is the bottom - // fallback and never needs to "fail over" to anything: + // Two mutually exclusive failover modes: // - // breaker[0] : primary endpoint (user-supplied) - // breaker[1] : BackupEndpoint (fallback_index=0; skipped if not set) - // breaker[2] : 1st TLD fallback (fallback_index=1) - // (no breaker): 2nd TLD fallback (fallback_index=2) <- last resort + // Mode A (BackupEndpoint configured): + // breaker[0]: guards primary; if Open → descend to BackupEndpoint + // breaker[1]: (unused, GetFallbackEndpoint returns "") + // breaker[2]: (unused, GetFallbackEndpoint returns "") + // BackupEndpoint is the bottom fallback (no breaker needed). + // + // Mode B (no BackupEndpoint, default): + // breaker[0]: (skipped, GetFallbackEndpoint returns "" for index 0) + // breaker[1]: guards primary; if Open → descend to 1st TLD fallback + // breaker[2]: guards 1st TLD fallback; if Open → descend to 2nd TLD + // 2nd TLD is the bottom fallback (no breaker needed). // // TLD ring: .com → .com.cn → .cn → .com → ... - // Examples (primary = .com, no BackupEndpoint): - // breaker[0]: cvm.ap-shanghai.tencentcloudapi.com - // breaker[1]: (skipped, BackupEndpoint empty) - // breaker[2]: cvm.tencentcloudapi.com.cn - // bottom: cvm.tencentcloudapi.cn + // Example (primary = cvm.ap-shanghai.tencentcloudapi.com): + // breaker[0]: (idle, skipped -- no BackupEndpoint configured) + // breaker[1]: guards primary; Open → fall to cvm.tencentcloudapi.com.cn + // breaker[2]: guards .com.cn; Open → fall to cvm.tencentcloudapi.cn + // bottom: cvm.tencentcloudapi.cn (no breaker) // // Semantics: breaker[i] Open means "the endpoint currently // handled by this breaker is unhealthy -- the next request - // should skip past it to endpoint i+1". When all three - // breakers are Open, requests go to the bottom TLD (no breaker + // should skip past it to endpoint i+1". When all active + // breakers are Open, requests go to the bottom (no breaker // needed since there is no further fallback). // // For any single request, at most ONE breaker is Allow()ed == diff --git a/core/include/tencentcloud/core/DomainFailoverManager.h b/core/include/tencentcloud/core/DomainFailoverManager.h index d3b6688d16..a646ddeb3c 100644 --- a/core/include/tencentcloud/core/DomainFailoverManager.h +++ b/core/include/tencentcloud/core/DomainFailoverManager.h @@ -26,30 +26,43 @@ namespace TencentCloud /// Pure utility that constructs fallback endpoint candidates. /// /// Design: -/// Primary endpoint (index -1): the user-supplied endpoint. -/// Fallback sequence: -/// index 0: user-configured BackupEndpoint (e.g. ap-guangzhou.tencentcloudapi.com) -/// index 1: next TLD in ring after primary's TLD (region stripped) -/// index 2: second TLD in ring after primary's TLD (region stripped) +/// Two mutually exclusive failover modes depending on whether a +/// BackupEndpoint is configured: /// -/// TLD ring order: .com → .com.cn → .cn → .com → ... -/// Examples: -/// primary .com → index 1 = .com.cn, index 2 = .cn -/// primary .com.cn → index 1 = .cn, index 2 = .com -/// primary .cn → index 1 = .com, index 2 = .com.cn +/// Mode A (BackupEndpoint configured): +/// Primary → BackupEndpoint (sole fallback, acts as bottom) +/// No TLD fallback is performed. +/// Works regardless of whether primary has a region segment. /// -/// When constructing TLD fallback, the region segment is stripped so that -/// "cvm.ap-shanghai.tencentcloudapi.com" -> "cvm.tencentcloudapi.com.cn". -/// The ".ai" style middle segment is preserved: -/// "hunyuan.ai.ap-shanghai.tencentcloudapi.com" -> "hunyuan.ai.tencentcloudapi.com.cn". +/// Mode B (BackupEndpoint empty, default): +/// - If primary does NOT contain a region segment: +/// Primary → next TLD in ring → second TLD (bottom) +/// TLD ring order: .com → .com.cn → .cn → .com → ... +/// Examples: +/// cvm.tencentcloudapi.com → .com.cn → .cn (bottom) +/// hunyuan.ai.tencentcloudapi.com → hunyuan.ai.tencentcloudapi.com.cn → .cn +/// cvm.internal.tencentcloudapi.com → cvm.internal.tencentcloudapi.com.cn → .cn +/// +/// - If primary DOES contain a region segment: +/// No TLD fallback is performed (returns ""). +/// Rationale: TLD fallback strips the region, which may route +/// requests to a different geography, violating user intent. +/// Users who need failover for regional endpoints should +/// configure a BackupEndpoint explicitly (Mode A). +/// Examples (no fallback): +/// cvm.ap-shanghai.tencentcloudapi.com → "" (no fallback) +/// hunyuan.ai.ap-shanghai.tencentcloudapi.com → "" (no fallback) /// /// This class is stateless; state (closed/open/halfopen) lives in CircuitBreaker. class DomainFailoverManager { public: /// Number of breaker slots needed for the failover chain. - /// 3 slots: primary(breaker[0]) + BackupEndpoint(breaker[1]) + - /// first TLD fallback(breaker[2]). The last TLD in the ring is the - /// bottom fallback and has no breaker. + /// 3 slots (the maximum across both modes): + /// Mode A (BackupEndpoint set): only breaker[0] is used (primary); + /// BackupEndpoint is the bottom, no breaker needed. + /// Mode B (no BackupEndpoint): breaker[0] skipped, breaker[1] guards + /// primary, breaker[2] guards 1st TLD fallback; last TLD is bottom. + /// Unused slots are skipped via empty GetFallbackEndpoint() returns. static int GetBreakerSlotCount(); /// Build a fallback endpoint for |fallback_index| given the primary @@ -74,12 +87,21 @@ class DomainFailoverManager { static std::string ExtractService(const std::string &endpoint); /// Extract the "middle" segment between the service and the TLD. - /// TencentCloud API domains have exactly two shapes: - /// {service}[.{region}].{tld} -> returns "" - /// {service}.ai[.{region}].{tld} -> returns "ai" - /// This recognizes ONLY the literal "ai" middle; any other shape - /// returns "". Region segments are not enumerated, so new regions - /// need no change here. + /// TencentCloud API domains have these shapes: + /// {service}[.{region}].{tld} -> returns "" + /// {service}.ai[.{region}].{tld} -> returns "ai" + /// {service}.internal.{tld} -> returns "internal" + /// + /// "ai" is a product identifier -- preserved in both TLD fallback + /// and BackupEndpoint construction. + /// + /// "internal" is a network route marker (intranet resolution) -- + /// preserved in TLD fallback (staying on internal route with + /// different TLD), but stripped in BackupEndpoint construction + /// (falling back from internal to public route). + /// + /// Region segments are not enumerated, so new regions need no + /// change here. static std::string ExtractMiddleSegment(const std::string &endpoint, const std::string &tld); diff --git a/core/include/tencentcloud/core/profile/ClientProfile.h b/core/include/tencentcloud/core/profile/ClientProfile.h index 100b877b93..82f99690f1 100644 --- a/core/include/tencentcloud/core/profile/ClientProfile.h +++ b/core/include/tencentcloud/core/profile/ClientProfile.h @@ -45,7 +45,7 @@ namespace TencentCloud m_httpProfile(httpProfile), m_unsignedPayload(false), m_signMethod(signMethod), - m_disableRegionBreaker(true), + m_disableRegionBreaker(false), m_regionBreakerProfile() { } @@ -60,9 +60,8 @@ namespace TencentCloud HttpProfile GetHttpProfile() const; /// Region-level failover control. - /// Disabled by default: users must opt in explicitly by calling - /// SetDisableRegionBreaker(false). Set to false to enable - /// region failover. + /// Enabled by default. Call SetDisableRegionBreaker(true) + /// to explicitly disable region failover. void SetDisableRegionBreaker(bool disabled); bool GetDisableRegionBreaker() const; diff --git a/core/include/tencentcloud/core/profile/RegionBreakerProfile.h b/core/include/tencentcloud/core/profile/RegionBreakerProfile.h index 316c45af18..523b57cfb4 100644 --- a/core/include/tencentcloud/core/profile/RegionBreakerProfile.h +++ b/core/include/tencentcloud/core/profile/RegionBreakerProfile.h @@ -24,10 +24,11 @@ namespace TencentCloud /// Configuration for region-level circuit breaker (domain failover). /// -/// Once the primary endpoint fails enough, the SDK automatically switches to -/// |backup_endpoint|; if that also fails it falls back to backup TLDs -/// (tencentcloudapi.com.cn -> tencentcloudapi.cn), each transition driven -/// by the same circuit breaker state machine. +/// Two mutually exclusive failover modes: +/// - If |backup_endpoint| is set: Primary → BackupEndpoint (sole fallback). +/// No TLD-level failover is performed. +/// - If |backup_endpoint| is empty (default): Primary → .com.cn → .cn +/// (TLD ring fallback with region stripped). class RegionBreakerProfile { public: diff --git a/core/src/DomainFailoverManager.cpp b/core/src/DomainFailoverManager.cpp index 8657d34bfd..06a3c4dfe8 100644 --- a/core/src/DomainFailoverManager.cpp +++ b/core/src/DomainFailoverManager.cpp @@ -25,9 +25,11 @@ const std::vector DomainFailoverManager::kTldRing = { }; int DomainFailoverManager::GetBreakerSlotCount() { - // 3 breaker slots: primary(0) + BackupEndpoint(1) + 1st TLD fallback(2). - // The last TLD in the ring is the bottom fallback and has no breaker. - // Numerically: 1 + 1 + (ring_size - 2) = ring_size. + // 3 breaker slots (maximum across both modes): + // Mode A: only slot 0 is active; slots 1,2 idle. + // Mode B: slot 0 skipped; slots 1,2 active. + // The last endpoint in either mode is the bottom fallback with no + // breaker. Numerically equals kTldRing.size(). return static_cast(kTldRing.size()); } @@ -42,10 +44,21 @@ std::string DomainFailoverManager::GetFallbackEndpoint( return ""; } - // Index 0: user-configured BackupEndpoint. - if (fallback_index == 0) { - if (backup_endpoint.empty()) { - return ""; + // Two mutually exclusive modes: + // + // Mode A (BackupEndpoint configured): + // index 0 = BackupEndpoint (the only fallback, acts as bottom) + // index 1..N = "" (no TLD fallback) + // + // Mode B (BackupEndpoint empty): + // index 0 = "" (skipped) + // index 1 = next TLD in ring (region stripped) + // index 2 = second TLD in ring (region stripped, bottom) + + if (!backup_endpoint.empty()) { + // Mode A: BackupEndpoint is set -- use it as the sole fallback. + if (fallback_index != 0) { + return ""; // No further fallback beyond BackupEndpoint. } // Decide whether |backup_endpoint| is a "bare region" (e.g. // "ap-guangzhou.tencentcloudapi.com") @@ -55,8 +68,7 @@ std::string DomainFailoverManager::GetFallbackEndpoint( // We do NOT enumerate region prefixes. Instead we simply count // how many dotted segments sit in front of the TLD: // - exactly ONE segment before the TLD -> "bare region": need - // to prepend the original endpoint's service (and .ai middle - // if any). + // to prepend the original endpoint's service name. // - otherwise -> already complete: return as-is. std::string backup_tld = ExtractTld(backup_endpoint); if (backup_tld.empty()) { @@ -75,20 +87,25 @@ std::string DomainFailoverManager::GetFallbackEndpoint( if (svc.empty()) { return backup_endpoint; } - // Preserve ".ai" middle segment from the original endpoint if any. - // original="hunyuan.ai.ap-shanghai.tencentcloudapi.com" - // backup="ap-guangzhou.tencentcloudapi.com" - // -> "hunyuan.ai.ap-guangzhou.tencentcloudapi.com" - std::string tld = ExtractTld(original_endpoint); - std::string middle = ExtractMiddleSegment(original_endpoint, tld); - std::string result = svc; - if (!middle.empty()) { - result += "." + middle; - } - result += "." + backup_endpoint; + // Only prepend the service name. Middle segments (ai, internal) + // are NOT automatically carried over -- this aligns with Go SDK + // behavior where: newEndpoint = service + "." + backupEndpoint. + // If the user wants the backup to include ".ai" or ".internal", + // they should configure a complete backup domain explicitly. + std::string result = svc + "." + backup_endpoint; return result; } + // Mode B: No BackupEndpoint -- use TLD ring fallback. + if (fallback_index == 0) { + return ""; // Slot 0 is unused when no BackupEndpoint is set. + } + // Only index 1 and 2 are valid TLD fallback slots. + int ring_size_limit = static_cast(kTldRing.size()); + if (fallback_index >= ring_size_limit) { + return ""; + } + // Index 1..2: TLD fallback, with region segment stripped. // TLDs form a ring: .com → .com.cn → .cn → .com → ... // Starting from the primary's own TLD, walk the ring and pick the @@ -101,6 +118,34 @@ std::string DomainFailoverManager::GetFallbackEndpoint( return ""; } + std::string svc = ExtractService(original_endpoint); + std::string middle = ExtractMiddleSegment(original_endpoint, original_tld); + + // Check whether the original endpoint contains a region segment. + // Structure: {service}[.{middle}][.{region}].{tld} + // If after removing service, middle, and tld there is still a segment + // left, that is the region. + // When region is present and no BackupEndpoint is configured, do NOT + // perform TLD fallback -- because the fallback domain would lack the + // region segment and may route to a different geography, violating + // the user's intent. + { + // Reconstruct what the endpoint would look like without a region: + // {service}[.{middle}].{tld} + std::string without_region = svc; + if (!middle.empty()) { + without_region += "." + middle; + } + without_region += "." + original_tld; + // If the original is longer than "without_region", it has a region. + std::string lower_endpoint = ToLowerAscii(original_endpoint); + std::string lower_without = ToLowerAscii(without_region); + if (lower_endpoint != lower_without) { + // Region detected -- suppress TLD fallback. + return ""; + } + } + // Find the primary's position in the ring. int ring_size = static_cast(kTldRing.size()); int origin_pos = -1; @@ -117,11 +162,9 @@ std::string DomainFailoverManager::GetFallbackEndpoint( int target_pos = (origin_pos + tld_step) % ring_size; const std::string &target_tld = kTldRing[target_pos]; - std::string svc = ExtractService(original_endpoint); - std::string middle = ExtractMiddleSegment(original_endpoint, original_tld); - // Build: {service}[.{middle}].{target_tld} - // Region segment is intentionally dropped. + // Region segment is intentionally dropped (only reachable when + // original has no region -- verified above). std::string result = svc; if (!middle.empty()) { result += "." + middle; @@ -138,6 +181,18 @@ bool EndsWith(const std::string &s, const std::string &suffix) { s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; } +// Case-insensitive lower-case conversion (ASCII only -- sufficient for +// domain names which are restricted to ASCII per RFC 952/1123). +std::string ToLowerAscii(const std::string &s) { + std::string result = s; + for (auto &c : result) { + if (c >= 'A' && c <= 'Z') { + c = c - 'A' + 'a'; + } + } + return result; +} + // Ordered by descending length: longer TLDs must be tested first. const char *const kTencentCloudTldSuffixes[] = { ".tencentcloudapi.com.cn", @@ -149,10 +204,12 @@ const char *const kTencentCloudTldSuffixes[] = { bool DomainFailoverManager::IsTencentCloudDomain( const std::string &endpoint) { + // Domain names are case-insensitive (RFC 4343). Normalize before matching. + std::string lower = ToLowerAscii(endpoint); // Exact suffix match (with a leading '.') so that look-alikes such as // "tencentcloudapi.company.example.com" are NOT misidentified. for (const char *suffix : kTencentCloudTldSuffixes) { - if (EndsWith(endpoint, suffix)) { + if (EndsWith(lower, suffix)) { return true; } } @@ -160,10 +217,12 @@ bool DomainFailoverManager::IsTencentCloudDomain( } std::string DomainFailoverManager::ExtractTld(const std::string &endpoint) { + // Domain names are case-insensitive. Normalize before matching. + std::string lower = ToLowerAscii(endpoint); // Match the longest TLD first. Require a leading '.' so that // "tencentcloudapi.comm" (typo) is not treated as ".com". for (const char *suffix : kTencentCloudTldSuffixes) { - if (EndsWith(endpoint, suffix)) { + if (EndsWith(lower, suffix)) { // suffix starts with '.'; skip it when returning. return std::string(suffix + 1); } @@ -204,8 +263,8 @@ std::string DomainFailoverManager::ExtractMiddleSegment( return ""; } std::string second = endpoint.substr(dot1 + 1, dot2 - dot1 - 1); - if (second == "ai") { - return "ai"; + if (second == "ai" || second == "internal") { + return second; } return ""; } diff --git a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp index 5126761eda..ea1790a7c9 100644 --- a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp +++ b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp @@ -37,15 +37,21 @@ TEST(DomainFailoverManagerTest, NonTencentCloudDomainReturnsEmpty) { TEST(DomainFailoverManagerTest, OutOfRangeIndexReturnsEmpty) { string primary = "cvm.tencentcloudapi.com"; + // Negative index is always out of range. EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( primary, "ap-guangzhou.tencentcloudapi.com", -1), ""); + // With BackupEndpoint set, only index 0 is valid. EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( primary, "ap-guangzhou.tencentcloudapi.com", 3), ""); EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( primary, "ap-guangzhou.tencentcloudapi.com", 999), ""); + // Without BackupEndpoint, index 0 returns "" (skip) and index >= 3 + // is out of range. + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, "", 3), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, "", 999), ""); } // ===== Index 0: BackupEndpoint ===== @@ -68,12 +74,15 @@ TEST(DomainFailoverManagerTest, BackupEndpointPrependsServiceForNonRegionalPrima EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); } -TEST(DomainFailoverManagerTest, BackupEndpointPreservesAiMiddleSegment) { +TEST(DomainFailoverManagerTest, BackupEndpointDoesNotPreserveAiMiddle) { + // Aligns with Go SDK: only service name is prepended, middle + // segments (ai/internal) are NOT automatically carried over. + // If user wants ai in backup, they should provide a complete domain. string result = DomainFailoverManager::GetFallbackEndpoint( "hunyuan.ai.ap-shanghai.tencentcloudapi.com", "ap-guangzhou.tencentcloudapi.com", 0); - EXPECT_EQ(result, "hunyuan.ai.ap-guangzhou.tencentcloudapi.com"); + EXPECT_EQ(result, "hunyuan.ap-guangzhou.tencentcloudapi.com"); } TEST(DomainFailoverManagerTest, BackupEndpointWithCompleteDomain) { @@ -86,38 +95,40 @@ TEST(DomainFailoverManagerTest, BackupEndpointWithCompleteDomain) { } // ===== Index 1: TLD ring fallback (next TLD after primary) ===== +// TLD fallback only works when BackupEndpoint is NOT set AND primary has no region. TEST(DomainFailoverManagerTest, TldFallbackFromComGoesToComCn) { - // Ring: .com → .com.cn → .cn. Primary .com → next = .com.cn. + // Ring: .com → .com.cn → .cn. Primary .com, no region → next = .com.cn. string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.com", + "", // no backup -> TLD fallback mode 1); EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); } TEST(DomainFailoverManagerTest, TldFallbackFromComCnGoesToCn) { - // Ring: .com.cn → .cn → .com. Primary .com.cn → next = .cn. + // Ring: .com.cn → .cn → .com. Primary .com.cn, no region → next = .cn. string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com.cn", - "ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.com.cn", + "", // no backup -> TLD fallback mode 1); EXPECT_EQ(result, "cvm.tencentcloudapi.cn"); } TEST(DomainFailoverManagerTest, TldFallbackFromCnGoesToCom) { - // Ring: .cn → .com → .com.cn. Primary .cn → next = .com. + // Ring: .cn → .com → .com.cn. Primary .cn, no region → next = .com. string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.cn", - "ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.cn", + "", // no backup -> TLD fallback mode 1); EXPECT_EQ(result, "cvm.tencentcloudapi.com"); } TEST(DomainFailoverManagerTest, TldFallbackPreservesAiMiddle) { + // ai domain without region: TLD fallback should work. string result = DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.ap-shanghai.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", + "hunyuan.ai.tencentcloudapi.com", + "", // no backup -> TLD fallback mode 1); EXPECT_EQ(result, "hunyuan.ai.tencentcloudapi.com.cn"); } @@ -125,48 +136,84 @@ TEST(DomainFailoverManagerTest, TldFallbackPreservesAiMiddle) { TEST(DomainFailoverManagerTest, TldFallbackFromNonRegionalPrimary) { string result = DomainFailoverManager::GetFallbackEndpoint( "cvm.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", + "", // no backup -> TLD fallback mode 1); EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); } +TEST(DomainFailoverManagerTest, TldFallbackSuppressedWhenRegionPresent) { + // Primary has a region segment → no TLD fallback (would change geography). + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com", "", 1), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com", "", 2), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.com.cn", "", 1), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + "cvm.ap-shanghai.tencentcloudapi.cn", "", 1), ""); + // ai + region → also suppressed. + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + "hunyuan.ai.ap-shanghai.tencentcloudapi.com", "", 1), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( + "hunyuan.ai.ap-shanghai.tencentcloudapi.com", "", 2), ""); +} + // ===== Index 2: TLD ring fallback (second TLD after primary) ===== TEST(DomainFailoverManagerTest, TldFallback2FromComGoesToCn) { - // Ring: .com → .com.cn → .cn. Primary .com → second = .cn. + // Ring: .com → .com.cn → .cn. Primary .com, no region → second = .cn. string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.com", + "", // no backup -> TLD fallback mode 2); EXPECT_EQ(result, "cvm.tencentcloudapi.cn"); } TEST(DomainFailoverManagerTest, TldFallback2FromComCnGoesToCom) { - // Ring: .com.cn → .cn → .com. Primary .com.cn → second = .com. + // Ring: .com.cn → .cn → .com. Primary .com.cn, no region → second = .com. string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com.cn", - "ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.com.cn", + "", // no backup -> TLD fallback mode 2); EXPECT_EQ(result, "cvm.tencentcloudapi.com"); } TEST(DomainFailoverManagerTest, TldFallback2FromCnGoesToComCn) { - // Ring: .cn → .com → .com.cn. Primary .cn → second = .com.cn. + // Ring: .cn → .com → .com.cn. Primary .cn, no region → second = .com.cn. string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.cn", - "ap-guangzhou.tencentcloudapi.com", + "cvm.tencentcloudapi.cn", + "", // no backup -> TLD fallback mode 2); EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); } TEST(DomainFailoverManagerTest, TldFallback2PreservesAiMiddle) { + // ai domain without region: TLD fallback should work. string result = DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.ap-shanghai.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", + "hunyuan.ai.tencentcloudapi.com", + "", // no backup -> TLD fallback mode 2); EXPECT_EQ(result, "hunyuan.ai.tencentcloudapi.cn"); } +// ===== Mutual exclusion: BackupEndpoint set → no TLD fallback ===== + +TEST(DomainFailoverManagerTest, BackupEndpointSetBlocksTldFallback) { + // When BackupEndpoint is configured, index 1 and 2 must return "" + // (no TLD fallback). + string primary = "cvm.ap-shanghai.tencentcloudapi.com"; + string backup = "ap-guangzhou.tencentcloudapi.com"; + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 1), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 2), ""); +} + +TEST(DomainFailoverManagerTest, BackupEndpointSetBlocksTldFallbackAiDomain) { + string primary = "hunyuan.ai.ap-shanghai.tencentcloudapi.com"; + string backup = "ap-guangzhou.tencentcloudapi.com"; + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 1), ""); + EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 2), ""); +} + // ===== Helper methods ===== TEST(DomainFailoverManagerTest, IsTencentCloudDomain) { @@ -182,6 +229,20 @@ TEST(DomainFailoverManagerTest, IsTencentCloudDomain) { EXPECT_FALSE(DomainFailoverManager::IsTencentCloudDomain("faketencentcloudapi.com")); } +TEST(DomainFailoverManagerTest, IsTencentCloudDomainCaseInsensitive) { + // RFC 4343: domain names are case-insensitive. + EXPECT_TRUE(DomainFailoverManager::IsTencentCloudDomain("CVM.TencentCloudApi.COM")); + EXPECT_TRUE(DomainFailoverManager::IsTencentCloudDomain("CVM.TENCENTCLOUDAPI.COM.CN")); + EXPECT_TRUE(DomainFailoverManager::IsTencentCloudDomain("cvm.TencentCloudApi.Cn")); +} + +TEST(DomainFailoverManagerTest, ExtractTldCaseInsensitive) { + EXPECT_EQ(DomainFailoverManager::ExtractTld("CVM.TencentCloudApi.COM"), + "tencentcloudapi.com"); + EXPECT_EQ(DomainFailoverManager::ExtractTld("cvm.TENCENTCLOUDAPI.COM.CN"), + "tencentcloudapi.com.cn"); +} + TEST(DomainFailoverManagerTest, ExtractTld) { EXPECT_EQ(DomainFailoverManager::ExtractTld("cvm.tencentcloudapi.com"), "tencentcloudapi.com"); @@ -216,3 +277,45 @@ TEST(DomainFailoverManagerTest, ExtractMiddleSegmentForAiDomains) { "cvm.ap-guangzhou.tencentcloudapi.com", "tencentcloudapi.com"), ""); } + +TEST(DomainFailoverManagerTest, ExtractMiddleSegmentForInternalDomains) { + // "internal" is recognized as a middle segment (network route marker). + EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( + "cvm.internal.tencentcloudapi.com", "tencentcloudapi.com"), + "internal"); + EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( + "cvm.internal.ap-shanghai.tencentcloudapi.com", + "tencentcloudapi.com"), + "internal"); +} + +TEST(DomainFailoverManagerTest, TldFallbackPreservesInternalMiddle) { + // TLD 降级时 internal 保留(内网线路间切换 TLD) + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.internal.tencentcloudapi.com", + "", // no backup -> TLD fallback mode + 1); + EXPECT_EQ(result, "cvm.internal.tencentcloudapi.com.cn"); + + result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.internal.tencentcloudapi.com", + "", + 2); + EXPECT_EQ(result, "cvm.internal.tencentcloudapi.cn"); +} + +TEST(DomainFailoverManagerTest, BackupEndpointStripsInternalMiddle) { + // internal 域名 + 裸地域 backup → 只拼 service(不带 internal) + string result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.internal.tencentcloudapi.com", + "ap-guangzhou.tencentcloudapi.com", + 0); + EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); + + // 完整域名作为 backup 时直接返回 + result = DomainFailoverManager::GetFallbackEndpoint( + "cvm.internal.tencentcloudapi.com", + "cvm.tencentcloudapi.com", + 0); + EXPECT_EQ(result, "cvm.tencentcloudapi.com"); +} From 76686700d2b455e09c9b975743043463071d0486 Mon Sep 17 00:00:00 2001 From: laughingyear Date: Thu, 11 Jun 2026 14:16:46 +0800 Subject: [PATCH 4/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- core/src/DomainFailoverManager.cpp | 78 ++++++++++--------- .../core/Cbs_DescribeDisks_SetCA_Ft.cpp | 4 + .../core/Core_Http_ResolveIp_Ft.cpp | 23 +++++- .../core/Core_Profile_DomainFailover_Ft.cpp | 12 +-- 4 files changed, 71 insertions(+), 46 deletions(-) diff --git a/core/src/DomainFailoverManager.cpp b/core/src/DomainFailoverManager.cpp index 06a3c4dfe8..661710fe0a 100644 --- a/core/src/DomainFailoverManager.cpp +++ b/core/src/DomainFailoverManager.cpp @@ -24,6 +24,35 @@ const std::vector DomainFailoverManager::kTldRing = { "tencentcloudapi.cn", }; +namespace { + +// True iff |s| ends with |suffix|. +bool EndsWith(const std::string &s, const std::string &suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// Case-insensitive lower-case conversion (ASCII only -- sufficient for +// domain names which are restricted to ASCII per RFC 952/1123). +std::string ToLowerAscii(const std::string &s) { + std::string result = s; + for (auto &c : result) { + if (c >= 'A' && c <= 'Z') { + c = c - 'A' + 'a'; + } + } + return result; +} + +// Ordered by descending length: longer TLDs must be tested first. +const char *const kTencentCloudTldSuffixes[] = { + ".tencentcloudapi.com.cn", + ".tencentcloudapi.com", + ".tencentcloudapi.cn", +}; + +} // namespace + int DomainFailoverManager::GetBreakerSlotCount() { // 3 breaker slots (maximum across both modes): // Mode A: only slot 0 is active; slots 1,2 idle. @@ -63,13 +92,15 @@ std::string DomainFailoverManager::GetFallbackEndpoint( // Decide whether |backup_endpoint| is a "bare region" (e.g. // "ap-guangzhou.tencentcloudapi.com") // or a complete endpoint with its own service prefix (e.g. - // "cvm.ap-guangzhou.tencentcloudapi.com"). + // "cvm.ap-guangzhou.tencentcloudapi.com" or + // "cvm.tencentcloudapi.com"). // - // We do NOT enumerate region prefixes. Instead we simply count - // how many dotted segments sit in front of the TLD: - // - exactly ONE segment before the TLD -> "bare region": need - // to prepend the original endpoint's service name. - // - otherwise -> already complete: return as-is. + // Heuristic: if exactly ONE segment sits before the TLD, it could + // be either a region ("ap-guangzhou") or a service name ("cvm"). + // We disambiguate by comparing with the primary's service name: + // if prefix == primary's service, the backup already has the right + // service and should be returned as-is; otherwise it is a bare + // region and needs the service prepended. std::string backup_tld = ExtractTld(backup_endpoint); if (backup_tld.empty()) { // Backup is not a TencentCloud domain at all -- return as-is. @@ -78,12 +109,14 @@ std::string DomainFailoverManager::GetFallbackEndpoint( // Prefix (the part before ".{tld}"). std::string prefix = backup_endpoint.substr(0, backup_endpoint.size() - backup_tld.size() - 1); - bool bare_region = (prefix.find('.') == std::string::npos); + std::string svc = ExtractService(original_endpoint); + // "bare region" = single segment AND that segment is NOT the same + // as the primary's service name. + bool bare_region = (prefix.find('.') == std::string::npos) && (prefix != svc); if (!bare_region) { return backup_endpoint; } - std::string svc = ExtractService(original_endpoint); if (svc.empty()) { return backup_endpoint; } @@ -173,35 +206,6 @@ std::string DomainFailoverManager::GetFallbackEndpoint( return result; } -namespace { - -// True iff |s| ends with |suffix|. -bool EndsWith(const std::string &s, const std::string &suffix) { - return s.size() >= suffix.size() && - s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; -} - -// Case-insensitive lower-case conversion (ASCII only -- sufficient for -// domain names which are restricted to ASCII per RFC 952/1123). -std::string ToLowerAscii(const std::string &s) { - std::string result = s; - for (auto &c : result) { - if (c >= 'A' && c <= 'Z') { - c = c - 'A' + 'a'; - } - } - return result; -} - -// Ordered by descending length: longer TLDs must be tested first. -const char *const kTencentCloudTldSuffixes[] = { - ".tencentcloudapi.com.cn", - ".tencentcloudapi.com", - ".tencentcloudapi.cn", -}; - -} // namespace - bool DomainFailoverManager::IsTencentCloudDomain( const std::string &endpoint) { // Domain names are case-insensitive (RFC 4343). Normalize before matching. diff --git a/test/function_test/core/Cbs_DescribeDisks_SetCA_Ft.cpp b/test/function_test/core/Cbs_DescribeDisks_SetCA_Ft.cpp index fd3f3102fd..dd2cfc8e96 100644 --- a/test/function_test/core/Cbs_DescribeDisks_SetCA_Ft.cpp +++ b/test/function_test/core/Cbs_DescribeDisks_SetCA_Ft.cpp @@ -171,6 +171,10 @@ httpProfile.SetEndpoint("cbs.ap-guangzhou.tencentcloudapi.com"); httpProfile.SetReqTimeout(5); httpProfile.SetCaPath("/tmp/nodir/"); + // Also override CAINFO so that libcurl's compile-time default + // CA bundle (if any) is disabled. Both CA sources must be + // invalid for the SSL handshake to reliably fail. + httpProfile.SetCaInfo("/tmp/noexist.pem"); ClientProfile clientProfile = ClientProfile(httpProfile); diff --git a/test/function_test/core/Core_Http_ResolveIp_Ft.cpp b/test/function_test/core/Core_Http_ResolveIp_Ft.cpp index 8f901a6933..76efc91c20 100644 --- a/test/function_test/core/Core_Http_ResolveIp_Ft.cpp +++ b/test/function_test/core/Core_Http_ResolveIp_Ft.cpp @@ -23,12 +23,18 @@ namespace string secretId = CUtils::GetEnv("TENCENTCLOUD_SECRET_ID"); string secretKey = CUtils::GetEnv("TENCENTCLOUD_SECRET_KEY"); + string resolveIp = CUtils::GetEnv("TENCENTCLOUD_RESOLVE_IP"); Credential cred = Credential(secretId, secretKey); HttpProfile httpProfile = HttpProfile(); httpProfile.SetEndpoint("cvm.ap-guangzhou.tencentcloudapi.com"); httpProfile.SetReqTimeout(10); - httpProfile.SetResolveIp("106.55.122.199"); + // Valid resolve IP must be provided via env TENCENTCLOUD_RESOLVE_IP. + // Skip the test if not set (avoid hardcoded IPs in source code). + if (resolveIp.empty()) { + GTEST_SKIP() << "TENCENTCLOUD_RESOLVE_IP not set, skipping"; + } + httpProfile.SetResolveIp(resolveIp); ClientProfile clientProfile = ClientProfile(httpProfile); @@ -57,7 +63,13 @@ namespace HttpProfile httpProfile = HttpProfile(); httpProfile.SetEndpoint("cvm.ap-guangzhou.tencentcloudapi.com"); httpProfile.SetReqTimeout(5); - httpProfile.SetResolveIp("192.168.1.1"); + // Resolve IP that is unreachable, causing connection failure. + // Provided via env to avoid hardcoded IP literals in source. + string invalidIp = CUtils::GetEnv("TENCENTCLOUD_INVALID_RESOLVE_IP"); + if (invalidIp.empty()) { + GTEST_SKIP() << "TENCENTCLOUD_INVALID_RESOLVE_IP not set, skipping"; + } + httpProfile.SetResolveIp(invalidIp); ClientProfile clientProfile = ClientProfile(httpProfile); @@ -105,7 +117,12 @@ namespace TEST(cvm, ResolveIp_ParameterPassing) { HttpProfile httpProfile; - string testIp = "106.55.122.199"; + // Read a valid IP from env to verify parameter passing without + // hardcoding IP literals in source code. + string testIp = CUtils::GetEnv("TENCENTCLOUD_RESOLVE_IP"); + if (testIp.empty()) { + GTEST_SKIP() << "TENCENTCLOUD_RESOLVE_IP not set, skipping"; + } httpProfile.SetResolveIp(testIp); EXPECT_EQ(httpProfile.GetResolveIp(), testIp); diff --git a/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp b/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp index caa999193b..69a3b8a766 100644 --- a/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp +++ b/test/function_test/core/Core_Profile_DomainFailover_Ft.cpp @@ -21,11 +21,11 @@ using namespace TencentCloud; -TEST(ClientProfileDomainFailoverTest, RegionBreakerDisabledByDefault) { - // Region failover is opt-in: users must explicitly call - // SetDisableRegionBreaker(false) to enable it. +TEST(ClientProfileDomainFailoverTest, RegionBreakerEnabledByDefault) { + // Region failover is enabled by default. Users can opt out by calling + // SetDisableRegionBreaker(true). ClientProfile cp; - EXPECT_TRUE(cp.GetDisableRegionBreaker()); + EXPECT_FALSE(cp.GetDisableRegionBreaker()); } TEST(ClientProfileDomainFailoverTest, EnableDisableRegionBreaker) { @@ -74,10 +74,10 @@ TEST(ClientProfileDomainFailoverTest, PreservedWithHttpProfileConstructor) { HttpProfile hp; hp.SetEndpoint("cvm.tencentcloudapi.com"); ClientProfile cp(hp); - EXPECT_TRUE(cp.GetDisableRegionBreaker()); + EXPECT_FALSE(cp.GetDisableRegionBreaker()); } TEST(ClientProfileDomainFailoverTest, PreservedWithSignMethodConstructor) { ClientProfile cp(ClientProfile::SignMethod::TC3_HMAC_SHA256); - EXPECT_TRUE(cp.GetDisableRegionBreaker()); + EXPECT_FALSE(cp.GetDisableRegionBreaker()); } From b333a29df9cc6b7b8328df5f27e45c46f210c8bf Mon Sep 17 00:00:00 2001 From: laughingyear Date: Mon, 15 Jun 2026 11:39:11 +0800 Subject: [PATCH 5/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- example/cvm/v20170312/DomainFailover.cpp | 62 +++++++++++++++++++----- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/example/cvm/v20170312/DomainFailover.cpp b/example/cvm/v20170312/DomainFailover.cpp index 17fcd3e984..8ea7328e8c 100644 --- a/example/cvm/v20170312/DomainFailover.cpp +++ b/example/cvm/v20170312/DomainFailover.cpp @@ -57,10 +57,11 @@ static void PrintOutcome(CvmClient::DescribeInstancesOutcome outcome) { cout << endl; } -/// Example 1: Default behavior - region failover is DISABLED by default -/// (aligned with Python SDK). The SDK uses the primary endpoint only. -void DefaultDisabled() { - cout << "=== Example 1: Default (Region Failover Disabled) ===" << endl; +/// Example 1: Default behavior - region failover is ENABLED by default. +/// The SDK uses circuit breakers to automatically fall back to alternate +/// endpoints when the primary endpoint keeps failing. +void DefaultEnabled() { + cout << "=== Example 1: Default (Region Failover Enabled) ===" << endl; const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); @@ -96,13 +97,29 @@ void EnableWithoutBackupEndpoint() { PrintOutcome(client.DescribeInstances(req)); } -/// Example 3: Custom RegionBreakerProfile (configuration only). +/// Example 3: Custom RegionBreakerProfile with multiple requests. +/// +/// Uses a fabricated sub-domain as primary (will fail with SSLError) and a +/// real BackupEndpoint. After enough failures trip the breaker (max_fail_num=3), +/// subsequent requests are routed to the BackupEndpoint. +/// +/// Expected output: +/// Requests 1~3: SSLError (primary endpoint cert mismatch) +/// Request 4+: Success (failover to backup endpoint) void EnableWithCustomProfile() { cout << "=== Example 3: Custom RegionBreakerProfile ===" << endl; + cout << " Primary endpoint: cvm.does-not-exist.tencentcloudapi.com (SSL will fail)" << endl; + cout << " Backup endpoint: ap-beijing.tencentcloudapi.com (will succeed)" << endl; + cout << endl; const char* sid = getenv("TENCENTCLOUD_SECRET_ID"); const char* skey = getenv("TENCENTCLOUD_SECRET_KEY"); - Credential cred(sid ? sid : "", skey ? skey : ""); + if (!sid || !skey) { + cout << " [SKIP] Set TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY to run this example." << endl; + cout << endl; + return; + } + Credential cred(sid, skey); RegionBreakerProfile rb( /*backup_endpoint=*/ "ap-beijing.tencentcloudapi.com", @@ -117,16 +134,39 @@ void EnableWithCustomProfile() { clientProfile.SetRegionBreakerProfile(rb); HttpProfile httpProfile; - httpProfile.SetEndpoint("cvm.ap-shanghai.tencentcloudapi.com"); + httpProfile.SetEndpoint("cvm.does-not-exist.tencentcloudapi.com"); + httpProfile.SetConnectTimeout(3); httpProfile.SetReqTimeout(5); clientProfile.SetHttpProfile(httpProfile); - CvmClient client(cred, "ap-shanghai", clientProfile); + CvmClient client(cred, "ap-guangzhou", clientProfile); DescribeInstancesRequest req; req.SetOffset(0); - req.SetLimit(5); - PrintOutcome(client.DescribeInstances(req)); + req.SetLimit(1); + + const int kTotalRequests = 6; + int fail_count = 0; + int success_count = 0; + + for (int i = 1; i <= kTotalRequests; ++i) { + cout << " Request #" << i << ": "; + auto outcome = client.DescribeInstances(req); + if (!outcome.IsSuccess()) { + ++fail_count; + cout << "FAILED - " << outcome.GetError().GetErrorCode() + << ": " << outcome.GetError().GetErrorMessage() << endl; + } else { + ++success_count; + cout << "SUCCESS - RequestId=" << outcome.GetResult().GetRequestId() << endl; + } + } + + cout << endl; + cout << " Summary: " << fail_count << " failed, " + << success_count << " succeeded (failover triggered after " + << fail_count << " failures)" << endl; + cout << endl; } /// Example 4: Demonstrate actual failover in action. @@ -315,7 +355,7 @@ void DemonstrateFailoverWithoutBackup() { int main() { TencentCloud::InitAPI(); - DefaultDisabled(); + DefaultEnabled(); EnableWithoutBackupEndpoint(); EnableWithCustomProfile(); DemonstrateActualFailover(); From fdfbb832f8b1b1d1590e0b4bd456c457786e38c5 Mon Sep 17 00:00:00 2001 From: laughingyear Date: Mon, 15 Jun 2026 14:15:45 +0800 Subject: [PATCH 6/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- .../tencentcloud/core/DomainFailoverManager.h | 115 ++--- core/src/CircuitBreaker.cpp | 13 +- core/src/DomainFailoverManager.cpp | 426 ++++++++++-------- .../core/Core_CircuitBreaker_Ft.cpp | 40 +- 4 files changed, 326 insertions(+), 268 deletions(-) diff --git a/core/include/tencentcloud/core/DomainFailoverManager.h b/core/include/tencentcloud/core/DomainFailoverManager.h index a646ddeb3c..d4c739cacb 100644 --- a/core/include/tencentcloud/core/DomainFailoverManager.h +++ b/core/include/tencentcloud/core/DomainFailoverManager.h @@ -30,18 +30,18 @@ namespace TencentCloud /// BackupEndpoint is configured: /// /// Mode A (BackupEndpoint configured): -/// Primary → BackupEndpoint (sole fallback, acts as bottom) +/// Primary -> BackupEndpoint (sole fallback, acts as bottom) /// No TLD fallback is performed. /// Works regardless of whether primary has a region segment. /// /// Mode B (BackupEndpoint empty, default): /// - If primary does NOT contain a region segment: -/// Primary → next TLD in ring → second TLD (bottom) -/// TLD ring order: .com → .com.cn → .cn → .com → ... +/// Primary -> next TLD in ring -> second TLD (bottom) +/// TLD ring order: .com -> .com.cn -> .cn -> .com -> ... /// Examples: -/// cvm.tencentcloudapi.com → .com.cn → .cn (bottom) -/// hunyuan.ai.tencentcloudapi.com → hunyuan.ai.tencentcloudapi.com.cn → .cn -/// cvm.internal.tencentcloudapi.com → cvm.internal.tencentcloudapi.com.cn → .cn +/// cvm.tencentcloudapi.com -> .com.cn -> .cn (bottom) +/// hunyuan.ai.tencentcloudapi.com -> hunyuan.ai.tencentcloudapi.com.cn -> .cn +/// cvm.internal.tencentcloudapi.com -> cvm.internal.tencentcloudapi.com.cn -> .cn /// /// - If primary DOES contain a region segment: /// No TLD fallback is performed (returns ""). @@ -50,65 +50,66 @@ namespace TencentCloud /// Users who need failover for regional endpoints should /// configure a BackupEndpoint explicitly (Mode A). /// Examples (no fallback): -/// cvm.ap-shanghai.tencentcloudapi.com → "" (no fallback) -/// hunyuan.ai.ap-shanghai.tencentcloudapi.com → "" (no fallback) +/// cvm.ap-shanghai.tencentcloudapi.com -> "" (no fallback) +/// hunyuan.ai.ap-shanghai.tencentcloudapi.com -> "" (no fallback) /// /// This class is stateless; state (closed/open/halfopen) lives in CircuitBreaker. -class DomainFailoverManager { - public: - /// Number of breaker slots needed for the failover chain. - /// 3 slots (the maximum across both modes): - /// Mode A (BackupEndpoint set): only breaker[0] is used (primary); - /// BackupEndpoint is the bottom, no breaker needed. - /// Mode B (no BackupEndpoint): breaker[0] skipped, breaker[1] guards - /// primary, breaker[2] guards 1st TLD fallback; last TLD is bottom. - /// Unused slots are skipped via empty GetFallbackEndpoint() returns. - static int GetBreakerSlotCount(); +class DomainFailoverManager +{ +public: + /// Number of breaker slots needed for the failover chain. + /// 3 slots (the maximum across both modes): + /// Mode A (BackupEndpoint set): only breaker[0] is used (primary); + /// BackupEndpoint is the bottom, no breaker needed. + /// Mode B (no BackupEndpoint): breaker[0] skipped, breaker[1] guards + /// primary, breaker[2] guards 1st TLD fallback; last TLD is bottom. + /// Unused slots are skipped via empty GetFallbackEndpoint() returns. + static int GetBreakerSlotCount(); - /// Build a fallback endpoint for |fallback_index| given the primary - /// |original_endpoint| and user-configured |backup_endpoint|. - /// - /// Returns empty string if |fallback_index| is out of range or if - /// |original_endpoint| is not a TencentCloud domain. - static std::string GetFallbackEndpoint(const std::string &original_endpoint, - const std::string &backup_endpoint, - int fallback_index); + /// Build a fallback endpoint for |fallback_index| given the primary + /// |original_endpoint| and user-configured |backup_endpoint|. + /// + /// Returns empty string if |fallback_index| is out of range or if + /// |original_endpoint| is not a TencentCloud domain. + static std::string GetFallbackEndpoint(const std::string &original_endpoint, + const std::string &backup_endpoint, + int fallback_index); - /// True iff |endpoint| targets tencentcloudapi.com / .com.cn / .cn . - static bool IsTencentCloudDomain(const std::string &endpoint); + /// True iff |endpoint| targets tencentcloudapi.com / .com.cn / .cn . + static bool IsTencentCloudDomain(const std::string &endpoint); - /// Extract the longest matching TLD suffix from |endpoint|, returning - /// one of "tencentcloudapi.com.cn", "tencentcloudapi.com", - /// "tencentcloudapi.cn", or "" if none match. - static std::string ExtractTld(const std::string &endpoint); + /// Extract the longest matching TLD suffix from |endpoint|, returning + /// one of "tencentcloudapi.com.cn", "tencentcloudapi.com", + /// "tencentcloudapi.cn", or "" if none match. + static std::string ExtractTld(const std::string &endpoint); - /// Extract the first segment (service name) from |endpoint|. - /// e.g. "hunyuan.ai.tencentcloudapi.com" -> "hunyuan". - static std::string ExtractService(const std::string &endpoint); + /// Extract the first segment (service name) from |endpoint|. + /// e.g. "hunyuan.ai.tencentcloudapi.com" -> "hunyuan". + static std::string ExtractService(const std::string &endpoint); - /// Extract the "middle" segment between the service and the TLD. - /// TencentCloud API domains have these shapes: - /// {service}[.{region}].{tld} -> returns "" - /// {service}.ai[.{region}].{tld} -> returns "ai" - /// {service}.internal.{tld} -> returns "internal" - /// - /// "ai" is a product identifier -- preserved in both TLD fallback - /// and BackupEndpoint construction. - /// - /// "internal" is a network route marker (intranet resolution) -- - /// preserved in TLD fallback (staying on internal route with - /// different TLD), but stripped in BackupEndpoint construction - /// (falling back from internal to public route). - /// - /// Region segments are not enumerated, so new regions need no - /// change here. - static std::string ExtractMiddleSegment(const std::string &endpoint, - const std::string &tld); + /// Extract the "middle" segment between the service and the TLD. + /// TencentCloud API domains have these shapes: + /// {service}[.{region}].{tld} -> returns "" + /// {service}.ai[.{region}].{tld} -> returns "ai" + /// {service}.internal.{tld} -> returns "internal" + /// + /// "ai" is a product identifier -- preserved in both TLD fallback + /// and BackupEndpoint construction. + /// + /// "internal" is a network route marker (intranet resolution) -- + /// preserved in TLD fallback (staying on internal route with + /// different TLD), but stripped in BackupEndpoint construction + /// (falling back from internal to public route). + /// + /// Region segments are not enumerated, so new regions need no + /// change here. + static std::string ExtractMiddleSegment(const std::string &endpoint, + const std::string &tld); - private: - /// TLD ring: .com → .com.cn → .cn → (wraps to .com). - /// Used to compute the two non-primary TLD fallback candidates. - static const std::vector kTldRing; +private: + /// TLD ring: .com -> .com.cn -> .cn -> (wraps to .com). + /// Used to compute the two non-primary TLD fallback candidates. + static const std::vector kTldRing; }; } // namespace TencentCloud diff --git a/core/src/CircuitBreaker.cpp b/core/src/CircuitBreaker.cpp index 69e5e1f385..e3853236a3 100644 --- a/core/src/CircuitBreaker.cpp +++ b/core/src/CircuitBreaker.cpp @@ -20,15 +20,6 @@ using namespace TencentCloud; -namespace -{ - // Trip condition tuning: consecutive-failure branch, independent - // of configured rate/count thresholds, to catch fast-failing - // endpoints that would otherwise not accumulate enough samples to - // trip the rate-based branch. - const int kConsecutiveFailureTripThreshold = 5; -} - CircuitBreaker::CircuitBreaker() : CircuitBreaker(RegionBreakerProfile()) { @@ -194,7 +185,7 @@ bool CircuitBreaker::ReadyToOpenLocked() const // Trip condition (two independent branches, either is sufficient): // (failures >= max_fail_num AND failures/total >= max_fail_percent) // OR - // (consecutive_failures >= kConsecutiveFailureTripThreshold) + // (consecutive_failures >= max_fail_num) if (m_total > 0) { double rate = static_cast(m_failures) / @@ -204,7 +195,7 @@ bool CircuitBreaker::ReadyToOpenLocked() const return true; } } - if (m_consecutiveFailures >= kConsecutiveFailureTripThreshold) + if (m_consecutiveFailures >= m_maxFailNum) { return true; } diff --git a/core/src/DomainFailoverManager.cpp b/core/src/DomainFailoverManager.cpp index 661710fe0a..f9afe1ea55 100644 --- a/core/src/DomainFailoverManager.cpp +++ b/core/src/DomainFailoverManager.cpp @@ -24,24 +24,29 @@ const std::vector DomainFailoverManager::kTldRing = { "tencentcloudapi.cn", }; -namespace { +namespace +{ // True iff |s| ends with |suffix|. -bool EndsWith(const std::string &s, const std::string &suffix) { - return s.size() >= suffix.size() && - s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +bool EndsWith(const std::string &s, const std::string &suffix) +{ + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; } // Case-insensitive lower-case conversion (ASCII only -- sufficient for // domain names which are restricted to ASCII per RFC 952/1123). -std::string ToLowerAscii(const std::string &s) { - std::string result = s; - for (auto &c : result) { - if (c >= 'A' && c <= 'Z') { - c = c - 'A' + 'a'; +std::string ToLowerAscii(const std::string &s) +{ + std::string result = s; + for (auto &c : result) + { + if (c >= 'A' && c <= 'Z') + { + c = c - 'A' + 'a'; + } } - } - return result; + return result; } // Ordered by descending length: longer TLDs must be tested first. @@ -53,222 +58,253 @@ const char *const kTencentCloudTldSuffixes[] = { } // namespace -int DomainFailoverManager::GetBreakerSlotCount() { - // 3 breaker slots (maximum across both modes): - // Mode A: only slot 0 is active; slots 1,2 idle. - // Mode B: slot 0 skipped; slots 1,2 active. - // The last endpoint in either mode is the bottom fallback with no - // breaker. Numerically equals kTldRing.size(). - return static_cast(kTldRing.size()); +int DomainFailoverManager::GetBreakerSlotCount() +{ + // 3 breaker slots (maximum across both modes): + // Mode A: only slot 0 is active; slots 1,2 idle. + // Mode B: slot 0 skipped; slots 1,2 active. + // The last endpoint in either mode is the bottom fallback with no + // breaker. Numerically equals kTldRing.size(). + return static_cast(kTldRing.size()); } std::string DomainFailoverManager::GetFallbackEndpoint( const std::string &original_endpoint, const std::string &backup_endpoint, - int fallback_index) { - if (!IsTencentCloudDomain(original_endpoint)) { - return ""; - } - if (fallback_index < 0) { - return ""; - } + int fallback_index) +{ + if (!IsTencentCloudDomain(original_endpoint)) + { + return ""; + } + if (fallback_index < 0) + { + return ""; + } + + // Two mutually exclusive modes: + // + // Mode A (BackupEndpoint configured): + // index 0 = BackupEndpoint (the only fallback, acts as bottom) + // index 1..N = "" (no TLD fallback) + // + // Mode B (BackupEndpoint empty): + // index 0 = "" (skipped) + // index 1 = next TLD in ring (region stripped) + // index 2 = second TLD in ring (region stripped, bottom) - // Two mutually exclusive modes: - // - // Mode A (BackupEndpoint configured): - // index 0 = BackupEndpoint (the only fallback, acts as bottom) - // index 1..N = "" (no TLD fallback) - // - // Mode B (BackupEndpoint empty): - // index 0 = "" (skipped) - // index 1 = next TLD in ring (region stripped) - // index 2 = second TLD in ring (region stripped, bottom) + if (!backup_endpoint.empty()) + { + // Mode A: BackupEndpoint is set -- use it as the sole fallback. + if (fallback_index != 0) + { + return ""; // No further fallback beyond BackupEndpoint. + } + // Decide whether |backup_endpoint| is a "bare region" (e.g. + // "ap-guangzhou.tencentcloudapi.com") + // or a complete endpoint with its own service prefix (e.g. + // "cvm.ap-guangzhou.tencentcloudapi.com" or + // "cvm.tencentcloudapi.com"). + // + // Heuristic: if exactly ONE segment sits before the TLD, it could + // be either a region ("ap-guangzhou") or a service name ("cvm"). + // We disambiguate by comparing with the primary's service name: + // if prefix == primary's service, the backup already has the right + // service and should be returned as-is; otherwise it is a bare + // region and needs the service prepended. + std::string backup_tld = ExtractTld(backup_endpoint); + if (backup_tld.empty()) + { + // Backup is not a TencentCloud domain at all -- return as-is. + return backup_endpoint; + } + // Prefix (the part before ".{tld}"). + std::string prefix = + backup_endpoint.substr(0, backup_endpoint.size() - backup_tld.size() - 1); + std::string svc = ExtractService(original_endpoint); + // "bare region" = single segment AND that segment is NOT the same + // as the primary's service name. + bool bare_region = (prefix.find('.') == std::string::npos) && (prefix != svc); + if (!bare_region) + { + return backup_endpoint; + } - if (!backup_endpoint.empty()) { - // Mode A: BackupEndpoint is set -- use it as the sole fallback. - if (fallback_index != 0) { - return ""; // No further fallback beyond BackupEndpoint. + if (svc.empty()) + { + return backup_endpoint; + } + // Only prepend the service name. Middle segments (ai, internal) + // are NOT automatically carried over -- this aligns with Go SDK + // behavior where: newEndpoint = service + "." + backupEndpoint. + // If the user wants the backup to include ".ai" or ".internal", + // they should configure a complete backup domain explicitly. + std::string result = svc + "." + backup_endpoint; + return result; } - // Decide whether |backup_endpoint| is a "bare region" (e.g. - // "ap-guangzhou.tencentcloudapi.com") - // or a complete endpoint with its own service prefix (e.g. - // "cvm.ap-guangzhou.tencentcloudapi.com" or - // "cvm.tencentcloudapi.com"). - // - // Heuristic: if exactly ONE segment sits before the TLD, it could - // be either a region ("ap-guangzhou") or a service name ("cvm"). - // We disambiguate by comparing with the primary's service name: - // if prefix == primary's service, the backup already has the right - // service and should be returned as-is; otherwise it is a bare - // region and needs the service prepended. - std::string backup_tld = ExtractTld(backup_endpoint); - if (backup_tld.empty()) { - // Backup is not a TencentCloud domain at all -- return as-is. - return backup_endpoint; + + // Mode B: No BackupEndpoint -- use TLD ring fallback. + if (fallback_index == 0) + { + return ""; // Slot 0 is unused when no BackupEndpoint is set. } - // Prefix (the part before ".{tld}"). - std::string prefix = - backup_endpoint.substr(0, backup_endpoint.size() - backup_tld.size() - 1); - std::string svc = ExtractService(original_endpoint); - // "bare region" = single segment AND that segment is NOT the same - // as the primary's service name. - bool bare_region = (prefix.find('.') == std::string::npos) && (prefix != svc); - if (!bare_region) { - return backup_endpoint; + // Only index 1 and 2 are valid TLD fallback slots. + int ring_size_limit = static_cast(kTldRing.size()); + if (fallback_index >= ring_size_limit) + { + return ""; } - if (svc.empty()) { - return backup_endpoint; + // Index 1..2: TLD fallback, with region segment stripped. + // TLDs form a ring: .com -> .com.cn -> .cn -> .com -> ... + // Starting from the primary's own TLD, walk the ring and pick the + // next two distinct TLDs. fallback_index 1 gets the first, 2 the + // second. This guarantees no TLD is ever repeated regardless of + // which TLD the user started with. + int tld_step = fallback_index; // 1 or 2 + std::string original_tld = ExtractTld(original_endpoint); + if (original_tld.empty()) + { + return ""; } - // Only prepend the service name. Middle segments (ai, internal) - // are NOT automatically carried over -- this aligns with Go SDK - // behavior where: newEndpoint = service + "." + backupEndpoint. - // If the user wants the backup to include ".ai" or ".internal", - // they should configure a complete backup domain explicitly. - std::string result = svc + "." + backup_endpoint; - return result; - } - // Mode B: No BackupEndpoint -- use TLD ring fallback. - if (fallback_index == 0) { - return ""; // Slot 0 is unused when no BackupEndpoint is set. - } - // Only index 1 and 2 are valid TLD fallback slots. - int ring_size_limit = static_cast(kTldRing.size()); - if (fallback_index >= ring_size_limit) { - return ""; - } - - // Index 1..2: TLD fallback, with region segment stripped. - // TLDs form a ring: .com → .com.cn → .cn → .com → ... - // Starting from the primary's own TLD, walk the ring and pick the - // next two distinct TLDs. fallback_index 1 gets the first, 2 the - // second. This guarantees no TLD is ever repeated regardless of - // which TLD the user started with. - int tld_step = fallback_index; // 1 or 2 - std::string original_tld = ExtractTld(original_endpoint); - if (original_tld.empty()) { - return ""; - } - - std::string svc = ExtractService(original_endpoint); - std::string middle = ExtractMiddleSegment(original_endpoint, original_tld); + std::string svc = ExtractService(original_endpoint); + std::string middle = ExtractMiddleSegment(original_endpoint, original_tld); - // Check whether the original endpoint contains a region segment. - // Structure: {service}[.{middle}][.{region}].{tld} - // If after removing service, middle, and tld there is still a segment - // left, that is the region. - // When region is present and no BackupEndpoint is configured, do NOT - // perform TLD fallback -- because the fallback domain would lack the - // region segment and may route to a different geography, violating - // the user's intent. - { - // Reconstruct what the endpoint would look like without a region: - // {service}[.{middle}].{tld} - std::string without_region = svc; - if (!middle.empty()) { - without_region += "." + middle; + // Check whether the original endpoint contains a region segment. + // Structure: {service}[.{middle}][.{region}].{tld} + // If after removing service, middle, and tld there is still a segment + // left, that is the region. + // When region is present and no BackupEndpoint is configured, do NOT + // perform TLD fallback -- because the fallback domain would lack the + // region segment and may route to a different geography, violating + // the user's intent. + { + // Reconstruct what the endpoint would look like without a region: + // {service}[.{middle}].{tld} + std::string without_region = svc; + if (!middle.empty()) + { + without_region += "." + middle; + } + without_region += "." + original_tld; + // If the original is longer than "without_region", it has a region. + std::string lower_endpoint = ToLowerAscii(original_endpoint); + std::string lower_without = ToLowerAscii(without_region); + if (lower_endpoint != lower_without) + { + // Region detected -- suppress TLD fallback. + return ""; + } } - without_region += "." + original_tld; - // If the original is longer than "without_region", it has a region. - std::string lower_endpoint = ToLowerAscii(original_endpoint); - std::string lower_without = ToLowerAscii(without_region); - if (lower_endpoint != lower_without) { - // Region detected -- suppress TLD fallback. - return ""; - } - } - // Find the primary's position in the ring. - int ring_size = static_cast(kTldRing.size()); - int origin_pos = -1; - for (int i = 0; i < ring_size; ++i) { - if (kTldRing[i] == original_tld) { - origin_pos = i; - break; + // Find the primary's position in the ring. + int ring_size = static_cast(kTldRing.size()); + int origin_pos = -1; + for (int i = 0; i < ring_size; ++i) + { + if (kTldRing[i] == original_tld) + { + origin_pos = i; + break; + } + } + if (origin_pos < 0) + { + return ""; // Unknown TLD, should not happen for a valid TC domain. } - } - if (origin_pos < 0) { - return ""; // Unknown TLD, should not happen for a valid TC domain. - } - int target_pos = (origin_pos + tld_step) % ring_size; - const std::string &target_tld = kTldRing[target_pos]; + int target_pos = (origin_pos + tld_step) % ring_size; + const std::string &target_tld = kTldRing[target_pos]; - // Build: {service}[.{middle}].{target_tld} - // Region segment is intentionally dropped (only reachable when - // original has no region -- verified above). - std::string result = svc; - if (!middle.empty()) { - result += "." + middle; - } - result += "." + target_tld; - return result; + // Build: {service}[.{middle}].{target_tld} + // Region segment is intentionally dropped (only reachable when + // original has no region -- verified above). + std::string result = svc; + if (!middle.empty()) + { + result += "." + middle; + } + result += "." + target_tld; + return result; } bool DomainFailoverManager::IsTencentCloudDomain( - const std::string &endpoint) { - // Domain names are case-insensitive (RFC 4343). Normalize before matching. - std::string lower = ToLowerAscii(endpoint); - // Exact suffix match (with a leading '.') so that look-alikes such as - // "tencentcloudapi.company.example.com" are NOT misidentified. - for (const char *suffix : kTencentCloudTldSuffixes) { - if (EndsWith(lower, suffix)) { - return true; + const std::string &endpoint) +{ + // Domain names are case-insensitive (RFC 4343). Normalize before matching. + std::string lower = ToLowerAscii(endpoint); + // Exact suffix match (with a leading '.') so that look-alikes such as + // "tencentcloudapi.company.example.com" are NOT misidentified. + for (const char *suffix : kTencentCloudTldSuffixes) + { + if (EndsWith(lower, suffix)) + { + return true; + } } - } - return false; + return false; } -std::string DomainFailoverManager::ExtractTld(const std::string &endpoint) { - // Domain names are case-insensitive. Normalize before matching. - std::string lower = ToLowerAscii(endpoint); - // Match the longest TLD first. Require a leading '.' so that - // "tencentcloudapi.comm" (typo) is not treated as ".com". - for (const char *suffix : kTencentCloudTldSuffixes) { - if (EndsWith(lower, suffix)) { - // suffix starts with '.'; skip it when returning. - return std::string(suffix + 1); +std::string DomainFailoverManager::ExtractTld(const std::string &endpoint) +{ + // Domain names are case-insensitive. Normalize before matching. + std::string lower = ToLowerAscii(endpoint); + // Match the longest TLD first. Require a leading '.' so that + // "tencentcloudapi.comm" (typo) is not treated as ".com". + for (const char *suffix : kTencentCloudTldSuffixes) + { + if (EndsWith(lower, suffix)) + { + // suffix starts with '.'; skip it when returning. + return std::string(suffix + 1); + } } - } - return ""; + return ""; } std::string DomainFailoverManager::ExtractService( - const std::string &endpoint) { - auto pos = endpoint.find('.'); - if (pos == std::string::npos) { - return endpoint; - } - return endpoint.substr(0, pos); + const std::string &endpoint) +{ + auto pos = endpoint.find('.'); + if (pos == std::string::npos) + { + return endpoint; + } + return endpoint.substr(0, pos); } std::string DomainFailoverManager::ExtractMiddleSegment( const std::string &endpoint, - const std::string &tld) { - // Tencent Cloud API domains come in exactly two shapes: - // 1) {service}[.{region}].{tld} -- no middle segment - // 2) {service}.ai[.{region}].{tld} -- middle == "ai" - // So the "middle" is determined solely by whether the SECOND dotted - // segment equals the literal "ai". No region-prefix enumeration is - // needed, which means new regions (me-/cn-/...) require no changes. - auto dot1 = endpoint.find('.'); - if (dot1 == std::string::npos) { - return ""; - } - auto dot2 = endpoint.find('.', dot1 + 1); - if (dot2 == std::string::npos) { - return ""; - } - // The second segment must lie strictly before the TLD to be a - // middle (otherwise it IS the start of the TLD). - auto tld_pos = endpoint.rfind("." + tld); - if (tld_pos == std::string::npos || tld_pos < dot2) { + const std::string &tld) +{ + // Tencent Cloud API domains come in exactly two shapes: + // 1) {service}[.{region}].{tld} -- no middle segment + // 2) {service}.ai[.{region}].{tld} -- middle == "ai" + // So the "middle" is determined solely by whether the SECOND dotted + // segment equals the literal "ai". No region-prefix enumeration is + // needed, which means new regions (me-/cn-/...) require no changes. + auto dot1 = endpoint.find('.'); + if (dot1 == std::string::npos) + { + return ""; + } + auto dot2 = endpoint.find('.', dot1 + 1); + if (dot2 == std::string::npos) + { + return ""; + } + // The second segment must lie strictly before the TLD to be a + // middle (otherwise it IS the start of the TLD). + auto tld_pos = endpoint.rfind("." + tld); + if (tld_pos == std::string::npos || tld_pos < dot2) + { + return ""; + } + std::string second = endpoint.substr(dot1 + 1, dot2 - dot1 - 1); + if (second == "ai" || second == "internal") + { + return second; + } return ""; - } - std::string second = endpoint.substr(dot1 + 1, dot2 - dot1 - 1); - if (second == "ai" || second == "internal") { - return second; - } - return ""; } diff --git a/test/function_test/core/Core_CircuitBreaker_Ft.cpp b/test/function_test/core/Core_CircuitBreaker_Ft.cpp index 925fda7c7a..ea05dfd986 100644 --- a/test/function_test/core/Core_CircuitBreaker_Ft.cpp +++ b/test/function_test/core/Core_CircuitBreaker_Ft.cpp @@ -77,12 +77,42 @@ TEST(CircuitBreakerTest, DoesNotTripWhenCountTooLow) { EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); } -TEST(CircuitBreakerTest, TripOnConsecutiveFailuresGreaterThanFive) { - // Even with an unreachable rate threshold, >=5 consecutive - // failures still trip the breaker via the consecutive-failure - // branch. - CircuitBreaker cb(MakeProfile(100, 1.1, 300, 60, 3)); +TEST(CircuitBreakerTest, TripOnConsecutiveFailuresReachesMaxFailNum) { + // Even with an unreachable rate threshold, consecutive failures + // reaching max_fail_num still trip the breaker via the + // consecutive-failure branch. Here max_fail_num=3, so 3 consecutive + // failures are enough (no need to wait for the rate branch). + CircuitBreaker cb(MakeProfile(3, 1.1, 300, 60, 3)); + DrainN(cb, 3, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); +} + +TEST(CircuitBreakerTest, ConsecutiveFailuresUseConfiguredMaxFailNum) { + // Verify that the consecutive-failure threshold follows the user's + // max_fail_num, not a hardcoded value. With max_fail_num=7 the + // breaker should NOT trip after 5 consecutive failures (the old + // hardcoded default), but SHOULD trip after 7. + CircuitBreaker cb(MakeProfile(7, 1.1, 300, 60, 3)); DrainN(cb, 5, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kClosed); + DrainN(cb, 2, false); + EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); +} + +TEST(CircuitBreakerTest, ConsecutiveFailuresOverrideRateBranch) { + // When failures are interleaved with successes, the rate branch + // may not trigger, but consecutive failures should still work. + // max_fail_num=3, max_fail_percent=0.9 (90% failure rate needed). + CircuitBreaker cb(MakeProfile(3, 0.9, 300, 60, 3)); + // 2 failures, 1 success, 3 consecutive failures: + // total=6, failures=5, rate=83% < 90% → rate branch NOT triggered. + // But consecutive=3 >= max_fail_num=3 → consecutive branch triggers. + ASSERT_TRUE(cb.Allow()); cb.Report(false); // consec=1, total=1, fail=1 + ASSERT_TRUE(cb.Allow()); cb.Report(false); // consec=2, total=2, fail=2 + ASSERT_TRUE(cb.Allow()); cb.Report(true); // consec=0, total=3, fail=2 + ASSERT_TRUE(cb.Allow()); cb.Report(false); // consec=1, total=4, fail=3 + ASSERT_TRUE(cb.Allow()); cb.Report(false); // consec=2, total=5, fail=4 + ASSERT_TRUE(cb.Allow()); cb.Report(false); // consec=3, total=6, fail=5 EXPECT_EQ(cb.GetState(), CircuitBreaker::State::kOpen); } From e953130ad80e09f734a8a55ef090af976775f5c7 Mon Sep 17 00:00:00 2001 From: laughingyear Date: Tue, 16 Jun 2026 15:30:10 +0800 Subject: [PATCH 7/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- .../tencentcloud/core/AbstractClient.h | 158 +++--- .../tencentcloud/core/DomainFailoverManager.h | 126 ++--- core/src/AbstractClient.cpp | 178 +++---- core/src/DomainFailoverManager.cpp | 277 ++++------ example/cvm/v20170312/DomainFailover.cpp | 35 +- test/function_test/core/CMakeLists.txt | 1 + .../core/Core_AbstractClient_Breaker_Ft.cpp | 128 +++++ .../core/Core_DomainFailoverManager_Ft.cpp | 479 ++++++++---------- 8 files changed, 667 insertions(+), 715 deletions(-) create mode 100644 test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp diff --git a/core/include/tencentcloud/core/AbstractClient.h b/core/include/tencentcloud/core/AbstractClient.h index 06d7d0801f..d16c8a02b6 100644 --- a/core/include/tencentcloud/core/AbstractClient.h +++ b/core/include/tencentcloud/core/AbstractClient.h @@ -26,6 +26,7 @@ #include "AbstractModel.h" #include #include +#include #include #include @@ -42,17 +43,13 @@ namespace TencentCloud /// threads. /// /// 2. DoRequestAsync() dispatches work on HttpClient's background - /// worker; its completion callback captures `this` and touches - /// m_endpointBreakers via ReportResult(). Correctness of those - /// callbacks relies on the invariant that by the time - /// ~AbstractClient() proceeds past `delete m_httpClient`, the - /// worker has been joined and no callback is still running. - /// The destructor is written to preserve this ordering; DO - /// NOT: - /// * reorder the dtor to release breakers before deleting - /// m_httpClient; - /// * replace m_httpClient with any lifetime management that - /// lets it outlive AbstractClient. + /// worker; its completion callback captures the selected + /// breaker as a std::shared_ptr (NOT `this`). + /// The breaker therefore stays alive for the whole callback + /// regardless of AbstractClient's destruction timing, so the + /// callback never dereferences a dangling client. The + /// destructor still deletes m_httpClient first to join the + /// worker before other members are torn down. class AbstractClient { public: @@ -88,6 +85,28 @@ namespace TencentCloud template void DoRequestAsync(std::string action, Req req, ReqOpts opts, AsyncCompletionHandler handler); + // Result of picking an endpoint for one request. + struct EndpointDecision + { + std::string host; // endpoint to send to (always non-empty) + // Breaker to report the outcome to; nullptr when bypassed + // (failover disabled / non-TencentCloud) or when falling + // through to the bottom fallback (force-send, no report). + std::shared_ptr breaker; + }; + + // Get-or-create the breaker guarding |host| under |origin|. + std::shared_ptr BreakerFor(const std::string &origin, + const std::string &host); + + // Pick the endpoint for this request. See EndpointDecision. + EndpointDecision SelectEndpoint(const std::string &primary_endpoint); + + // Report the outcome to the breaker that Allow()ed this request. + // |breaker| == nullptr is a no-op (bypass / bottom fallback). + static void ReportResult(const std::shared_ptr &breaker, + bool success); + private: Credential m_credential; ClientProfile m_clientProfile; @@ -101,71 +120,27 @@ namespace TencentCloud // Region failover (domain-level circuit breaker). // - // Two mutually exclusive failover modes: - // - // Mode A (BackupEndpoint configured): - // breaker[0]: guards primary; if Open → descend to BackupEndpoint - // breaker[1]: (unused, GetFallbackEndpoint returns "") - // breaker[2]: (unused, GetFallbackEndpoint returns "") - // BackupEndpoint is the bottom fallback (no breaker needed). - // - // Mode B (no BackupEndpoint, default): - // breaker[0]: (skipped, GetFallbackEndpoint returns "" for index 0) - // breaker[1]: guards primary; if Open → descend to 1st TLD fallback - // breaker[2]: guards 1st TLD fallback; if Open → descend to 2nd TLD - // 2nd TLD is the bottom fallback (no breaker needed). - // - // TLD ring: .com → .com.cn → .cn → .com → ... - // Example (primary = cvm.ap-shanghai.tencentcloudapi.com): - // breaker[0]: (idle, skipped -- no BackupEndpoint configured) - // breaker[1]: guards primary; Open → fall to cvm.tencentcloudapi.com.cn - // breaker[2]: guards .com.cn; Open → fall to cvm.tencentcloudapi.cn - // bottom: cvm.tencentcloudapi.cn (no breaker) + // Breakers are keyed by (origin, host) in a lazily-populated + // registry. The candidate list for a request is built by + // DomainFailoverManager::BuildCandidates(); every candidate + // except the LAST is guarded by a breaker. The last candidate is + // the bottom fallback: it is force-sent without a breaker and + // without reporting (legacy behavior preserved). // - // Semantics: breaker[i] Open means "the endpoint currently - // handled by this breaker is unhealthy -- the next request - // should skip past it to endpoint i+1". When all active - // breakers are Open, requests go to the bottom (no breaker - // needed since there is no further fallback). - // - // For any single request, at most ONE breaker is Allow()ed == - // true, and that breaker is the only one that receives a - // Report() call. Breakers whose Allow() returned false are not - // reported; they recover via their own Open-to-HalfOpen - // timeout. - // - // Size == DomainFailoverManager::GetBreakerSlotCount() (== 3). - // - // Stored as raw pointers (new[]/delete[] in Init/Release) to - // preserve AbstractClient's implicit copyability -- adding - // unique_ptr would make the class non-copyable and break - // backward compatibility even though no one actually copies - // a Client instance. The ownership is clear: AbstractClient - // creates them in the ctor and destroys them in the dtor. - CircuitBreaker **m_endpointBreakers; - int m_endpointBreakerCount; + // shared_ptr is used so async completion callbacks can capture + // the selected breaker directly; the breaker then outlives the + // AbstractClient if needed, independent of destruction order. + struct BreakerRegistry + { + std::mutex mutex; // guards lazy insertion into |map| + // key = origin + "\n" + host + std::map > map; + }; + + // nullptr when region failover is disabled -> fully bypassed. + std::shared_ptr m_breakers; void InitRegionBreakers(); - void ReleaseRegionBreakers(); - - /// Pick the endpoint to use for this request. - /// |out_allowed_breaker_idx| is set to the index (0..2) of the - /// breaker that returned Allow() == true (the breaker - /// associated with the endpoint this request will use), or -1 - /// in the following cases: - /// - region failover disabled (no breaker consulted) - /// - non-TencentCloud primary endpoint (breakers bypassed) - /// - all three breakers are Open, so the request falls - /// through to the bottom TLD endpoint (the last in the - /// ring) which has no breaker to report to - /// In any of these cases, ReportResult() is a no-op. - std::string SelectEndpoint(const std::string &primary_endpoint, - int &out_allowed_breaker_idx); - - /// Report the request outcome to the single breaker that - /// Allow()ed this request. |allowed_breaker_idx| == -1 is a - /// no-op (see SelectEndpoint for when that happens). - void ReportResult(int allowed_breaker_idx, bool success); static bool IsFailoverTriggering(const std::string &error_code); @@ -189,8 +164,9 @@ void TencentCloud::AbstractClient::DoRequestAsync( } // Pick an endpoint for this request based on circuit breaker state. - int allowed_breaker_idx = -1; - std::string resolved_endpoint = SelectEndpoint(primary_endpoint, allowed_breaker_idx); + EndpointDecision decision = SelectEndpoint(primary_endpoint); + std::string resolved_endpoint = decision.host; + std::shared_ptr breaker = decision.breaker; std::string::size_type pos = resolved_endpoint.find_first_of('.'); if (pos != std::string::npos) @@ -201,7 +177,7 @@ void TencentCloud::AbstractClient::DoRequestAsync( // Endpoint is syntactically invalid and cannot be used. // Release the HalfOpen probe slot SelectEndpoint reserved, so // invalid-endpoint paths don't leak breaker probe capacity. - ReportResult(allowed_breaker_idx, /*success=*/false); + ReportResult(breaker, /*success=*/false); Core::Error err("ClientError", "endpoint `" + resolved_endpoint + "` is not valid"); handler(req, RequestOutcome(err)); return; @@ -261,32 +237,28 @@ void TencentCloud::AbstractClient::DoRequestAsync( m_httpClient->SetCaPath(http_profile.GetCaPath()); m_httpClient->SetResolveIp(http_profile.GetResolveIp()); - // Capture |allowed_breaker_idx| (a plain int) so the async - // callback can Report() via AbstractClient's method. - // - // Capturing `this` is safe because of the lifetime contract - // documented on the AbstractClient class: ~AbstractClient() deletes - // m_httpClient first, which joins the async worker and guarantees - // no callback is running before m_endpointBreakers is released. - // Breaking that contract (e.g. reordering the dtor, or sharing - // HttpClient across longer-lived owners) turns this capture into a - // dangling-this bug. + // Capture the selected breaker (a shared_ptr) so the async callback + // can Report() to it. The breaker is kept alive by the captured + // shared_ptr for the whole callback, independent of AbstractClient's + // destruction order. The callback no longer captures `this`. m_httpClient->SendRequestAsync(http_req, - [this, req, handler, allowed_breaker_idx]( + [req, handler, breaker]( HttpClient::HttpResponseOutcome http_resp) mutable { + // Unified report rule: any Allow()==true request MUST Report() + // exactly once. A non-failover error (business 4xx / throttling) + // is treated as a "success" for the endpoint, so the HalfOpen + // probe slot is released (fixes P7). |breaker| nullptr -> no-op. + bool ok = http_resp.IsSuccess() || + !IsFailoverTriggering(http_resp.GetError().GetErrorCode()); + ReportResult(breaker, ok); + if (!http_resp.IsSuccess()) { - const auto& error_code = http_resp.GetError().GetErrorCode(); - if (IsFailoverTriggering(error_code)) { - ReportResult(allowed_breaker_idx, /*success=*/false); - } handler(req, RequestOutcome(http_resp.GetError())); return; } - ReportResult(allowed_breaker_idx, /*success=*/true); - std::string http_resp_body{http_resp.GetResult().Body(), http_resp.GetResult().BodySize()}; Resp resp{}; diff --git a/core/include/tencentcloud/core/DomainFailoverManager.h b/core/include/tencentcloud/core/DomainFailoverManager.h index d4c739cacb..ec973a6b86 100644 --- a/core/include/tencentcloud/core/DomainFailoverManager.h +++ b/core/include/tencentcloud/core/DomainFailoverManager.h @@ -23,57 +23,83 @@ namespace TencentCloud { -/// Pure utility that constructs fallback endpoint candidates. +/// Pure utility that constructs fallback endpoint candidates from a +/// structured parse of the primary endpoint (see ParseEndpoint). /// -/// Design: -/// Two mutually exclusive failover modes depending on whether a -/// BackupEndpoint is configured: +/// Two mutually exclusive failover modes depending on whether a +/// BackupEndpoint is configured: /// /// Mode A (BackupEndpoint configured): -/// Primary -> BackupEndpoint (sole fallback, acts as bottom) +/// Primary -> ResolveBackupEndpoint(backup) (sole fallback / bottom). /// No TLD fallback is performed. -/// Works regardless of whether primary has a region segment. /// /// Mode B (BackupEndpoint empty, default): -/// - If primary does NOT contain a region segment: -/// Primary -> next TLD in ring -> second TLD (bottom) -/// TLD ring order: .com -> .com.cn -> .cn -> .com -> ... -/// Examples: -/// cvm.tencentcloudapi.com -> .com.cn -> .cn (bottom) -/// hunyuan.ai.tencentcloudapi.com -> hunyuan.ai.tencentcloudapi.com.cn -> .cn -/// cvm.internal.tencentcloudapi.com -> cvm.internal.tencentcloudapi.com.cn -> .cn -/// -/// - If primary DOES contain a region segment: -/// No TLD fallback is performed (returns ""). -/// Rationale: TLD fallback strips the region, which may route -/// requests to a different geography, violating user intent. -/// Users who need failover for regional endpoints should -/// configure a BackupEndpoint explicitly (Mode A). -/// Examples (no fallback): -/// cvm.ap-shanghai.tencentcloudapi.com -> "" (no fallback) -/// hunyuan.ai.ap-shanghai.tencentcloudapi.com -> "" (no fallback) +/// Primary -> next TLD in ring -> second TLD (bottom). +/// TLD ring order: .com -> .com.cn -> .cn -> .com -> ... +/// The fallback target is rebuilt from the parsed primary as +/// service + [kept-modifier] + new-tld +/// so the region segment and dropped-modifiers (intl) are removed, +/// while service and kept-modifiers (ai/internal) are preserved. +/// Region is NO LONGER suppressed: a regional primary also falls +/// back (dropping its region), trading geography drift for +/// availability. +/// Examples: +/// cvm.tencentcloudapi.com -> .com.cn -> .cn +/// hunyuan.ai.tencentcloudapi.com -> hunyuan.ai...com.cn -> .cn +/// cvm.intl.tencentcloudapi.com -> cvm...com.cn -> .cn (intl dropped) +/// cvm.ap-shanghai.tencentcloudapi.com -> cvm...com.cn -> .cn (region dropped) /// /// This class is stateless; state (closed/open/halfopen) lives in CircuitBreaker. class DomainFailoverManager { public: - /// Number of breaker slots needed for the failover chain. - /// 3 slots (the maximum across both modes): - /// Mode A (BackupEndpoint set): only breaker[0] is used (primary); - /// BackupEndpoint is the bottom, no breaker needed. - /// Mode B (no BackupEndpoint): breaker[0] skipped, breaker[1] guards - /// primary, breaker[2] guards 1st TLD fallback; last TLD is bottom. - /// Unused slots are skipped via empty GetFallbackEndpoint() returns. - static int GetBreakerSlotCount(); + /// Structured parse of a TencentCloud API endpoint. + /// Shape: {service}.[modifier?].[region...].{tld} + /// - modifier is one of the known modifiers (ai/internal/intl), + /// mutually exclusive, at most one. + /// - ORDERING CONTRACT: the modifier MUST immediately follow the + /// service (i.e. always before region). Only the first segment + /// after service is checked. Reversed input (region before + /// modifier, e.g. "cvm.ap-shanghai.ai...") is NOT supported: the + /// modifier there falls into region and is dropped. + /// - region (everything between modifier/service and tld) is NOT + /// stored: it is always dropped on fallback. + struct ParsedEndpoint + { + bool is_tc_domain = false; // false -> not a TC domain + std::string service; // first segment, e.g. "cvm" + std::string modifier; // "ai"/"internal"/"intl"/"" + bool modifier_kept = false; // keep this modifier on fallback? + std::string tld; // normalized lower-case TLD + }; + + /// Parse |endpoint| into its structured parts. If |endpoint| is not a + /// TencentCloud domain, returns a ParsedEndpoint with is_tc_domain=false. + static ParsedEndpoint ParseEndpoint(const std::string &endpoint); - /// Build a fallback endpoint for |fallback_index| given the primary - /// |original_endpoint| and user-configured |backup_endpoint|. + /// Normalize a user-configured BackupEndpoint (Mode A) using the + /// primary's service name to disambiguate the "bare region" form. + /// - non-TencentCloud domain -> returned as-is + /// - bare region (single segment != primary_service) + /// -> "{primary_service}.{backup}" + /// - complete domain (multi-segment, or single == primary_service) + /// -> returned as-is + /// Modifiers (ai/internal/intl) are never auto-prepended. + static std::string ResolveBackupEndpoint(const std::string &backup_endpoint, + const std::string &primary_service); + + /// Build the ordered candidate endpoint list for one request: + /// [primary, fallback1, fallback2, ...]. The first element is always + /// |primary|; the LAST element is the bottom fallback (the caller + /// must NOT guard it with a breaker -- it is force-sent without + /// reporting, preserving legacy behavior). /// - /// Returns empty string if |fallback_index| is out of range or if - /// |original_endpoint| is not a TencentCloud domain. - static std::string GetFallbackEndpoint(const std::string &original_endpoint, - const std::string &backup_endpoint, - int fallback_index); + /// Built on ParseEndpoint(): Mode A (BackupEndpoint set) appends the + /// normalized backup via ResolveBackupEndpoint(); Mode B (no backup) + /// walks the TLD ring, dropping region and dropped-modifiers (intl) + /// while keeping service and kept-modifiers (ai/internal). + static std::vector BuildCandidates( + const std::string &primary, const std::string &backup_endpoint); /// True iff |endpoint| targets tencentcloudapi.com / .com.cn / .cn . static bool IsTencentCloudDomain(const std::string &endpoint); @@ -83,32 +109,8 @@ class DomainFailoverManager /// "tencentcloudapi.cn", or "" if none match. static std::string ExtractTld(const std::string &endpoint); - /// Extract the first segment (service name) from |endpoint|. - /// e.g. "hunyuan.ai.tencentcloudapi.com" -> "hunyuan". - static std::string ExtractService(const std::string &endpoint); - - /// Extract the "middle" segment between the service and the TLD. - /// TencentCloud API domains have these shapes: - /// {service}[.{region}].{tld} -> returns "" - /// {service}.ai[.{region}].{tld} -> returns "ai" - /// {service}.internal.{tld} -> returns "internal" - /// - /// "ai" is a product identifier -- preserved in both TLD fallback - /// and BackupEndpoint construction. - /// - /// "internal" is a network route marker (intranet resolution) -- - /// preserved in TLD fallback (staying on internal route with - /// different TLD), but stripped in BackupEndpoint construction - /// (falling back from internal to public route). - /// - /// Region segments are not enumerated, so new regions need no - /// change here. - static std::string ExtractMiddleSegment(const std::string &endpoint, - const std::string &tld); - private: /// TLD ring: .com -> .com.cn -> .cn -> (wraps to .com). - /// Used to compute the two non-primary TLD fallback candidates. static const std::vector kTldRing; }; diff --git a/core/src/AbstractClient.cpp b/core/src/AbstractClient.cpp index ff1d7c0d02..fc2f1579ce 100644 --- a/core/src/AbstractClient.cpp +++ b/core/src/AbstractClient.cpp @@ -41,22 +41,18 @@ AbstractClient::AbstractClient(const string &endpoint, const string &version, co m_apiVersion(version), m_httpClient(new HttpClient()), m_service(""), - m_endpointBreakers(nullptr), - m_endpointBreakerCount(0) + m_breakers(nullptr) { InitRegionBreakers(); } AbstractClient::~AbstractClient() { - // Order matters: deleting m_httpClient joins its async worker, - // ensuring no DoRequestAsync callback is still running (and thus no - // callback is still holding `this` or about to touch - // m_endpointBreakers) by the time ReleaseRegionBreakers() runs. - // Do NOT reorder these two lines. See the lifetime note in - // AbstractClient.h. + // delete m_httpClient first to join its async worker. Breakers are + // held by shared_ptr (in m_breakers and possibly captured by async + // callbacks), so they release automatically and safely regardless + // of destruction timing. delete m_httpClient; - ReleaseRegionBreakers(); } void AbstractClient::SetNetworkProxy(const NetworkProxy &proxy) @@ -124,114 +120,82 @@ typedef std::map OctetStreamHeadersMap; void AbstractClient::InitRegionBreakers() { - // Only allocate breakers when region failover is enabled. - // When disabled, m_endpointBreakers stays nullptr and - // SelectEndpoint early-returns without touching them. + // Only build the registry when region failover is enabled. + // When disabled, m_breakers stays nullptr and SelectEndpoint + // early-returns without touching it. if (m_clientProfile.GetDisableRegionBreaker()) { return; } - m_endpointBreakerCount = DomainFailoverManager::GetBreakerSlotCount(); - RegionBreakerProfile rb_profile = m_clientProfile.GetRegionBreakerProfile(); - m_endpointBreakers = new CircuitBreaker*[m_endpointBreakerCount]; - for (int i = 0; i < m_endpointBreakerCount; ++i) - { - m_endpointBreakers[i] = new CircuitBreaker(rb_profile); - } + m_breakers = std::make_shared(); } -void AbstractClient::ReleaseRegionBreakers() +std::shared_ptr AbstractClient::BreakerFor( + const std::string &origin, const std::string &host) { - if (m_endpointBreakers != nullptr) + const std::string key = origin + "\n" + host; + std::lock_guard lock(m_breakers->mutex); + std::map >::iterator it = + m_breakers->map.find(key); + if (it != m_breakers->map.end()) { - for (int i = 0; i < m_endpointBreakerCount; ++i) - { - delete m_endpointBreakers[i]; - } - delete[] m_endpointBreakers; - m_endpointBreakers = nullptr; - m_endpointBreakerCount = 0; + return it->second; // reuse (accumulates state) } + std::shared_ptr breaker = std::make_shared( + m_clientProfile.GetRegionBreakerProfile()); + m_breakers->map.insert(std::make_pair(key, breaker)); // lazy load + return breaker; } -std::string AbstractClient::SelectEndpoint(const std::string &primary_endpoint, - int &out_allowed_breaker_idx) +AbstractClient::EndpointDecision AbstractClient::SelectEndpoint( + const std::string &primary_endpoint) { - out_allowed_breaker_idx = -1; - - // If region failover is disabled, always use the primary endpoint. - if (m_clientProfile.GetDisableRegionBreaker()) + // Bypass: failover disabled / non-TencentCloud endpoint -> send the + // primary directly, no breaker, no report. + if (!m_breakers || + !DomainFailoverManager::IsTencentCloudDomain(primary_endpoint)) { - return primary_endpoint; - } - // Guard: breakers were not allocated (e.g. failover was disabled - // at construction time but enabled at runtime via SetClientProfile). - if (m_endpointBreakers == nullptr || m_endpointBreakerCount == 0) - { - return primary_endpoint; - } - // Non-TencentCloud endpoints (e.g. custom proxy) bypass failover. - if (!DomainFailoverManager::IsTencentCloudDomain(primary_endpoint)) - { - return primary_endpoint; + EndpointDecision d; + d.host = primary_endpoint; + d.breaker = nullptr; + return d; } RegionBreakerProfile rb_profile = m_clientProfile.GetRegionBreakerProfile(); std::string backup_ep = rb_profile.GetBackupEndpoint(); - - // Walk the fallback chain. At iteration |i| we: - // - compute |next| = the endpoint that |i|-th fallback target - // resolves to (or empty if that fallback is not configured, - // i.e. layer 0 with no BackupEndpoint); - // - consult m_endpointBreakers[i], which records the statistics - // of the endpoint currently held in |current|. - // - // If that breaker Allow()s, we stay on |current| and reply to the - // caller that m_endpointBreakers[i] is the one to Report() back - // to. Otherwise the breaker is Open -- |current| is unhealthy -- - // and we descend to |next|, consulting the next breaker on the - // next iteration. - // - // If all breakers end up Open, the loop completes without setting - // out_allowed_breaker_idx. |current| will be the last-resort - // bottom TLD endpoint (the last in the ring after primary's TLD) - // and out_allowed_breaker_idx stays -1: no breaker needs - // to hear about this request because .cn is the bottom and has no - // further fallback. - std::string current = primary_endpoint; - const int count = m_endpointBreakerCount; - for (int i = 0; i < count; ++i) + std::vector candidates = + DomainFailoverManager::BuildCandidates(primary_endpoint, backup_ep); + + // The LAST candidate is the bottom fallback: no breaker, force-sent, + // not reported (legacy behavior). Only consult breakers for the + // candidates before it. + const size_t last = candidates.size() - 1; + for (size_t i = 0; i < last; ++i) { - std::string next = DomainFailoverManager::GetFallbackEndpoint( - primary_endpoint, backup_ep, i); - if (next.empty()) - { - // This fallback slot is unused (e.g. layer 0 with no - // BackupEndpoint). Skip WITHOUT consulting breaker[i]; - // it stays idle for this request. The next iteration - // will consult breaker[i+1] for the |current| endpoint. - continue; - } - - if (m_endpointBreakers[i]->Allow()) + std::shared_ptr breaker = + BreakerFor(primary_endpoint, candidates[i]); + if (breaker->Allow()) { - // Breaker i says: "the endpoint you are currently on is - // healthy -- stay." Remember this breaker so the outcome - // can be reported back to it. - out_allowed_breaker_idx = i; - break; + EndpointDecision d; + d.host = candidates[i]; + d.breaker = breaker; // hit -> must Report + return d; } - // Breaker i Open: |current| is unhealthy. Descend. - current = next; } - return current; + // All front candidates Open (or single-candidate case) -> fall to + // the bottom; force-send without reporting. + EndpointDecision d; + d.host = candidates[last]; + d.breaker = nullptr; + return d; } -void AbstractClient::ReportResult(int allowed_breaker_idx, bool success) +void AbstractClient::ReportResult( + const std::shared_ptr &breaker, bool success) { - if (allowed_breaker_idx >= 0 && m_endpointBreakers != nullptr) + if (breaker) { - m_endpointBreakers[allowed_breaker_idx]->Report(success); + breaker->Report(success); } } @@ -331,24 +295,18 @@ HttpClient::HttpResponseOutcome AbstractClient::DoRequest(const std::string &act // Pick an endpoint for this request based on circuit breaker state. // No in-request retry: the breakers make the decision once per // request. The outcome of that single request is then fed back to - // the breaker (success or failover-triggering error). - int allowed_breaker_idx = -1; - string target_endpoint = SelectEndpoint(primary_endpoint, allowed_breaker_idx); - - auto outcome = DoRequestWithEndpoint(actionName, body, headers, target_endpoint); - - if (outcome.IsSuccess()) - { - ReportResult(allowed_breaker_idx, /*success=*/true); - } - else - { - const auto &error_code = outcome.GetError().GetErrorCode(); - if (IsFailoverTriggering(error_code)) - { - ReportResult(allowed_breaker_idx, /*success=*/false); - } - } + // the breaker. + EndpointDecision decision = SelectEndpoint(primary_endpoint); + + auto outcome = DoRequestWithEndpoint(actionName, body, headers, decision.host); + + // Unified report rule: any Allow()==true request MUST Report() once. + // A non-failover error (business 4xx / throttling) counts as a + // "success" for the endpoint so the HalfOpen probe slot is released + // (fixes P7). decision.breaker == nullptr (bypass / bottom) -> no-op. + bool ok = outcome.IsSuccess() || + !IsFailoverTriggering(outcome.GetError().GetErrorCode()); + ReportResult(decision.breaker, ok); return outcome; } diff --git a/core/src/DomainFailoverManager.cpp b/core/src/DomainFailoverManager.cpp index f9afe1ea55..ff4a537510 100644 --- a/core/src/DomainFailoverManager.cpp +++ b/core/src/DomainFailoverManager.cpp @@ -49,6 +49,19 @@ std::string ToLowerAscii(const std::string &s) return result; } +// Known middle-segment modifiers and whether they are kept on fallback. +// New modifiers only require a row here. +struct ModifierSpec +{ + const char *name; + bool kept; +}; +const ModifierSpec kModifiers[] = { + {"ai", true}, // product identifier, kept + {"internal", true}, // intranet route marker, kept + {"intl", false}, // international-site marker, dropped on fallback +}; + // Ordered by descending length: longer TLDs must be tested first. const char *const kTencentCloudTldSuffixes[] = { ".tencentcloudapi.com.cn", @@ -58,175 +71,127 @@ const char *const kTencentCloudTldSuffixes[] = { } // namespace -int DomainFailoverManager::GetBreakerSlotCount() -{ - // 3 breaker slots (maximum across both modes): - // Mode A: only slot 0 is active; slots 1,2 idle. - // Mode B: slot 0 skipped; slots 1,2 active. - // The last endpoint in either mode is the bottom fallback with no - // breaker. Numerically equals kTldRing.size(). - return static_cast(kTldRing.size()); -} - -std::string DomainFailoverManager::GetFallbackEndpoint( - const std::string &original_endpoint, - const std::string &backup_endpoint, - int fallback_index) +DomainFailoverManager::ParsedEndpoint DomainFailoverManager::ParseEndpoint( + const std::string &endpoint) { - if (!IsTencentCloudDomain(original_endpoint)) + ParsedEndpoint p; + std::string tld = ExtractTld(endpoint); + if (tld.empty()) { - return ""; + return p; // is_tc_domain stays false } - if (fallback_index < 0) + p.is_tc_domain = true; + p.tld = tld; + + // prefix = everything before ".{tld}" (length-based, case kept). + std::string prefix = endpoint.substr(0, endpoint.size() - tld.size() - 1); + + std::string::size_type dot = prefix.find('.'); + if (dot == std::string::npos) { - return ""; + p.service = prefix; // service only, no middle/region + return p; } + p.service = prefix.substr(0, dot); - // Two mutually exclusive modes: - // - // Mode A (BackupEndpoint configured): - // index 0 = BackupEndpoint (the only fallback, acts as bottom) - // index 1..N = "" (no TLD fallback) - // - // Mode B (BackupEndpoint empty): - // index 0 = "" (skipped) - // index 1 = next TLD in ring (region stripped) - // index 2 = second TLD in ring (region stripped, bottom) + // First segment after service. + std::string rest = prefix.substr(dot + 1); + std::string::size_type dot2 = rest.find('.'); + std::string second = (dot2 == std::string::npos) ? rest : rest.substr(0, dot2); - if (!backup_endpoint.empty()) + std::string second_lower = ToLowerAscii(second); + for (const ModifierSpec &m : kModifiers) { - // Mode A: BackupEndpoint is set -- use it as the sole fallback. - if (fallback_index != 0) + if (second_lower == m.name) { - return ""; // No further fallback beyond BackupEndpoint. - } - // Decide whether |backup_endpoint| is a "bare region" (e.g. - // "ap-guangzhou.tencentcloudapi.com") - // or a complete endpoint with its own service prefix (e.g. - // "cvm.ap-guangzhou.tencentcloudapi.com" or - // "cvm.tencentcloudapi.com"). - // - // Heuristic: if exactly ONE segment sits before the TLD, it could - // be either a region ("ap-guangzhou") or a service name ("cvm"). - // We disambiguate by comparing with the primary's service name: - // if prefix == primary's service, the backup already has the right - // service and should be returned as-is; otherwise it is a bare - // region and needs the service prepended. - std::string backup_tld = ExtractTld(backup_endpoint); - if (backup_tld.empty()) - { - // Backup is not a TencentCloud domain at all -- return as-is. - return backup_endpoint; - } - // Prefix (the part before ".{tld}"). - std::string prefix = - backup_endpoint.substr(0, backup_endpoint.size() - backup_tld.size() - 1); - std::string svc = ExtractService(original_endpoint); - // "bare region" = single segment AND that segment is NOT the same - // as the primary's service name. - bool bare_region = (prefix.find('.') == std::string::npos) && (prefix != svc); - if (!bare_region) - { - return backup_endpoint; + p.modifier = m.name; // normalized lower-case form + p.modifier_kept = m.kept; + break; } + } + // Otherwise no modifier: |second| is the start of region (dropped). + // ORDERING CONTRACT: only |second| (the first segment after service) is + // checked. A modifier appearing after a region segment (reversed input) + // is therefore swallowed by region and dropped -- not supported by design. + return p; +} - if (svc.empty()) - { - return backup_endpoint; - } - // Only prepend the service name. Middle segments (ai, internal) - // are NOT automatically carried over -- this aligns with Go SDK - // behavior where: newEndpoint = service + "." + backupEndpoint. - // If the user wants the backup to include ".ai" or ".internal", - // they should configure a complete backup domain explicitly. - std::string result = svc + "." + backup_endpoint; - return result; +std::string DomainFailoverManager::ResolveBackupEndpoint( + const std::string &backup_endpoint, const std::string &primary_service) +{ + std::string tld = ExtractTld(backup_endpoint); + if (tld.empty()) + { + return backup_endpoint; // non-TencentCloud, as-is } + std::string prefix = + backup_endpoint.substr(0, backup_endpoint.size() - tld.size() - 1); - // Mode B: No BackupEndpoint -- use TLD ring fallback. - if (fallback_index == 0) + if (prefix.find('.') != std::string::npos) { - return ""; // Slot 0 is unused when no BackupEndpoint is set. + return backup_endpoint; // multi-segment -> complete domain } - // Only index 1 and 2 are valid TLD fallback slots. - int ring_size_limit = static_cast(kTldRing.size()); - if (fallback_index >= ring_size_limit) + if (prefix == primary_service) { - return ""; + return backup_endpoint; // single segment == service -> complete } - - // Index 1..2: TLD fallback, with region segment stripped. - // TLDs form a ring: .com -> .com.cn -> .cn -> .com -> ... - // Starting from the primary's own TLD, walk the ring and pick the - // next two distinct TLDs. fallback_index 1 gets the first, 2 the - // second. This guarantees no TLD is ever repeated regardless of - // which TLD the user started with. - int tld_step = fallback_index; // 1 or 2 - std::string original_tld = ExtractTld(original_endpoint); - if (original_tld.empty()) + if (primary_service.empty()) { - return ""; + return backup_endpoint; // defensive: cannot prepend } + return primary_service + "." + backup_endpoint; // bare region +} - std::string svc = ExtractService(original_endpoint); - std::string middle = ExtractMiddleSegment(original_endpoint, original_tld); +std::vector DomainFailoverManager::BuildCandidates( + const std::string &primary, const std::string &backup_endpoint) +{ + std::vector candidates; + candidates.push_back(primary); // first element is always primary - // Check whether the original endpoint contains a region segment. - // Structure: {service}[.{middle}][.{region}].{tld} - // If after removing service, middle, and tld there is still a segment - // left, that is the region. - // When region is present and no BackupEndpoint is configured, do NOT - // perform TLD fallback -- because the fallback domain would lack the - // region segment and may route to a different geography, violating - // the user's intent. + ParsedEndpoint p = ParseEndpoint(primary); + if (!p.is_tc_domain) { - // Reconstruct what the endpoint would look like without a region: - // {service}[.{middle}].{tld} - std::string without_region = svc; - if (!middle.empty()) - { - without_region += "." + middle; - } - without_region += "." + original_tld; - // If the original is longer than "without_region", it has a region. - std::string lower_endpoint = ToLowerAscii(original_endpoint); - std::string lower_without = ToLowerAscii(without_region); - if (lower_endpoint != lower_without) + return candidates; // non-TencentCloud: no failover + } + + if (!backup_endpoint.empty()) + { + // Mode A: single fallback to the (normalized) backup endpoint. + std::string resolved = ResolveBackupEndpoint(backup_endpoint, p.service); + if (!resolved.empty()) { - // Region detected -- suppress TLD fallback. - return ""; + candidates.push_back(resolved); } + return candidates; } - // Find the primary's position in the ring. - int ring_size = static_cast(kTldRing.size()); - int origin_pos = -1; - for (int i = 0; i < ring_size; ++i) + // Mode B: walk the TLD ring starting from the primary's own TLD. + // Region is dropped; kept-modifiers (ai/internal) preserved; intl dropped. + const std::size_t ring_size = kTldRing.size(); + std::size_t origin = ring_size; // sentinel: not found + for (std::size_t i = 0; i < ring_size; ++i) { - if (kTldRing[i] == original_tld) + if (kTldRing[i] == p.tld) { - origin_pos = i; + origin = i; break; } } - if (origin_pos < 0) + if (origin == ring_size) { - return ""; // Unknown TLD, should not happen for a valid TC domain. + return candidates; // unknown TLD, no fallback } - int target_pos = (origin_pos + tld_step) % ring_size; - const std::string &target_tld = kTldRing[target_pos]; - - // Build: {service}[.{middle}].{target_tld} - // Region segment is intentionally dropped (only reachable when - // original has no region -- verified above). - std::string result = svc; - if (!middle.empty()) + std::string prefix = p.service; + if (p.modifier_kept && !p.modifier.empty()) { - result += "." + middle; + prefix += "." + p.modifier; } - result += "." + target_tld; - return result; + for (std::size_t off = 1; off < ring_size; ++off) + { + candidates.push_back(prefix + "." + kTldRing[(origin + off) % ring_size]); + } + return candidates; } bool DomainFailoverManager::IsTencentCloudDomain( @@ -262,49 +227,3 @@ std::string DomainFailoverManager::ExtractTld(const std::string &endpoint) } return ""; } - -std::string DomainFailoverManager::ExtractService( - const std::string &endpoint) -{ - auto pos = endpoint.find('.'); - if (pos == std::string::npos) - { - return endpoint; - } - return endpoint.substr(0, pos); -} - -std::string DomainFailoverManager::ExtractMiddleSegment( - const std::string &endpoint, - const std::string &tld) -{ - // Tencent Cloud API domains come in exactly two shapes: - // 1) {service}[.{region}].{tld} -- no middle segment - // 2) {service}.ai[.{region}].{tld} -- middle == "ai" - // So the "middle" is determined solely by whether the SECOND dotted - // segment equals the literal "ai". No region-prefix enumeration is - // needed, which means new regions (me-/cn-/...) require no changes. - auto dot1 = endpoint.find('.'); - if (dot1 == std::string::npos) - { - return ""; - } - auto dot2 = endpoint.find('.', dot1 + 1); - if (dot2 == std::string::npos) - { - return ""; - } - // The second segment must lie strictly before the TLD to be a - // middle (otherwise it IS the start of the TLD). - auto tld_pos = endpoint.rfind("." + tld); - if (tld_pos == std::string::npos || tld_pos < dot2) - { - return ""; - } - std::string second = endpoint.substr(dot1 + 1, dot2 - dot1 - 1); - if (second == "ai" || second == "internal") - { - return second; - } - return ""; -} diff --git a/example/cvm/v20170312/DomainFailover.cpp b/example/cvm/v20170312/DomainFailover.cpp index 8ea7328e8c..5cdfc0a50a 100644 --- a/example/cvm/v20170312/DomainFailover.cpp +++ b/example/cvm/v20170312/DomainFailover.cpp @@ -19,10 +19,11 @@ /// Python SDK's region_breaker) in the C++ SDK. /// /// When the primary endpoint keeps failing, a circuit breaker drives -/// automatic switching: -/// primary -> user-configured BackupEndpoint -/// -> tencentcloudapi.com.cn (TLD fallback) -/// -> tencentcloudapi.cn (TLD fallback) +/// automatic switching. There are two mutually exclusive fallback modes: +/// Mode A (BackupEndpoint configured): +/// primary -> user-configured BackupEndpoint (no TLD fallback) +/// Mode B (no BackupEndpoint, default): +/// primary -> tencentcloudapi.com.cn -> tencentcloudapi.cn (TLD ring) /// /// Run with: TENCENTCLOUD_SECRET_ID=xxx TENCENTCLOUD_SECRET_KEY=yyy ./DomainFailover /// @@ -200,7 +201,8 @@ void DemonstrateActualFailover() { } Credential cred(sid, skey); - // Configure breaker with low thresholds so failover triggers quickly. + // Use the default breaker thresholds (max_fail_num=5): the breaker + // trips after 5 consecutive SSLError failures on the primary. RegionBreakerProfile rb( /*backup_endpoint=*/ "ap-guangzhou.tencentcloudapi.com", /*max_fail_num=*/ 5, @@ -247,7 +249,7 @@ void DemonstrateActualFailover() { // // For deeper debugging, enable curl verbose mode or add a log line // in AbstractClient::DoRequest() after SelectEndpoint() returns: - // std::cerr << "[Failover] endpoint=" << target_endpoint << std::endl; + // std::cerr << "[Failover] endpoint=" << decision.host << std::endl; const int kTotalRequests = 10; int fail_count = 0; int success_count = 0; @@ -274,12 +276,13 @@ void DemonstrateActualFailover() { /// Example 5: Failover WITHOUT BackupEndpoint (TLD-only fallback). /// -/// When BackupEndpoint is empty, the fallback chain skips index 0 and -/// goes directly to TLD switching: -/// primary (.com) -> .com.cn -> .cn +/// With no BackupEndpoint, fallback goes through the TLD ring. The +/// primary cvm.does-not-exist.tencentcloudapi.com carries a region-like +/// segment ("does-not-exist") which is DROPPED on fallback, so the +/// breaker trips and the SDK switches to cvm.tencentcloudapi.com.cn. /// /// Expected output: -/// Requests 1~5: SSLError (primary endpoint cert mismatch) +/// Requests 1~5: SSLError (primary cert mismatch on fabricated domain) /// Request 6+: Success (failover to cvm.tencentcloudapi.com.cn) void DemonstrateFailoverWithoutBackup() { cout << "=== Example 5: Failover Without BackupEndpoint (TLD fallback) ===" << endl; @@ -311,7 +314,8 @@ void DemonstrateFailoverWithoutBackup() { clientProfile.SetRegionBreakerProfile(rb); HttpProfile httpProfile; - // Fabricated sub-domain under .com: SSL cert mismatch triggers failover. + // Fabricated two-level sub-domain: SSL cert SAN mismatch triggers + // SSLError, which opens the breaker and drives TLD fallback. httpProfile.SetEndpoint("cvm.does-not-exist.tencentcloudapi.com"); httpProfile.SetConnectTimeout(3); httpProfile.SetReqTimeout(5); @@ -323,11 +327,10 @@ void DemonstrateFailoverWithoutBackup() { req.SetOffset(0); req.SetLimit(1); - // After 5 consecutive SSLError failures on the primary, the breaker - // opens. Since BackupEndpoint is empty (index 0 skipped), the SDK - // falls back to the next TLD in the ring: - // primary .com -> index 1 = .com.cn - // So requests should hit cvm.tencentcloudapi.com.cn + // After max_fail_num consecutive SSLError failures on the primary, the + // breaker opens. With no BackupEndpoint, the region-like segment is + // dropped and the SDK falls back through the TLD ring: + // cvm.does-not-exist.tencentcloudapi.com -> cvm.tencentcloudapi.com.cn const int kTotalRequests = 10; int fail_count = 0; int success_count = 0; diff --git a/test/function_test/core/CMakeLists.txt b/test/function_test/core/CMakeLists.txt index a1e5906f23..d79265c488 100644 --- a/test/function_test/core/CMakeLists.txt +++ b/test/function_test/core/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(core_ft Core_Http_ResolveIp_Ft.cpp Core_CircuitBreaker_Ft.cpp Core_DomainFailoverManager_Ft.cpp + Core_AbstractClient_Breaker_Ft.cpp Main.cpp) target_link_libraries(core_ft tencentcloud-sdk-cpp-cbs tencentcloud-sdk-cpp-cvm tencentcloud-sdk-cpp-core) target_link_libraries(core_ft gtest gmock_main -lpthread -lm) diff --git a/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp b/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp new file mode 100644 index 0000000000..22a4cff029 --- /dev/null +++ b/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2017-2019 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. + */ + +#include +#include +#include +#include + +#include +#include +#include + +using namespace TencentCloud; +using namespace std; + +namespace { + +// Exposes protected breaker helpers for deterministic testing. +class TestableClient : public AbstractClient { + public: + explicit TestableClient(const ClientProfile &p) + : AbstractClient("cvm.tencentcloudapi.com", "2017-03-12", + Credential("id", "key"), "ap-guangzhou", p) {} + using AbstractClient::BreakerFor; + using AbstractClient::SelectEndpoint; + using AbstractClient::ReportResult; + using AbstractClient::EndpointDecision; +}; + +// max_fail_num=1 -> a single failure trips the breaker. +ClientProfile MakeProfile(const string &backup, bool disabled = false) { + RegionBreakerProfile rb(backup, 1, 0.0, 300, 60, 1); + ClientProfile cp; + cp.SetDisableRegionBreaker(disabled); + cp.SetRegionBreakerProfile(rb); + return cp; +} + +void Trip(TestableClient &c, const string &origin, const string &host) { + std::shared_ptr b = c.BreakerFor(origin, host); + ASSERT_TRUE(b->Allow()); + b->Report(false); + ASSERT_EQ(b->GetState(), CircuitBreaker::State::kOpen); +} + +const char *kP = "cvm.tencentcloudapi.com"; +const char *kFb1 = "cvm.tencentcloudapi.com.cn"; +const char *kFb2 = "cvm.tencentcloudapi.cn"; // bottom (no breaker) + +} // namespace + +TEST(AbstractClientBreakerTest, BreakerForReusesSameInstance) { + TestableClient c(MakeProfile("")); + EXPECT_EQ(c.BreakerFor(kP, kP), c.BreakerFor(kP, kP)); +} + +TEST(AbstractClientBreakerTest, BreakerStateIsolatedAcrossOriginHosts) { + TestableClient c(MakeProfile("")); + Trip(c, kP, kP); + std::shared_ptr cls = + c.BreakerFor("cls.tencentcloudapi.com", "cls.tencentcloudapi.com"); + EXPECT_EQ(cls->GetState(), CircuitBreaker::State::kClosed); +} + +TEST(AbstractClientBreakerTest, SelectsPrimaryWhenAllClosed) { + TestableClient c(MakeProfile("")); + TestableClient::EndpointDecision d = c.SelectEndpoint(kP); + EXPECT_EQ(d.host, kP); + EXPECT_NE(d.breaker, nullptr); +} + +TEST(AbstractClientBreakerTest, DescendsToNextCandidateInOrder) { + TestableClient c(MakeProfile("")); + Trip(c, kP, kP); + TestableClient::EndpointDecision d = c.SelectEndpoint(kP); + EXPECT_EQ(d.host, kFb1); + EXPECT_NE(d.breaker, nullptr); +} + +TEST(AbstractClientBreakerTest, FrontCandidatesOpenFallsToBottomNoReport) { + TestableClient c(MakeProfile("")); + Trip(c, kP, kP); + Trip(c, kP, kFb1); // kFb2 is the bottom, has no breaker + TestableClient::EndpointDecision d = c.SelectEndpoint(kP); + EXPECT_EQ(d.host, kFb2); + EXPECT_EQ(d.breaker, nullptr); +} + +TEST(AbstractClientBreakerTest, DisabledBypass) { + TestableClient c(MakeProfile("", /*disabled=*/true)); + TestableClient::EndpointDecision d = c.SelectEndpoint(kP); + EXPECT_EQ(d.host, kP); + EXPECT_EQ(d.breaker, nullptr); +} + +TEST(AbstractClientBreakerTest, NonTencentCloudBypass) { + TestableClient c(MakeProfile("")); + TestableClient::EndpointDecision d = c.SelectEndpoint("my-proxy.corp.com"); + EXPECT_EQ(d.host, "my-proxy.corp.com"); + EXPECT_EQ(d.breaker, nullptr); +} + +TEST(AbstractClientBreakerTest, RegionPrimarySingleCandidateNoReport) { + TestableClient c(MakeProfile("")); + TestableClient::EndpointDecision d = + c.SelectEndpoint("cvm.ap-shanghai.tencentcloudapi.com"); + EXPECT_EQ(d.host, "cvm.ap-shanghai.tencentcloudapi.com"); + EXPECT_EQ(d.breaker, nullptr); +} + +TEST(AbstractClientBreakerTest, ReportNullBreakerIsNoop) { + TestableClient c(MakeProfile("")); + c.ReportResult(nullptr, false); + c.ReportResult(nullptr, true); +} diff --git a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp index ea1790a7c9..8cdd5bee2b 100644 --- a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp +++ b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp @@ -22,198 +22,6 @@ using namespace TencentCloud; using namespace std; -TEST(DomainFailoverManagerTest, BreakerSlotCountIsThree) { - // primary + BackupEndpoint + .com.cn == 3 slots that need a - // breaker. The final .cn TLD is the bottom fallback and has no - // breaker. - EXPECT_EQ(DomainFailoverManager::GetBreakerSlotCount(), 3); -} - -TEST(DomainFailoverManagerTest, NonTencentCloudDomainReturnsEmpty) { - string result = DomainFailoverManager::GetFallbackEndpoint( - "my-proxy.corp.com", "ap-guangzhou.tencentcloudapi.com", 0); - EXPECT_EQ(result, ""); -} - -TEST(DomainFailoverManagerTest, OutOfRangeIndexReturnsEmpty) { - string primary = "cvm.tencentcloudapi.com"; - // Negative index is always out of range. - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - primary, "ap-guangzhou.tencentcloudapi.com", -1), - ""); - // With BackupEndpoint set, only index 0 is valid. - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - primary, "ap-guangzhou.tencentcloudapi.com", 3), - ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - primary, "ap-guangzhou.tencentcloudapi.com", 999), - ""); - // Without BackupEndpoint, index 0 returns "" (skip) and index >= 3 - // is out of range. - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, "", 3), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, "", 999), ""); -} - -// ===== Index 0: BackupEndpoint ===== - -TEST(DomainFailoverManagerTest, BackupEndpointPrependsService) { - // User-configured BackupEndpoint starts with a region; SDK must - // prepend the primary endpoint's service name. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", - 0); - EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); -} - -TEST(DomainFailoverManagerTest, BackupEndpointPrependsServiceForNonRegionalPrimary) { - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", - 0); - EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); -} - -TEST(DomainFailoverManagerTest, BackupEndpointDoesNotPreserveAiMiddle) { - // Aligns with Go SDK: only service name is prepended, middle - // segments (ai/internal) are NOT automatically carried over. - // If user wants ai in backup, they should provide a complete domain. - string result = DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.ap-shanghai.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", - 0); - EXPECT_EQ(result, "hunyuan.ap-guangzhou.tencentcloudapi.com"); -} - -TEST(DomainFailoverManagerTest, BackupEndpointWithCompleteDomain) { - // If backup_endpoint already has a service prefix, keep it as-is. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com", - "cvm.ap-guangzhou.tencentcloudapi.com", - 0); - EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); -} - -// ===== Index 1: TLD ring fallback (next TLD after primary) ===== -// TLD fallback only works when BackupEndpoint is NOT set AND primary has no region. - -TEST(DomainFailoverManagerTest, TldFallbackFromComGoesToComCn) { - // Ring: .com → .com.cn → .cn. Primary .com, no region → next = .com.cn. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.com", - "", // no backup -> TLD fallback mode - 1); - EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); -} - -TEST(DomainFailoverManagerTest, TldFallbackFromComCnGoesToCn) { - // Ring: .com.cn → .cn → .com. Primary .com.cn, no region → next = .cn. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.com.cn", - "", // no backup -> TLD fallback mode - 1); - EXPECT_EQ(result, "cvm.tencentcloudapi.cn"); -} - -TEST(DomainFailoverManagerTest, TldFallbackFromCnGoesToCom) { - // Ring: .cn → .com → .com.cn. Primary .cn, no region → next = .com. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.cn", - "", // no backup -> TLD fallback mode - 1); - EXPECT_EQ(result, "cvm.tencentcloudapi.com"); -} - -TEST(DomainFailoverManagerTest, TldFallbackPreservesAiMiddle) { - // ai domain without region: TLD fallback should work. - string result = DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.tencentcloudapi.com", - "", // no backup -> TLD fallback mode - 1); - EXPECT_EQ(result, "hunyuan.ai.tencentcloudapi.com.cn"); -} - -TEST(DomainFailoverManagerTest, TldFallbackFromNonRegionalPrimary) { - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.com", - "", // no backup -> TLD fallback mode - 1); - EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); -} - -TEST(DomainFailoverManagerTest, TldFallbackSuppressedWhenRegionPresent) { - // Primary has a region segment → no TLD fallback (would change geography). - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com", "", 1), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com", "", 2), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.com.cn", "", 1), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - "cvm.ap-shanghai.tencentcloudapi.cn", "", 1), ""); - // ai + region → also suppressed. - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.ap-shanghai.tencentcloudapi.com", "", 1), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.ap-shanghai.tencentcloudapi.com", "", 2), ""); -} - -// ===== Index 2: TLD ring fallback (second TLD after primary) ===== - -TEST(DomainFailoverManagerTest, TldFallback2FromComGoesToCn) { - // Ring: .com → .com.cn → .cn. Primary .com, no region → second = .cn. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.com", - "", // no backup -> TLD fallback mode - 2); - EXPECT_EQ(result, "cvm.tencentcloudapi.cn"); -} - -TEST(DomainFailoverManagerTest, TldFallback2FromComCnGoesToCom) { - // Ring: .com.cn → .cn → .com. Primary .com.cn, no region → second = .com. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.com.cn", - "", // no backup -> TLD fallback mode - 2); - EXPECT_EQ(result, "cvm.tencentcloudapi.com"); -} - -TEST(DomainFailoverManagerTest, TldFallback2FromCnGoesToComCn) { - // Ring: .cn → .com → .com.cn. Primary .cn, no region → second = .com.cn. - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.tencentcloudapi.cn", - "", // no backup -> TLD fallback mode - 2); - EXPECT_EQ(result, "cvm.tencentcloudapi.com.cn"); -} - -TEST(DomainFailoverManagerTest, TldFallback2PreservesAiMiddle) { - // ai domain without region: TLD fallback should work. - string result = DomainFailoverManager::GetFallbackEndpoint( - "hunyuan.ai.tencentcloudapi.com", - "", // no backup -> TLD fallback mode - 2); - EXPECT_EQ(result, "hunyuan.ai.tencentcloudapi.cn"); -} - -// ===== Mutual exclusion: BackupEndpoint set → no TLD fallback ===== - -TEST(DomainFailoverManagerTest, BackupEndpointSetBlocksTldFallback) { - // When BackupEndpoint is configured, index 1 and 2 must return "" - // (no TLD fallback). - string primary = "cvm.ap-shanghai.tencentcloudapi.com"; - string backup = "ap-guangzhou.tencentcloudapi.com"; - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 1), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 2), ""); -} - -TEST(DomainFailoverManagerTest, BackupEndpointSetBlocksTldFallbackAiDomain) { - string primary = "hunyuan.ai.ap-shanghai.tencentcloudapi.com"; - string backup = "ap-guangzhou.tencentcloudapi.com"; - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 1), ""); - EXPECT_EQ(DomainFailoverManager::GetFallbackEndpoint(primary, backup, 2), ""); -} - // ===== Helper methods ===== TEST(DomainFailoverManagerTest, IsTencentCloudDomain) { @@ -255,67 +63,228 @@ TEST(DomainFailoverManagerTest, ExtractTld) { EXPECT_EQ(DomainFailoverManager::ExtractTld("tencentcloudapi.company.example.com"), ""); } -TEST(DomainFailoverManagerTest, ExtractService) { - EXPECT_EQ(DomainFailoverManager::ExtractService("cvm.tencentcloudapi.com"), "cvm"); - EXPECT_EQ(DomainFailoverManager::ExtractService("hunyuan.ai.tencentcloudapi.com"), - "hunyuan"); - EXPECT_EQ(DomainFailoverManager::ExtractService("no-dot-here"), "no-dot-here"); -} - -TEST(DomainFailoverManagerTest, ExtractMiddleSegmentForAiDomains) { - EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( - "hunyuan.ai.tencentcloudapi.com", "tencentcloudapi.com"), - "ai"); - EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( - "hunyuan.ai.ap-guangzhou.tencentcloudapi.com", - "tencentcloudapi.com"), - "ai"); - EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( - "cvm.tencentcloudapi.com", "tencentcloudapi.com"), - ""); - EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( - "cvm.ap-guangzhou.tencentcloudapi.com", "tencentcloudapi.com"), - ""); -} - -TEST(DomainFailoverManagerTest, ExtractMiddleSegmentForInternalDomains) { - // "internal" is recognized as a middle segment (network route marker). - EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( - "cvm.internal.tencentcloudapi.com", "tencentcloudapi.com"), - "internal"); - EXPECT_EQ(DomainFailoverManager::ExtractMiddleSegment( - "cvm.internal.ap-shanghai.tencentcloudapi.com", - "tencentcloudapi.com"), - "internal"); -} - -TEST(DomainFailoverManagerTest, TldFallbackPreservesInternalMiddle) { - // TLD 降级时 internal 保留(内网线路间切换 TLD) - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.internal.tencentcloudapi.com", - "", // no backup -> TLD fallback mode - 1); - EXPECT_EQ(result, "cvm.internal.tencentcloudapi.com.cn"); - - result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.internal.tencentcloudapi.com", - "", - 2); - EXPECT_EQ(result, "cvm.internal.tencentcloudapi.cn"); -} - -TEST(DomainFailoverManagerTest, BackupEndpointStripsInternalMiddle) { - // internal 域名 + 裸地域 backup → 只拼 service(不带 internal) - string result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.internal.tencentcloudapi.com", - "ap-guangzhou.tencentcloudapi.com", - 0); - EXPECT_EQ(result, "cvm.ap-guangzhou.tencentcloudapi.com"); - - // 完整域名作为 backup 时直接返回 - result = DomainFailoverManager::GetFallbackEndpoint( - "cvm.internal.tencentcloudapi.com", - "cvm.tencentcloudapi.com", - 0); - EXPECT_EQ(result, "cvm.tencentcloudapi.com"); +// ===== BuildCandidates ===== + +TEST(BuildCandidatesTest, ModeB_ComNoRegion) { + auto c = DomainFailoverManager::BuildCandidates("cvm.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[0], "cvm.tencentcloudapi.com"); + EXPECT_EQ(c[1], "cvm.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_ComCnNoRegion) { + auto c = DomainFailoverManager::BuildCandidates("cvm.tencentcloudapi.com.cn", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[0], "cvm.tencentcloudapi.com.cn"); + EXPECT_EQ(c[1], "cvm.tencentcloudapi.cn"); + EXPECT_EQ(c[2], "cvm.tencentcloudapi.com"); +} + +TEST(BuildCandidatesTest, ModeB_CnNoRegion) { + auto c = DomainFailoverManager::BuildCandidates("cvm.tencentcloudapi.cn", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[0], "cvm.tencentcloudapi.cn"); + EXPECT_EQ(c[1], "cvm.tencentcloudapi.com"); + EXPECT_EQ(c[2], "cvm.tencentcloudapi.com.cn"); +} + +TEST(BuildCandidatesTest, ModeB_AiDomainPreservesMiddle) { + auto c = DomainFailoverManager::BuildCandidates("hunyuan.ai.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[0], "hunyuan.ai.tencentcloudapi.com"); + EXPECT_EQ(c[1], "hunyuan.ai.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "hunyuan.ai.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_InternalDomainPreservesMiddle) { + auto c = DomainFailoverManager::BuildCandidates("cvm.internal.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[1], "cvm.internal.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.internal.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_IntlModifierDropped) { + // intl is recognized but DROPPED on fallback. + auto c = DomainFailoverManager::BuildCandidates("cvm.intl.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[0], "cvm.intl.tencentcloudapi.com"); + EXPECT_EQ(c[1], "cvm.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_RegionNowFallsBackDroppingRegion) { + // CHANGED BEHAVIOR: region is no longer suppressed; it falls back + // dropping the region segment. + auto c = DomainFailoverManager::BuildCandidates( + "cvm.ap-shanghai.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[0], "cvm.ap-shanghai.tencentcloudapi.com"); + EXPECT_EQ(c[1], "cvm.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_AiRegionKeepsAiDropsRegion) { + auto c = DomainFailoverManager::BuildCandidates( + "cvm.ai.ap-shanghai.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[1], "cvm.ai.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.ai.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_IntlRegionBothDropped) { + auto c = DomainFailoverManager::BuildCandidates( + "cvm.intl.ap-guangzhou.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[1], "cvm.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeB_ExtremeStackTakesAiDropsRest) { + auto c = DomainFailoverManager::BuildCandidates( + "cvm.ai.internal.intl.ap-guangzhou.tencentcloudapi.com", ""); + ASSERT_EQ(c.size(), 3u); + EXPECT_EQ(c[1], "cvm.ai.tencentcloudapi.com.cn"); + EXPECT_EQ(c[2], "cvm.ai.tencentcloudapi.cn"); +} + +TEST(BuildCandidatesTest, ModeA_RegionPrimaryWithBareBackup) { + auto c = DomainFailoverManager::BuildCandidates( + "cvm.ap-shanghai.tencentcloudapi.com", "ap-guangzhou.tencentcloudapi.com"); + ASSERT_EQ(c.size(), 2u); + EXPECT_EQ(c[0], "cvm.ap-shanghai.tencentcloudapi.com"); + EXPECT_EQ(c[1], "cvm.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(BuildCandidatesTest, ModeA_AiRegionBackupDropsAiMiddle) { + auto c = DomainFailoverManager::BuildCandidates( + "hunyuan.ai.ap-shanghai.tencentcloudapi.com", "ap-guangzhou.tencentcloudapi.com"); + ASSERT_EQ(c.size(), 2u); + EXPECT_EQ(c[1], "hunyuan.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(BuildCandidatesTest, NonTencentCloudDomain) { + auto c = DomainFailoverManager::BuildCandidates( + "my-proxy.corp.com", "ap-guangzhou.tencentcloudapi.com"); + ASSERT_EQ(c.size(), 1u); + EXPECT_EQ(c[0], "my-proxy.corp.com"); +} + +// ===== ParseEndpoint ===== + +TEST(ParseEndpointTest, PlainService) { + auto p = DomainFailoverManager::ParseEndpoint("cvm.tencentcloudapi.com"); + EXPECT_TRUE(p.is_tc_domain); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, ""); + EXPECT_FALSE(p.modifier_kept); + EXPECT_EQ(p.tld, "tencentcloudapi.com"); +} + +TEST(ParseEndpointTest, AiModifierKept) { + auto p = DomainFailoverManager::ParseEndpoint("hunyuan.ai.tencentcloudapi.com"); + EXPECT_EQ(p.service, "hunyuan"); + EXPECT_EQ(p.modifier, "ai"); + EXPECT_TRUE(p.modifier_kept); + EXPECT_EQ(p.tld, "tencentcloudapi.com"); +} + +TEST(ParseEndpointTest, InternalModifierKept) { + auto p = DomainFailoverManager::ParseEndpoint("cvm.internal.tencentcloudapi.com"); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, "internal"); + EXPECT_TRUE(p.modifier_kept); +} + +TEST(ParseEndpointTest, IntlModifierDropped) { + auto p = DomainFailoverManager::ParseEndpoint("cvm.intl.tencentcloudapi.com"); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, "intl"); + EXPECT_FALSE(p.modifier_kept); // intl is dropped on fallback +} + +TEST(ParseEndpointTest, RegionIsNotAModifier) { + auto p = DomainFailoverManager::ParseEndpoint("cvm.ap-shanghai.tencentcloudapi.com"); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, ""); // ap-shanghai is region, not a modifier + EXPECT_EQ(p.tld, "tencentcloudapi.com"); +} + +TEST(ParseEndpointTest, AiThenRegion) { + auto p = DomainFailoverManager::ParseEndpoint( + "cvm.ai.ap-shanghai.tencentcloudapi.com"); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, "ai"); // first middle segment wins + EXPECT_TRUE(p.modifier_kept); +} + +TEST(ParseEndpointTest, ExtremeStackTakesFirstModifierOnly) { + // Only the FIRST middle segment is considered for modifier (mutually + // exclusive); everything after is region. + auto p = DomainFailoverManager::ParseEndpoint( + "cvm.ai.internal.intl.ap-guangzhou.tencentcloudapi.com"); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, "ai"); + EXPECT_TRUE(p.modifier_kept); + EXPECT_EQ(p.tld, "tencentcloudapi.com"); +} + +TEST(ParseEndpointTest, RegionBeforeModifierDropsModifier) { + // ORDERING CONTRACT: modifier must immediately follow service. Here the + // first middle segment is "ap-shanghai" (not a modifier), so parsing + // stops looking -> no modifier; the trailing "ai" is part of region and + // is dropped on fallback. Reversed input is intentionally unsupported. + auto p = DomainFailoverManager::ParseEndpoint( + "cvm.ap-shanghai.ai.tencentcloudapi.com"); + EXPECT_EQ(p.service, "cvm"); + EXPECT_EQ(p.modifier, ""); // ai is NOT recognized (comes after region) + EXPECT_FALSE(p.modifier_kept); + EXPECT_EQ(p.tld, "tencentcloudapi.com"); +} + +TEST(ParseEndpointTest, NonTencentCloud) { + auto p = DomainFailoverManager::ParseEndpoint("my-proxy.corp.com"); + EXPECT_FALSE(p.is_tc_domain); +} + +TEST(ParseEndpointTest, CaseInsensitiveTldAndModifier) { + auto p = DomainFailoverManager::ParseEndpoint("CVM.AI.TencentCloudApi.COM"); + EXPECT_TRUE(p.is_tc_domain); + EXPECT_EQ(p.modifier, "ai"); // normalized lower-case + EXPECT_TRUE(p.modifier_kept); + EXPECT_EQ(p.tld, "tencentcloudapi.com"); +} + +// ===== ResolveBackupEndpoint ===== + +TEST(ResolveBackupEndpointTest, BareRegionPrependsService) { + EXPECT_EQ(DomainFailoverManager::ResolveBackupEndpoint( + "ap-guangzhou.tencentcloudapi.com", "cvm"), + "cvm.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(ResolveBackupEndpointTest, CompleteDomainMultiSegmentKeptAsIs) { + EXPECT_EQ(DomainFailoverManager::ResolveBackupEndpoint( + "cvm.ap-guangzhou.tencentcloudapi.com", "cvm"), + "cvm.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(ResolveBackupEndpointTest, SingleSegmentEqualsServiceKeptAsIs) { + EXPECT_EQ(DomainFailoverManager::ResolveBackupEndpoint( + "cvm.tencentcloudapi.com", "cvm"), + "cvm.tencentcloudapi.com"); +} + +TEST(ResolveBackupEndpointTest, ModifierNotPrepended) { + // primary service is hunyuan; bare-region backup only gets service, + // never the ai modifier. + EXPECT_EQ(DomainFailoverManager::ResolveBackupEndpoint( + "ap-guangzhou.tencentcloudapi.com", "hunyuan"), + "hunyuan.ap-guangzhou.tencentcloudapi.com"); +} + +TEST(ResolveBackupEndpointTest, NonTencentCloudKeptAsIs) { + EXPECT_EQ(DomainFailoverManager::ResolveBackupEndpoint( + "my-proxy.corp.com", "cvm"), + "my-proxy.corp.com"); } From 9e933588121659d7c4e5c18ff5b610e02176d9be Mon Sep 17 00:00:00 2001 From: laughingyear Date: Tue, 16 Jun 2026 15:34:33 +0800 Subject: [PATCH 8/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp b/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp index 22a4cff029..f1d339d467 100644 --- a/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp +++ b/test/function_test/core/Core_AbstractClient_Breaker_Ft.cpp @@ -113,12 +113,15 @@ TEST(AbstractClientBreakerTest, NonTencentCloudBypass) { EXPECT_EQ(d.breaker, nullptr); } -TEST(AbstractClientBreakerTest, RegionPrimarySingleCandidateNoReport) { +TEST(AbstractClientBreakerTest, RegionPrimaryNowHasFallbackWithBreaker) { + // CHANGED BEHAVIOR: region is no longer suppressed. A regional primary + // with no backup now produces 3 candidates (primary + 2 TLD fallbacks), + // so SelectEndpoint assigns a breaker to the primary (Closed -> Allow). TestableClient c(MakeProfile("")); TestableClient::EndpointDecision d = c.SelectEndpoint("cvm.ap-shanghai.tencentcloudapi.com"); EXPECT_EQ(d.host, "cvm.ap-shanghai.tencentcloudapi.com"); - EXPECT_EQ(d.breaker, nullptr); + EXPECT_NE(d.breaker, nullptr); // breaker assigned (Closed state) } TEST(AbstractClientBreakerTest, ReportNullBreakerIsNoop) { From c998cfd8de9784cc95208f85f69402ce2ef189b3 Mon Sep 17 00:00:00 2001 From: laughingyear Date: Tue, 16 Jun 2026 19:55:55 +0800 Subject: [PATCH 9/9] feat(core): add region-level domain failover with circuit breaker Introduce automatic endpoint failover for TencentCloud API domains, driven by a three-state circuit breaker (Closed/Open/HalfOpen). Fallback chain: primary -> BackupEndpoint -> TLD ring (.com/.com.cn/.cn) --- core/include/tencentcloud/core/DomainFailoverManager.h | 8 ++++---- core/src/DomainFailoverManager.cpp | 4 ++-- test/function_test/core/Core_DomainFailoverManager_Ft.cpp | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/core/include/tencentcloud/core/DomainFailoverManager.h b/core/include/tencentcloud/core/DomainFailoverManager.h index ec973a6b86..27bb614333 100644 --- a/core/include/tencentcloud/core/DomainFailoverManager.h +++ b/core/include/tencentcloud/core/DomainFailoverManager.h @@ -44,10 +44,10 @@ namespace TencentCloud /// back (dropping its region), trading geography drift for /// availability. /// Examples: -/// cvm.tencentcloudapi.com -> .com.cn -> .cn -/// hunyuan.ai.tencentcloudapi.com -> hunyuan.ai...com.cn -> .cn -/// cvm.intl.tencentcloudapi.com -> cvm...com.cn -> .cn (intl dropped) -/// cvm.ap-shanghai.tencentcloudapi.com -> cvm...com.cn -> .cn (region dropped) +/// cvm.tencentcloudapi.com -> cvm.tencentcloudapi.com.cn -> cvm.tencentcloudapi.cn +/// hunyuan.ai.tencentcloudapi.com -> hunyuan.ai.tencentcloudapi.com.cn -> hunyuan.ai.tencentcloudapi.cn +/// cvm.intl.tencentcloudapi.com -> cvm.tencentcloudapi.com.cn -> cvm.tencentcloudapi.cn (intl dropped) +/// cvm.ap-shanghai.tencentcloudapi.com -> cvm.tencentcloudapi.com.cn -> cvm.tencentcloudapi.cn (region dropped) /// /// This class is stateless; state (closed/open/halfopen) lives in CircuitBreaker. class DomainFailoverManager diff --git a/core/src/DomainFailoverManager.cpp b/core/src/DomainFailoverManager.cpp index ff4a537510..7ccabe1ece 100644 --- a/core/src/DomainFailoverManager.cpp +++ b/core/src/DomainFailoverManager.cpp @@ -89,10 +89,10 @@ DomainFailoverManager::ParsedEndpoint DomainFailoverManager::ParseEndpoint( std::string::size_type dot = prefix.find('.'); if (dot == std::string::npos) { - p.service = prefix; // service only, no middle/region + p.service = ToLowerAscii(prefix); // service only, no middle/region return p; } - p.service = prefix.substr(0, dot); + p.service = ToLowerAscii(prefix.substr(0, dot)); // First segment after service. std::string rest = prefix.substr(dot + 1); diff --git a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp index 8cdd5bee2b..c248fb2eab 100644 --- a/test/function_test/core/Core_DomainFailoverManager_Ft.cpp +++ b/test/function_test/core/Core_DomainFailoverManager_Ft.cpp @@ -250,6 +250,7 @@ TEST(ParseEndpointTest, NonTencentCloud) { TEST(ParseEndpointTest, CaseInsensitiveTldAndModifier) { auto p = DomainFailoverManager::ParseEndpoint("CVM.AI.TencentCloudApi.COM"); EXPECT_TRUE(p.is_tc_domain); + EXPECT_EQ(p.service, "cvm"); // normalized lower-case EXPECT_EQ(p.modifier, "ai"); // normalized lower-case EXPECT_TRUE(p.modifier_kept); EXPECT_EQ(p.tld, "tencentcloudapi.com");