Skip to content

Add NewConnectedRoundTripper for race-aware callers#6

Merged
myleshorton merged 2 commits into
mainfrom
adam/connected-roundtripper
Apr 19, 2026
Merged

Add NewConnectedRoundTripper for race-aware callers#6
myleshorton merged 2 commits into
mainfrom
adam/connected-roundtripper

Conversation

@myleshorton
Copy link
Copy Markdown
Contributor

Summary

Adds `Client.NewConnectedRoundTripper(ctx, addr)` that blocks until a front is actually dialed (TLS handshake complete) and returns a one-shot RoundTripper bound to that connection.

Motivation: `Client.RoundTripper()` returns a lazy wrapper — it doesn't touch the network until `RoundTrip` is called. That breaks race-based callers like getlantern/kindling's `raceTransport`, which expect transports to block on real readiness so the race reflects actual wire-level latency instead of insertion order. A lazy wrapper "connects" instantly and always wins the race, hiding failures.

API

```go
rt, err := client.NewConnectedRoundTripper(ctx, "")
// rt is now bound to a TLS-connected front; use it for exactly one request
resp, err := rt.RoundTrip(req)
```

  • Blocks until a front is taken from the pool AND the TLS handshake completes (or `ctx` cancels / `maxRetries` is exhausted).
  • One-shot: after the request, success / failure is reported to the pool. Call again for the next request.
  • `addr` is accepted for parity with other "connect to addr" transport APIs but ignored — the front (and therefore the dialed destination) is chosen by the pool.

Changes

  • `connected.go` (new) — `NewConnectedRoundTripper` + `connectedRoundTripper` struct that reuses the existing `doRequest`/`rewriteRequest`/`getBodyFactory` machinery.
  • `integration_test.go` — `TestIntegration_NewConnectedRoundTripper` fetches `/ping` on `borda.lantern.io` through the returned RT.

Test plan

  • `go build ./...` / `go vet ./...` pass
  • `go test ./...` (unit) passes
  • `go test -tags integration -run TestIntegration_NewConnectedRoundTripper` passes against real CloudFront — 202 from `borda.lantern.io`

🤖 Generated with Claude Code

Client.RoundTripper() returns a lazy http.RoundTripper — it doesn't touch
the network until RoundTrip is called. That works for standalone callers
but defeats race-based transports (e.g. getlantern/kindling's race
transport) that expect their transports to block until a connection is
actually ready: a lazy wrapper "connects" instantly and always wins the
race, hiding failures and ordering requests by insertion rather than by
real readiness.

Add NewConnectedRoundTripper(ctx, addr) which blocks until a front is
taken from the pool AND the TLS handshake to that front completes.
Returns a one-shot RoundTripper bound to that specific connection; if
the request fails, the front is marked failed; on success it's requeued.

addr is accepted for API parity with other "connect to addr" transports
but is ignored — domainfront always dials the front chosen by the pool.

Integration test: fetches /ping on borda.lantern.io through the returned
RT. Passes against real CloudFront infrastructure (status=202).
Copilot AI review requested due to automatic review settings April 19, 2026 16:02
myleshorton added a commit to getlantern/kindling that referenced this pull request Apr 19, 2026
Two review comments on #31:

1. (Copilot, kindling.go:176) WithDomainFronting returned a cached
   http.RoundTripper immediately without doing any connection work, which
   defeats raceTransport's "connect in parallel, send serially by real
   readiness" design — the fronted transport would always "win" the race
   before anyone else had done real network work. Switch to the new
   domainfront.Client.NewConnectedRoundTripper (getlantern/domainfront#6),
   which blocks until a front is taken from the pool and the TLS handshake
   completes. Each race attempt now gets a one-shot RT bound to a real
   pre-dialed connection.

2. (Copilot, kindling_test.go:210) TestLantern_DomainFronting asserted a
   non-empty response body, but a successful fronted request can
   legitimately have an empty body (204/304/405-with-no-body). Drop the
   body-length assertion — status-code assertions are the actual signal
   that domain fronting worked.

Verified against real CDN infrastructure:
  KINDLING_INTEGRATION=1 go test -run TestLantern_DomainFronting
  → PASS (405 from config.getiantem.org through the pre-connected RT).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new API, Client.NewConnectedRoundTripper(ctx, addr), to support race-based callers by returning a RoundTripper that only becomes available after a real TLS connection to a selected front has been established.

Changes:

  • Adds Client.NewConnectedRoundTripper plus a connectedRoundTripper implementation that reuses existing request rewrite/body buffering/doRequest logic.
  • Adds an integration test validating that NewConnectedRoundTripper blocks until a TLS-connected front is available and can complete a real /ping request via CloudFront.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
connected.go Adds the new connected, one-shot RoundTripper API and implementation.
integration_test.go Adds an integration test exercising the new API against a real endpoint.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread connected.go
Comment thread connected.go Outdated
Comment thread integration_test.go Outdated
Comment thread connected.go
Four Copilot comments on #6:

1. (connected.go:72) connectedRoundTripper was documented as one-shot but
   nothing enforced it. A second RoundTrip call would reuse an already-
   closed conn and double-report success/failure to the pool, enqueueing
   the same front twice. Added an atomic.Bool `used` guard; the second
   call returns the new exported sentinel ErrRoundTripperUsed.

2. (connected.go:50) If the caller never called RoundTrip (e.g. a losing
   racer), the taken front and pre-dialed conn leaked until GC. Added
   io.Closer: Close returns the front to the pool (non-success) and
   closes the conn. Uses the same `used` guard so Close after RoundTrip
   is a no-op and double-Close is safe.

3. (connected.go:53) On retry exhaustion the last dial error was
   discarded and the caller got a generic "could not connect to any
   front". Now wraps the last error and reports the attempt count:
   "could not connect to any front after N attempts: <last err>".

4. (integration_test.go:195) io.ReadAll error was ignored, so a truncated
   body would silently look like success. Asserts require.NoError.

Unit tests in connected_test.go cover: second RoundTrip returns
ErrRoundTripperUsed; Close returns front + closes conn + is idempotent;
RoundTrip with an unmapped host closes the conn exactly once and a
subsequent Close is a no-op.

Integration test still passes against real CloudFront.
@myleshorton myleshorton merged commit 0bff0b2 into main Apr 19, 2026
1 check passed
myleshorton added a commit to getlantern/kindling that referenced this pull request Apr 19, 2026
Moves from the adam/connected-roundtripper branch tag to the released
main-branch SHA containing NewConnectedRoundTripper, Close, and the
single-use guard (getlantern/domainfront#6).
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