diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c886bc47..7752d4b6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Increment the: ## [Unreleased] +* [OTLP/HTTP] Honor `Retry-After` response header when retrying exports, + supporting both delay-seconds and HTTP-date formats per RFC 7231 ยง7.1.3. + [#4172](https://github.com/open-telemetry/opentelemetry-cpp/issues/4172) * [CODE HEALTH] Move api/test and sdk/test classes into anonymous namespace [#4217](https://github.com/open-telemetry/opentelemetry-cpp/pull/4217) diff --git a/api/include/opentelemetry/common/timestamp.h b/api/include/opentelemetry/common/timestamp.h index f7c79b8b57..9dd142a066 100644 --- a/api/include/opentelemetry/common/timestamp.h +++ b/api/include/opentelemetry/common/timestamp.h @@ -3,9 +3,17 @@ #pragma once +#include #include #include +#include +#include +#include +#include +#include +#include "opentelemetry/common/string_util.h" +#include "opentelemetry/nostd/string_view.h" #include "opentelemetry/version.h" OPENTELEMETRY_BEGIN_NAMESPACE @@ -202,5 +210,103 @@ class DurationUtil } }; +/** + * @brief Parsing helpers for RFC 7231 value formats (HTTP-date, section + * 7.1.1.1, and delay-seconds, section 7.1.3) that appear in headers such + * as Retry-After, Date, Last-Modified, and Expires. + */ +class HttpUtil +{ +public: + static bool ParseDelaySeconds(nostd::string_view value, std::chrono::seconds &delay) noexcept + { + value = StringUtil::Trim(value); + if (value.empty()) + { + return false; + } + + std::chrono::seconds::rep result = 0; + for (const char c : value) + { + if (!std::isdigit(static_cast(c))) + { + return false; + } + auto digit = c - '0'; + if (result > (std::numeric_limits::max() - digit) / 10) + { + return false; + } + result = result * 10 + digit; + } + + delay = std::chrono::seconds(result); + return true; + } + + static bool ParseHttpDate(nostd::string_view value, std::chrono::system_clock::time_point &date) + { + value = StringUtil::Trim(value); + std::string str(value.data(), value.size()); + std::tm tm = {}; + std::istringstream ss(str); + + ss >> std::get_time(&tm, "%a, %d %b %Y %H:%M:%S"); + if (!ss.fail()) + { + date = std::chrono::system_clock::from_time_t(PortableTimegm(&tm)); + return true; + } + + ss.clear(); + ss.str(str); + tm = {}; + ss >> std::get_time(&tm, "%A, %d-%b-%y %H:%M:%S"); + if (!ss.fail()) + { + std::time_t now_t = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + int current_year = 1970 + static_cast(now_t / 31556952); + int full_year = 1900 + tm.tm_year; + if (full_year - current_year > 50) + { + full_year -= 100; + } + tm.tm_year = full_year - 1900; + date = std::chrono::system_clock::from_time_t(PortableTimegm(&tm)); + return true; + } + + ss.clear(); + ss.str(str); + tm = {}; + ss >> std::get_time(&tm, "%a %b %d %H:%M:%S %Y"); + if (!ss.fail()) + { + date = std::chrono::system_clock::from_time_t(PortableTimegm(&tm)); + return true; + } + + return false; + } + +private: + static std::time_t PortableTimegm(std::tm *tm) noexcept + { + int year = tm->tm_year + 1900; + int month = tm->tm_mon + 1; + if (month <= 2) + { + year -= 1; + month += 12; + } + int day = tm->tm_mday; + int days = 365 * year + year / 4 - year / 100 + year / 400 + (153 * (month - 3) + 2) / 5 + day - + 719469; + return static_cast(days) * 86400 + tm->tm_hour * 3600 + tm->tm_min * 60 + + tm->tm_sec; + } +}; + } // namespace common OPENTELEMETRY_END_NAMESPACE diff --git a/ext/include/opentelemetry/ext/http/client/curl/http_operation_curl.h b/ext/include/opentelemetry/ext/http/client/curl/http_operation_curl.h index 541b74a85c..6ac23a3393 100644 --- a/ext/include/opentelemetry/ext/http/client/curl/http_operation_curl.h +++ b/ext/include/opentelemetry/ext/http/client/curl/http_operation_curl.h @@ -348,6 +348,7 @@ class HttpOperation const RetryPolicy retry_policy_; decltype(RetryPolicy::max_attempts) retry_attempts_; std::chrono::system_clock::time_point last_attempt_time_; + std::chrono::system_clock::time_point retry_after_time_point_{}; // Processed response headers and body // See CURLINFO_RESPONSE_CODE, type is long diff --git a/ext/src/http/client/curl/http_operation_curl.cc b/ext/src/http/client/curl/http_operation_curl.cc index abd8b3ea77..263b0acec9 100644 --- a/ext/src/http/client/curl/http_operation_curl.cc +++ b/ext/src/http/client/curl/http_operation_curl.cc @@ -8,8 +8,10 @@ # include #endif // ENABLE_OTLP_RETRY_PREVIEW +#include #include #include +#include #include #include #include @@ -23,6 +25,7 @@ #include #include +#include "opentelemetry/common/timestamp.h" #include "opentelemetry/ext/http/client/curl/http_client_curl.h" #include "opentelemetry/ext/http/client/curl/http_operation_curl.h" #include "opentelemetry/ext/http/client/http_client.h" @@ -35,6 +38,55 @@ # define CURL_VERSION_BITS(x, y, z) ((x) << 16 | (y) << 8 | (z)) #endif +namespace +{ + +bool FindRetryAfterValue(const std::vector &raw_headers, std::string &value) +{ + if (raw_headers.empty()) + { + return false; + } + + const char *data = reinterpret_cast(raw_headers.data()); + const char *end = data + raw_headers.size(); + const char *line = data; + + while (line < end) + { + const char *line_end = line; + while (line_end < end && *line_end != '\n') + { + ++line_end; + } + + static const char kRetryAfterHeader[] = "retry-after:"; + static const size_t kRetryAfterLen = sizeof(kRetryAfterHeader) - 1; + + size_t line_len = static_cast(line_end - line); + if (line_len > kRetryAfterLen) + { + bool match = true; + for (size_t i = 0; i < kRetryAfterLen && match; ++i) + { + match = (std::tolower(static_cast(line[i])) == kRetryAfterHeader[i]); + } + + if (match) + { + value = std::string(line + kRetryAfterLen, line_end); + return true; + } + } + + line = (line_end < end) ? line_end + 1 : end; + } + + return false; +} + +} // namespace + OPENTELEMETRY_BEGIN_NAMESPACE namespace ext { @@ -560,6 +612,11 @@ bool HttpOperation::IsRetryable() std::chrono::system_clock::time_point HttpOperation::NextRetryTime() { + if (retry_after_time_point_ != std::chrono::system_clock::time_point{}) + { + return retry_after_time_point_; + } + static std::random_device rd; static std::mt19937 gen(rd()); static std::uniform_real_distribution dis(0.8f, 1.2f); @@ -1463,8 +1520,9 @@ void HttpOperation::Abort() void HttpOperation::PerformCurlMessage(CURLcode code) { ++retry_attempts_; - last_attempt_time_ = std::chrono::system_clock::now(); - last_curl_result_ = code; + last_attempt_time_ = std::chrono::system_clock::now(); + last_curl_result_ = code; + retry_after_time_point_ = std::chrono::system_clock::time_point{}; if (code != CURLE_OK) { @@ -1513,6 +1571,27 @@ void HttpOperation::PerformCurlMessage(CURLcode code) if (IsRetryable()) { + + std::string retry_after; + if (FindRetryAfterValue(response_headers_, retry_after)) + { + std::chrono::seconds delay; + + if (opentelemetry::common::HttpUtil::ParseDelaySeconds(retry_after, delay)) + { + retry_after_time_point_ = std::chrono::system_clock::now() + delay; + } + else + { + std::chrono::system_clock::time_point date; + if (opentelemetry::common::HttpUtil::ParseHttpDate(retry_after, date)) + { + auto now = std::chrono::system_clock::now(); + retry_after_time_point_ = (date > now) ? date : now; + } + } + } + // Clear any response data received in previous attempt ReleaseResponse(); // Rewind request data so that read callback can re-transfer the payload