Skip to content

Implement async http client#213

Merged
caleb2h merged 3 commits intomainfrom
feat/async-http-client
Apr 19, 2026
Merged

Implement async http client#213
caleb2h merged 3 commits intomainfrom
feat/async-http-client

Conversation

@gophergogo
Copy link
Copy Markdown
Collaborator

@gophergogo gophergogo commented Apr 19, 2026

Summary

  • Add HttpAsyncClient — a fire-and-forget HTTP/1.1 client built on top of HttpCodecFilter (client mode) and the MCP socket
    interface, for use by the forthcoming SSE gateway and other dispatcher-thread callers that need to emit outbound JSON-RPC-over-HTTP
    without blocking on DNS or shelling out to libcurl.
  • Each send() creates an isolated RequestContext owning its own connection + codec filter; completion hands the context to
    Dispatcher::deferredDelete so teardown runs past the current callback frame (same lifetime pattern as the crash fixes in Fix connection lifetime and init-path crashes #212).
  • Real-IO integration tests via RealListenerTestBase: successful POST round-trip with header + body assertions, malformed-URL
    rejection (no callback fires), and malformed-response → error-callback path.

Design notes

  • Dispatcher-thread only. send() asserts dispatcher_.isThreadSafe(); all callbacks fire on the dispatcher thread, matching the
    project-wide convention.
  • URL parser is strict on purpose. Accepts http(s)://<IPv4>[:port][/path]. No DNS here — callers running on the dispatcher thread
    must not block. Unsupported shapes return false from send() so callers get a clean error instead of a mystery connect attempt.
  • Codec installed as READ filter only. HttpCodecFilter's client-mode write path rewrites outgoing bytes into its own MCP-oriented
    request shape; we format the request ourselves so callers keep full control over method, path, and headers.
  • onBody dedup. Client-mode HttpCodecFilter emits each body chunk twice — once inline (end_stream=false) and once accumulated
    at message-complete (end_stream=true). We only take the final delivery.
  • No synchronous close from onMessageComplete. Closing the connection while the codec parser is mid-dispatch flushes the read
    buffer and makes its post-return drain(consumed) overshoot ("Drain size exceeds buffer length"). Instead, finishRequest hands the
    context to Dispatcher::deferredDelete; the connection and filter tear down at the next dispatcher pass.

Files

  • include/mcp/http/http_async_client.h — public API (HttpRequest, HttpResponse, HttpAsyncClient).
  • src/http/http_async_client.cc — implementation; per-request RequestContext inherits ConnectionCallbacks,
    HttpCodecFilter::MessageCallbacks, and DeferredDeletable.
  • tests/http/test_http_async_client.cc — 3 integration tests over a real ephemeral-port localhost listener.

Test plan

  • HttpAsyncClientTest.PostRoundTripDeliversResponseBody — full POST → server sees correct wire format → client parses 200 + body +
    headers.
  • HttpAsyncClientTest.RejectsMalformedUrlsend() returns false, neither callback fires (50 ms quiescent window).
  • HttpAsyncClientTest.MalformedResponseFiresErrorCallback — non-HTTP bytes → onError → error callback with codec message.
  • No references to legacy http_transport_socket.h.
  • Dispatcher-thread assertion on send(); teardown via deferredDelete.

Context

Second carve-out from PR #211's larger "feature/gateway-sse-transport" bundle, following the crash fixes in #212. This PR lands the outbound HTTP/1.1 client that the SSE gateway needs (GET /sse endpoint announcement, POST /callback response routing) without the SSE server transport itself, which will follow as PR C. Tests for the remaining integration-level gaps call out in #212's "out-of-scope" list will follow as PR D.

gophergogo added 3 commits April 19, 2026 15:35
The codec was only publishing the reason phrase in headers["status"] on
the client path. That is good for logging but useless for callers that
need to branch on the actual HTTP status (e.g. to treat a 2xx as success
and a 4xx as a client error). Mirror the :method pseudo-header the
server path already writes and populate headers[":status"] with the
numeric response code before firing onHeaders.

Comes with two unit tests that feed a minimal HTTP/1.1 response through
the filter and assert both :status and the original status reason land
on the callbacks' header map — guarding against future regressions in
onHeadersComplete's client branch.
HttpAsyncClient hosts one outbound TCP connection per send(), installs
the existing HttpCodecFilter in client mode as a read-only filter, and
formats the request bytes directly so callers keep full control over
method, path, and headers. Each request context implements
DeferredDeletable; on completion the client hands ownership to
Dispatcher::deferredDelete so the connection, codec filter, and
callbacks tear down past the current callback frame rather than
unwinding from inside their own callbacks.

Body handling reflects a quirk of the codec in client mode: each body
chunk is emitted twice, once inline and once again with the
accumulated body at message-complete. The client takes only the
end_stream delivery so the response body matches what arrived on the
wire.
Drive the client against a real 127.0.0.1 listener stood up by
RealListenerTestBase so the codec, deferred-delete teardown, and
connection lifecycle all run through live kernel sockets rather than
mocks. Covers: POST round-trip (request formatting and response body
delivery), malformed-URL rejection (send returns false and fires no
callback), and malformed response bytes (codec error surfaces as an
error callback).

Callbacks are captured through a shared_ptr so late firings from the
dispatcher teardown path cannot hit a destroyed sink on the test
stack.
@caleb2h caleb2h merged commit 2cacf41 into main Apr 19, 2026
1 check passed
caleb2h pushed a commit that referenced this pull request Apr 19, 2026
The codec was only publishing the reason phrase in headers["status"] on
the client path. That is good for logging but useless for callers that
need to branch on the actual HTTP status (e.g. to treat a 2xx as success
and a 4xx as a client error). Mirror the :method pseudo-header the
server path already writes and populate headers[":status"] with the
numeric response code before firing onHeaders.

Comes with two unit tests that feed a minimal HTTP/1.1 response through
the filter and assert both :status and the original status reason land
on the callbacks' header map — guarding against future regressions in
onHeadersComplete's client branch.
caleb2h pushed a commit that referenced this pull request Apr 19, 2026
HttpAsyncClient hosts one outbound TCP connection per send(), installs
the existing HttpCodecFilter in client mode as a read-only filter, and
formats the request bytes directly so callers keep full control over
method, path, and headers. Each request context implements
DeferredDeletable; on completion the client hands ownership to
Dispatcher::deferredDelete so the connection, codec filter, and
callbacks tear down past the current callback frame rather than
unwinding from inside their own callbacks.

Body handling reflects a quirk of the codec in client mode: each body
chunk is emitted twice, once inline and once again with the
accumulated body at message-complete. The client takes only the
end_stream delivery so the response body matches what arrived on the
wire.
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.

2 participants