Skip to content

[BUG] The curl retry path shares one jitter generator, redraws its deadline, and retries cancelled requests #4398

Description

@thc1006

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtriage/acceptedIndicates an issue or PR is ready to be actively worked on.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions