Describe your environment main at f6e4818, Ubuntu 24.04, gcc 14, libcurl 8.14.1, CMake, -fsanitize=thread.
Three separate things in the retry path. The first is a data race and I have it under a sanitizer. The other two are from reading, and I say so.
1. The jitter generator is a shared mutable static
std::chrono::system_clock::time_point HttpOperation::NextRetryTime()
{
...
static std::random_device rd; // :621
static std::mt19937 gen(rd()); // :622
static std::uniform_real_distribution<float> dis(0.8f, 1.2f); // :623
...
backoff *= dis(gen); // :641
Every HttpClient runs its own background thread, and dis(gen) advances the engine's state, so two clients retrying at once mutate one std::mt19937. Initialisation of a function local static is thread safe; using it is not.
NextRetryTime() is public and is not itself behind ENABLE_OTLP_RETRY_PREVIEW, so a test can call it directly and the static ships in every build:
TEST_F(BasicCurlHttpTests, RetryJitterRace)
{
...
HttpOperation a(Method::Get, "http://127.0.0.1:19000/", ssl, nullptr, headers, body);
HttpOperation b(Method::Get, "http://127.0.0.1:19000/", ssl, nullptr, headers, body);
std::thread t1([&a] { for (int i = 0; i < 200; ++i) { (void)a.NextRetryTime(); } });
std::thread t2([&b] { for (int i = 0; i < 200; ++i) { (void)b.NextRetryTime(); } });
t1.join();
t2.join();
}
Three warnings per run, three runs out of three:
WARNING: ThreadSanitizer: data race
Read of size 8 by thread T3:
#0 std::mersenne_twister_engine<...>::operator()() /usr/include/c++/14/bits/random.tcc:458
#5 HttpOperation::NextRetryTime() ext/src/http/client/curl/http_operation_curl.cc:641
thread_local on the engine fixes it and keeps the jitter, which is the whole point of the draw.
2. The retry time is redrawn every time it is asked for
doRetrySessions() calls operation->NextRetryTime() on every pass of the IO loop, and NextRetryTime() recomputes backoff *= dis(gen) on every call. The deadline is therefore not a point in time, it is a fresh sample each time somebody asks. A session can be due on one pass and not due on the next.
That also undermines the ordering the queue assumes. pending_to_retry_sessions_ is a deque and the loop breaks at the first session that is not due yet, which is only correct if deadlines are ordered. They are not: per request retry policies differ, Retry-After can name any time, and now the jitter is redrawn under the comparison. #4186 already noted the sessions are no longer sorted by retry time.
Computing the deadline once, in PerformCurlMessage, and storing it would make the value stable. Whether the queue then needs anything cleverer than scanning it is a separate question, and for the number of retries an exporter has in flight, a scan is probably enough.
3. A cancelled session can still be retried
return is_retryable && (last_curl_result_ == CURLE_OK) &&
(retry_attempts_ < retry_policy_.max_attempts); // :607
No WasAborted(). doRetrySessions() does not check it either, and it calls curl_multi_remove_handle and curl_multi_add_handle on GetCurlEasyHandle() without a null check or a look at whether the operation was cleaned. So a cancel that lands after doAbortSessions has run and before doRetrySessions does can leave a cancelled transfer queued, and re-arm it.
What is the expected behavior? Retry jitter is per thread or otherwise not shared, a retry deadline is decided once, and a cancelled request is not retried.
What is the actual behavior? The engine is shared across client threads, the deadline moves every time it is read, and cancellation is not part of the retry decision.
Additional context Item 1 stands alone and is small: it does not depend on anything else and it is the one I would send first if you want it. Items 2 and 3 are entangled with the queue, and item 3 overlaps the teardown ordering in #4391, so those are better after it.
All three are inside the retry preview in practice, since IsRetryable() returns false without ENABLE_OTLP_RETRY_PREVIEW and nothing enters the queue. The static in item 1 is compiled either way.
Found while reading the cancellation paths for #4390 and #4391.
Describe your environment
mainat f6e4818, Ubuntu 24.04, gcc 14, libcurl 8.14.1, CMake,-fsanitize=thread.Three separate things in the retry path. The first is a data race and I have it under a sanitizer. The other two are from reading, and I say so.
1. The jitter generator is a shared mutable static
Every
HttpClientruns its own background thread, anddis(gen)advances the engine's state, so two clients retrying at once mutate onestd::mt19937. Initialisation of a function local static is thread safe; using it is not.NextRetryTime()is public and is not itself behindENABLE_OTLP_RETRY_PREVIEW, so a test can call it directly and the static ships in every build:Three warnings per run, three runs out of three:
thread_localon the engine fixes it and keeps the jitter, which is the whole point of the draw.2. The retry time is redrawn every time it is asked for
doRetrySessions()callsoperation->NextRetryTime()on every pass of the IO loop, andNextRetryTime()recomputesbackoff *= dis(gen)on every call. The deadline is therefore not a point in time, it is a fresh sample each time somebody asks. A session can be due on one pass and not due on the next.That also undermines the ordering the queue assumes.
pending_to_retry_sessions_is a deque and the loopbreaks at the first session that is not due yet, which is only correct if deadlines are ordered. They are not: per request retry policies differ,Retry-Aftercan name any time, and now the jitter is redrawn under the comparison. #4186 already noted the sessions are no longer sorted by retry time.Computing the deadline once, in
PerformCurlMessage, and storing it would make the value stable. Whether the queue then needs anything cleverer than scanning it is a separate question, and for the number of retries an exporter has in flight, a scan is probably enough.3. A cancelled session can still be retried
No
WasAborted().doRetrySessions()does not check it either, and it callscurl_multi_remove_handleandcurl_multi_add_handleonGetCurlEasyHandle()without a null check or a look at whether the operation was cleaned. So a cancel that lands afterdoAbortSessionshas run and beforedoRetrySessionsdoes can leave a cancelled transfer queued, and re-arm it.What is the expected behavior? Retry jitter is per thread or otherwise not shared, a retry deadline is decided once, and a cancelled request is not retried.
What is the actual behavior? The engine is shared across client threads, the deadline moves every time it is read, and cancellation is not part of the retry decision.
Additional context Item 1 stands alone and is small: it does not depend on anything else and it is the one I would send first if you want it. Items 2 and 3 are entangled with the queue, and item 3 overlaps the teardown ordering in #4391, so those are better after it.
All three are inside the retry preview in practice, since
IsRetryable()returns false withoutENABLE_OTLP_RETRY_PREVIEWand nothing enters the queue. The static in item 1 is compiled either way.Found while reading the cancellation paths for #4390 and #4391.