Skip to content

[DESIGN] HTTP transport requirements and options - #4458

Open
thc1006 wants to merge 7 commits into
open-telemetry:mainfrom
thc1006:design/http-transport-4448
Open

[DESIGN] HTTP transport requirements and options#4458
thc1006 wants to merge 7 commits into
open-telemetry:mainfrom
thc1006:design/http-transport-4448

Conversation

@thc1006

@thc1006 thc1006 commented Aug 19, 2026

Copy link
Copy Markdown
Member

This is the requirements and options document @lalitb asked for in #4448, as a pull request so it can be reviewed before anyone chooses an implementation direction.

It answers the question that was actually asked, which is whether the HTTP transport needs to manage asynchronous concurrency itself or whether a request and result model is enough with concurrency owned somewhere else. It does not propose class names, and it does not decide where retry belongs, which is one of the open decisions at the end. Whether HttpOperation survives is written down as @owent's preference with the evidence for it, not as a conclusion.

What is in it

The three shapes named in the discussion, compared against the criteria named with them: throughput and connection reuse, cancellation, shutdown, who owns admission and retry, connection and stream scheduling, and what happens to the installed interfaces.

Before that, a table of what every in-tree consumer does today, read from main rather than from memory. That table is the load-bearing part of the argument, so here is where each row came from:

  • Zipkin takes a HttpClientSync, in zipkin_exporter.h and its factory.
  • Elasticsearch calls handler->waitForResponse() in Export, in es_log_record_exporter.cc.
  • OtlpHttpClient::Export takes a max_running_requests budget, and its own comment says that only a sync export, meaning zero, needs to capture the result of the callback. The default path passes zero and waits.
  • OTELCPP_WITH_ASYNC_EXPORT_PREVIEW is OFF by default in the top level CMakeLists.txt.

So in the default configuration every exporter blocks until its request is done, and two of the three do it by handing the request to an asynchronous client and then waiting. That is the first of the two facts the document turns on. The second is @owent's production evidence in #4448, which is why the document does not conclude that the asynchronous machinery should go.

What is already settled

Three questions are recorded as settled rather than left open, on the strength of @owent's replies on #4448:

  • The existing max_concurrent_requests and max_requests_per_connection are legacy curl compatibility controls. A replacement is not bound by their semantics, only by the goals behind them.
  • A bounded governor needs both a count and a retained-byte dimension. 64 in flight at the 4 MB of the reported workload is 256 MB of request payload before responses or retries.
  • Session::SendRequest was never meant to be called more than once. That is the contract violated by the use after free I measured on main and reported in Proposal: Redesign the curl HttpClient API to address thread-safety and lifetime issues #4448.

One shared CURLM is now written as the preferred default for the curl backend rather than one option among three, with its sharing scope left to the benchmark. What that sharing costs has its own section: a reference counted runtime beneath separate logical clients rather than a global HttpClient, a hierarchical budget, a fairness requirement, and a shared failure domain.

Two sections come from reading the reports together rather than from the discussion. An export operation and an HTTP attempt are given separate state sequences, because a retried operation makes a second attempt with its own deadline, handle and result, and four of the open reports are about points where that second sequence is not respected today. The other section names what a transport rewrite does not fix: processor batching in #4449 and #4498, which decides the request geometry the transport is handed and so gates the benchmark; the untimed join in #4532, so a deadline honoured by every attempt still does not bound Shutdown; and #4534, a throw crossing a noexcept callback, which the completion contract has to answer for even where there are no exceptions to catch.

What is deliberately not in it

No requirement numbering scheme and no migration plan. You asked for something short, and the open decisions at the end are what has to be settled before either is worth writing.

The bug to invariant mapping is not repeated here. It is the matrix already in the discussion thread, which the document points back to, and which marks which reports are measured and which are only reasoned.

On the recommendation section

There is one, it is labelled as an opinion, and it is meant to be argued with rather than accepted. Short version: define the one attempt semantics as the contract, keep the current interface working through an adapter, and treat the single owner event loop as the curl backend's implementation for the case where concurrency is actually wanted. I would also want the concurrency question settled by a measurement rather than by assumption.

If the shape of the document is useful but the recommendation is not, the recommendation is one section and can go.

For significant contributions please make sure you have completed the following items:

  • CHANGELOG.md updated for non-trivial changes
  • Unit tests have been added
  • Changes in public API reviewed

@thc1006
thc1006 force-pushed the design/http-transport-4448 branch from 5207f9e to d837491 Compare August 23, 2026 23:29
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.47%. Comparing base (312acb6) to head (163d7ca).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #4458   +/-   ##
=======================================
  Coverage   83.47%   83.47%           
=======================================
  Files         522      522           
  Lines       20440    20440           
=======================================
  Hits        17061    17061           
  Misses       3379     3379           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006
thc1006 marked this pull request as ready for review August 27, 2026 16:35
@thc1006
thc1006 requested a review from a team as a code owner August 27, 2026 16:35
@thc1006
thc1006 force-pushed the design/http-transport-4448 branch from 0fea657 to 16db2fc Compare September 8, 2026 05:27
Written for the question in open-telemetry#4448: does the HTTP transport need to manage
asynchronous concurrency itself, or is a request and result model enough with
concurrency owned somewhere else.

Sets out what every in-tree consumer actually does, where the current model is
inconsistent with itself, and compares the three shapes named in the discussion:
the current Session and EventHandler model, an implementation neutral request
and result, and a single owner CURLM event loop for the case where transport
level concurrency is wanted.

It deliberately does not decide whether HttpOperation should exist, whether a
Session carries one request, or which layer owns retry. Those are listed as
decisions instead, together with the CMake and Bazel surfaces that disagree
about the concrete curl headers today.

The evidence is the invariant matrix already posted in open-telemetry#4448, linked rather than
repeated, so this stays short enough to read in one sitting.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ocument

The first draft turned on one fact, that every default consumer blocks, and read
as an argument for dropping the asynchronous machinery. @owent supplied the
other half on open-telemetry#4448: a high volume logging workload with roughly 100 ms of
network latency and batches past 4 MB, where a collector that was not saturated
still dropped data until about four requests were allowed in flight.

That does not contradict the first fact but it does change what follows from it.
Concurrency is a capability the transport has to have; what the blocking callers
want is not to pay for it. So the document now asks for a contract where it is
available, bounded and configurable rather than for one that picks a side, and
option B is an asynchronous one attempt operation with blocking as an adapter
over it, not a blocking call.

Also corrected: the specification's wording differs by transport, and this
document is about HTTP. Concurrent unary calls are a SHOULD for gRPC. For HTTP
the text is that a client MAY use several parallel connections, with the maximum
SHOULD be configurable.

Adds the benchmark matrix the concurrency question needs, because the only OTLP
HTTP benchmark in the tree runs against localhost with a 1 ms timeout, and
records the open question that the option defaults to 64 requests in flight
while the one reported workload needed about four.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…ution holds

@owent's second reply on open-telemetry#4448 says the two options were written around curl and
that a replacement is not bound by them. Reading them says what a replacement
would have to keep, and it is less than the names suggest.

max_concurrent_requests is a watermark applied after the request has been built
and handed to the client, not an admission limit. max_requests_per_connection
counts nothing per connection: every Nth session by global id sets
CURLOPT_FRESH_CONNECT and CURLOPT_FORBID_REUSE, which is a rotation rather than
a quota. Neither shapes what is on the wire, because the client sets none of
CURLMOPT_MAX_HOST_CONNECTIONS, CURLMOPT_MAX_TOTAL_CONNECTIONS,
CURLMOPT_MAX_CONCURRENT_STREAMS, CURLMOPT_PIPELINING or CURLOPT_HTTP_VERSION.

The same reply says requests added to one CURLM without disabling reuse can look
concurrent while running serially. That holds on one connection that cannot
multiplex, which is the HTTP/1.1 case, and not on several connections or on
HTTP/2 and HTTP/3 streams. The document now says which, because it decides how
many numbers the replacement needs rather than one.

Also adds a byte budget and a separate export operation and attempt identity to
the decision list, and makes the benchmark name its rate units and read back the
negotiated protocol and connection count rather than trusting the option values.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The comparison table said option C keeps retry and concurrency inside the
backend while the recommendation put a scheduler above the transport owning
exactly those. One row cannot answer for both, so it is now two: admission and
retry above, connection and stream scheduling below.

max_concurrent_requests currently stands for a record rate, an attempt count and
a connection count depending on who is reading it, so the document now names
those apart before comparing anything. The reported workload is a record rate;
"about four" is an attempt count.

The byte bound, the separation of export operations from attempts, and
exactly-once settlement move out of the open questions and into requirements,
since no answer to the remaining questions makes them optional.

The decision list splits into what open-telemetry#4448 has settled and what is still open, so
a reviewer can object to one line rather than re-read the thread.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
18231989 settles two things the open list still asked. The existing options are
legacy curl compatibility controls rather than requirements of a replacement, and
a bounded governor needs both a count and a retained-byte dimension rather than
either alone. It also states a preference for one shared CURLM while leaving the
sharing scope to the benchmark.

18233325 settles a third. Session::SendRequest was never meant to be called more
than once, which is the contract the use after free measured on main violates, so
the document states it instead of asking it.

What sharing a multi handle costs now has its own section: a reference counted
runtime under separate logical clients rather than a global HttpClient, a
hierarchical budget because one per-client limit stops bounding the process once
three exporters share it, a fairness requirement, and a failure domain, since
libcurl leaves every transfer on a multi handle undefined when socket_action
fails. The benchmark grows the cases that measure isolation rather than speed.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The vocabulary table already named an export operation and an attempt as
different things without saying that they move through different states.
They do, and today they share one set of fields, which is where several of
the reports come from: a retried operation gets a second attempt with its
own deadline, handle and result, and if those live on the operation the
second overwrites the first. Each boundary in the attempt sequence is a
report already filed, from a rejected curl_multi_add_handle that never
runs to CURLOPT_PRIVATE naming a Session rather than the attempt whose
completion is arriving.

The second section names three accepted issues the rewrite does not fix,
so the scope is not read wider than it is. Processor batching decides the
request geometry the transport is handed, and open-telemetry#4498 measured 2,332 export
calls for 50,000 records with one batch at or above 2,048, so a benchmark
run before that is settled reports the processor. open-telemetry#4532 shows an untimed
join above the exporter, so a deadline honoured by every attempt still
does not bound Shutdown. open-telemetry#4534 is a throw crossing a noexcept callback,
which the completion contract has to answer for, including where there
are no exceptions to catch.

One word elsewhere: an export operation is accepted, not admitted, since
the table reserves admitted for attempts the governor has let start.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the design/http-transport-4448 branch from 16db2fc to 76fb5e3 Compare September 8, 2026 07:33
The option is `OTELCPP_WITH_ASYNC_EXPORT_PREVIEW`. The old spelling still
works but is deprecated, and the document was citing it.

"The configuration almost everyone builds" was a claim about how many
people build what, which I cannot observe. What is checkable is that the
option is off by default, so the sentence now says the default
configuration and nothing more.

"Each boundary in the second sequence is a report" was not true of the
sequence I had just written. It has seven boundaries and four bullets
follow it, one of which is not a boundary at all. Four of the open
reports are about that sequence, so it says that instead.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant