Skip to content

Share one uTLS HTTP/2 connection per proxy across requests - #1

Merged
khairm merged 14 commits into
edgee-oauth-base-urlfrom
shared-utls-connection
Nov 6, 2026
Merged

khairm merged 14 commits into
edgee-oauth-base-urlfrom
shared-utls-connection

Conversation

@khairm

@khairm khairm commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Why

Each Codex/Claude request built a fresh uTLS client, so connections were never reused and each request left an idle proxy tunnel open for about 7 minutes. Through the home proxy, that used up its 1,024 file-descriptor limit.

What

  • Shared transport: one http2.Transport per proxy URL for the whole process. No idle or ping timeouts (AGENTS.md:59); a connection the server closes is redialled on the next request.
  • Connection pool: a small ClientConnPool dials once per host:port. The dial runs separately from any single request and is limited to 30s. Each waiting request can give up when its own context ends.
  • Handled by the transport:
    • stream reservation;
    • retries on GOAWAY, REFUSED_STREAM and unusable connections;
    • keeping the connection when one stream is cancelled.
  • Proxy dialer: the HTTP CONNECT dialer now follows the context for the dial, the TLS handshake and the CONNECT exchange.
  • Protocol check: the request fails with a clear error if ALPN is not h2.
  • Invalid proxy URL: every request fails with an error. It no longer falls back silently to a direct or default connection, and the bad setting is never cached.
  • Other paths: the standard (HTTP/1.1) proxy transport is now shared per proxy URL as well. This covers non-protected hosts, a custom base_url and the image endpoints.
  • Claude OAuth: its client now uses the shared uTLS transport, and its separate copy is removed.
  • Version: release 7.2.62+edgee.8.

Tests

go test -race passes on helps, proxyutil, auth/claude and cmd/server. Every test builds a fresh client per request through NewUtlsHTTPClient. Each test below was checked to fail when its fix is reverted:

  • 50 concurrent requests use 1 tunnel (with the fix reverted: 50 tunnels).
  • After the server closes an idle connection, the next request succeeds on one new tunnel.
  • With a silent proxy, 5 requests come back at their 300ms deadline, using 1 shared dial (with the fix reverted: they hang for 30s).
  • With repeated GOAWAYs during 200 requests, all succeed (without the transport's retries: 87 fail).
  • After a cancelled request, the connection is kept and 1 tunnel is used.
  • A server without HTTP/2 is rejected, and an invalid proxy URL is rejected.

The full go test ./... has one failure, internal/api TestModelsWithClientVersionReturnsCodexCatalog. It also fails on the parent commit 7da1f0f, so it is unrelated.

Live proof (ovh, through the switchboard and the Pi)

50 concurrent requests to chatgpt.com all returned over HTTP/2 (no login was sent) and used 1 tunnel. With no client idle timeout, chatgpt.com closed it after 6m31s-6m46s idle; the next request succeeded on 1 new tunnel.

After merge

Tag v7.2.62-edgee.8 on the merged commit. Then point Self-Hosted flake.nix at that tag instead of the commit. Branch cliproxyapi-shared-utls-connection currently pins d6a12af.

Cache growth: one entry per proxy and account; an account's entries are dropped when it is removed or its proxy changes.

Round 2 (c65ae5c)

  • 2xx CONNECT replies: body never read or closed (RFC 9110 §9.3.6), tunnel bytes intact; a passed dial deadline reports context.DeadlineExceeded.
  • proxyutil.FailingTransport closes the request body on invalid-proxy errors (replaces two copies).
  • Codex/xAI websocket dialer and the per-auth round-tripper provider fail on an invalid proxy instead of going direct.
  • proxy-url validated at config load and on management PUT/PATCH and auth-file edits (400).
  • Version strings, README rebase range and the release test gate updated for edgee.8.
  • Owner decision: no post-connection timeouts (idle or ping), per AGENTS.md:59.

Round 3 (4bb0249)

  • A shared connection whose request context ends with no bytes received on it since the request began (a dead tunnel) is removed from the pool and shut down; the next request dials a fresh one. No timer. A connection still receiving frames keeps a cancelled stream's neighbours.
  • One replay after an abrupt close (unexpected EOF, reset) for idempotent requests or ones with an idempotency key; POSTs are never replayed.
  • Request body is closed when no connection was obtained (cancelled while waiting on the shared dial).
  • Pools keyed by the normalised proxy setting (direct for no proxy) plus the auth ID (88b50d2, owner decision): each subscription account reuses its own connection and accounts never share one. Callers with no auth (config-level clients, login flows) use their own key.
  • Shared dial errors read utls: shared dial to <addr> failed: ....
  • proxyutil.Parse requires a hostname, a port in 1-65535 when given, and a port for SOCKS5.
  • util.SetProxy and the Antigravity model-hints fetch fail loudly on a bad proxy instead of going direct.
  • Auth-file PATCH validates the whole patch first (proxy_url must be a valid string, no proxy_url.* writes) and applies it to a deep copy; uploads reject a bad proxy_url with 400.
  • New tests (each fails with its fix reverted): silent tunnel is dropped and redialled; a receiving connection survives a cancelled stream; 100 abrupt closes with no failed GET; body closed when no connection is obtained; equivalent proxy settings share one transport; two accounts through one proxy use 2 tunnels while repeat requests per account share one; table tests for every config handler family and auth-file PATCH/upload returning 400 with config, files and live auth unchanged.

Round 4 (d6a12af)

  • Requests go straight to the pooled ClientConn instead of http2.Transport's retry loop. Retried (up to 6): requests the server confirmed it never processed — conn unusable before the stream opened, GOAWAY above the last stream id, REFUSED_STREAM. Replayed once, only for idempotent methods or an idempotency key: abrupt close or peer PROTOCOL_ERROR. A plain POST is never sent twice.
  • Each attempt's body (including rewound retry bodies) is closed exactly once.
  • A request whose context already ended reserves no connection and never counts towards dead-connection detection. A quick cancel on a quiet connection costs one redial.
  • Claude token refreshes use the account's own pool (NewClaudeAuthForAccount); login flows keep the account-less pool. Codex refresh builds its own transport per refresh, so it never mixes accounts.
  • cliproxyauth.OnAuthReleased: on auth removal or a proxy URL change, that account's shared transports are dropped and their connections shut down after open streams finish.
  • Proxy URLs with a malformed host:port (socks5://localhost:1080:80) are rejected.
  • Auth-file PATCH proxy_url: null clears the live proxy, matching the saved file.
  • New tests (each fails with its fix reverted): POST not replayed after PROTOCOL_ERROR or an abrupt close, POST retried after REFUSED_STREAM, GET replayed once (server hit counts); every attempt body closed once; cancelled requests keep the shared connection; removed or re-proxied accounts lose their connections; PATCH failing mid-apply leaves the live record unchanged; null proxy_url clears the live proxy. Added to the release race gate.
  • Live (ovh, switchboard and the Pi): one account, 50 concurrent requests on 1 tunnel; two accounts at once on 2 tunnels.

🤖 Generated with Claude Code

Round 5 (fbf365c)

  • Removed the round-4 OnAuthReleased hook and per-account release: it raced in-flight requests and global proxy changes. Pools are never evicted; a stale one holds memory only, because the upstream closes its idle tunnel (chatgpt.com measured at about 6.5 min).
  • Retries of requests the server did not process now wait x/net's schedule (none, then 1s, 2s, 4s… plus up to 10% jitter) and stop when the request's context ends. This is a wait before sending, not a post-connection timer.
  • PROTOCOL_ERROR is replayed only when the server sent the reset (received from peer); a locally detected malformed response is not replayed.
  • Removed the not-established mark-dead branch: x/net's read loop already removes that connection from the pool.
  • New tests: POST retried after a GOAWAY below its stream, POST not resent after a GOAWAY covering it, idempotency-keyed POST replayed once after an abrupt close, GET not replayed after a malformed response, backoff schedule. Release gate updated.
  • Known and unchanged from base: the manager's account failover can still resend a POST on another account after a connection drop.

Round 6

  • Restored eviction of a conn that dies before its first stream (http2: client conn could not be established): x/net leaves it reservable, so later requests failed on it without redialling. Regression test TestReviewNotEstablishedConnIsReused.
  • The body is rewound before the backoff wait; a failing GetBody is returned as its own error, and the rewound body is closed if the wait is cancelled.
  • New RoundTrip tests prove the backoff is used and stops on cancel, and that a rewind error returns without waiting. Added to the release race gate.

Round 7 (aa5be62)

  • A streamed response that ends by its context with nothing received on the conn since the last read now retires the conn, so a tunnel that goes silent mid-SSE is not handed to the next request.
  • A retired conn is closed with ClientConn.Close once no request with a live context uses it. Shutdown could block behind a writer stuck on a full TCP send buffer and leave the socket open.
  • A conn whose read loop has stopped is marked dead and dropped from the pool on reserve and after a failed round trip (x/net only removes it on a 5 s timer that a reserved request can cancel).
  • x/net's force closed via ClientConn.Close error counts as an abrupt close, so safe requests on the other streams of a cut conn are replayed once. A plain POST still is not.
  • README: one pool per proxy URL and account.
  • New tests (each fails with its fix reverted): silent tunnel after SSE cancel, retired conn closes under real TCP backpressure, closed conns leave the pool, GET replayed and POST not resent after a forced close.
  • Live (ovh, switchboard and the Pi): one account, 50 concurrent requests on 1 tunnel; two accounts at once on 2 tunnels.

Round 8 (15cc44b)

  • Reserving a stream and counting the request live are now one step under the conn's mutex, and retire and release decide to close under the same mutex. Before, a retire between the two steps saw zero live requests and closed the conn under the new request, aborting a plain POST the server had already received. A conn retire has chosen to close is never handed out (both the pooled and the just-dialled path).
  • New tests (each fails with its fix reverted): a reserved POST survives another request's cancel retiring its conn and reaches the server once; a retired conn accepts no new reservation and closes when its last request ends.
  • Live (ovh, switchboard and the Pi): one account, 50 concurrent requests on 1 tunnel; two accounts at once on 2 tunnels.

Each Codex/Claude request built a fresh uTLS round tripper, so no connection
was reused and every request left an idle proxy tunnel open until the server
closed it (~7 min). Keep one round tripper per proxy URL for the process and
close idle HTTP/2 connections after 90s, with 30s/15s health pings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T08:41:56.950365Z 0b5e416 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b5e416593

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +65 to +67
IdleConnTimeout: 90 * time.Second,
ReadIdleTimeout: 30 * time.Second,
PingTimeout: 15 * time.Second,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove post-connection HTTP/2 timeouts

For an established upstream connection that receives no frames for 30 seconds and then fails to acknowledge a ping within 15 seconds, these settings close the connection even if inference requests are still active, aborting all streams sharing it. This introduces a transport-imposed failure deadline into the Claude/Codex paths that deliberately construct clients with a zero timeout. Remove these post-connection deadlines; the repository explicitly prohibits them outside its enumerated exceptions, which do not include this HTTP/2 transport.

AGENTS.md reference: AGENTS.md:L59-L59

Useful? React with 👍 / 👎.

}

var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
var utlsRT http.RoundTripper = sharedUtlsRoundTripper(proxyURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Key shared connections by hostname and port

When two requests use the same proxy and protected hostname but different ports, the new process-wide transport reuses the first port's connection for the second request. RoundTrip computes the correct destination address, but getOrCreateConnection indexes connections and pending only by hostname, ignoring that address on a cache hit. This now affects independently constructed clients, including Claude credentials with different configured base_url ports, sending requests to the wrong upstream listener. Key connections and pending dials by the full destination address before sharing the transport across requests.

Useful? React with 👍 / 👎.

}

var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
var utlsRT http.RoundTripper = sharedUtlsRoundTripper(proxyURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent a stalled dial from blocking all shared clients

If one connection attempt stalls during proxy CONNECT or the TLS handshake, sharing this transport makes every subsequent request to that hostname through the same proxy wait indefinitely, even if new connections would succeed. getOrCreateConnection leaves a pending[host] entry until createConnection returns, while that operation uses context-free Dial and Handshake, and waiting requests use an unconditional cond.Wait() that cannot observe cancellation. Previously, independently created clients did not share this blockage; now one stalled attempt can wedge all credentials using that proxy until restart. Make connection establishment and pending waits cancellation-aware before sharing this state process-wide.

Useful? React with 👍 / 👎.

}

var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
var utlsRT http.RoundTripper = sharedUtlsRoundTripper(proxyURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reserve stream capacity before sharing an HTTP/2 connection

When concurrent requests race for the last available stream on a shared connection, both can pass getOrCreateConnection's CanTakeNewRequest() check before either starts its stream. The first request consumes the remaining capacity, and the second receives http2: client conn not usable from ClientConn.RoundTrip; this wrapper returns that error without retrying. Process-wide sharing introduces this race between otherwise independent Claude/Codex requests, whereas previously each client had its own connection. Use ReserveNewRequest() when selecting a connection, including newly created connections, so capacity selection and reservation are atomic.

Useful? React with 👍 / 👎.

khairm and others added 13 commits November 6, 2026 09:02
…ections

Review fixes for the shared uTLS connection:
- One http2.Transport per proxy URL with a ConnPool that dials once per
  host:port, detached from any one request and bounded at 30s, and lets
  each waiting request give up on its own context. The transport supplies
  stream reservation and retries for GOAWAY, REFUSED_STREAM and unusable
  connections; a cancelled stream no longer drops the connection.
- Proxy CONNECT dialer honours the context (dial, TLS, CONNECT exchange).
- Fail loudly when ALPN is not h2.
- Invalid proxy URLs fail every request instead of silently going direct
  or to the default transport, and are never cached.
- Share the standard proxy transport per proxy URL too.
- Claude OAuth client uses the shared uTLS transport; its copy is removed.
- Tests go through NewUtlsHTTPClient with a fresh client per request.
- Release 7.2.62+edgee.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xy-url early

- Never read or close the body of a 2xx CONNECT reply; framing headers are ignored per RFC 9110 9.3.6 so tunnel bytes stay intact.
- Treat a passed dial deadline as context.DeadlineExceeded even if ctx has not recorded it yet.
- proxyutil.FailingTransport replaces the two error round-trippers and closes the request body.
- Codex/xAI websocket dialer and the per-auth round-tripper provider fail on an invalid proxy instead of going direct.
- proxy-url is validated at config load and on management PUT/PATCH and auth-file edits (400).
- uTLS test root CAs are copied into the pool under the shared mutex.
- Version strings and README rebase range bumped to edgee.8; release gate covers proxyutil, auth/claude and the shared-connection race tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md forbids timeouts after a connection is established; idle agents
must keep their connection. The server's own idle close (chatgpt.com ~6.5
min) is redialled transparently on the next request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tighten proxy-url checks

- Retire a shared HTTP/2 connection when a request's context ends and the
  connection received no bytes meanwhile (dead tunnel), without any timer.
- Replay an idempotent request once after an abrupt close (unexpected EOF).
- Close the request body when no connection was obtained.
- Key shared transports by the normalised proxy setting.
- Wrap shared dial errors as "utls: shared dial to <addr> failed".
- proxyutil.Parse requires a hostname, a 1-65535 port when given, and a
  port for SOCKS5.
- util.SetProxy and the Antigravity model fetch fail loudly on a bad proxy.
- Auth-file PATCH validates the whole patch (proxy_url must be a valid
  string, no nested writes) before mutating a deep copy; uploads reject a
  bad proxy_url with 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both shared pools are keyed by the normalised proxy setting plus the auth ID,
so subscription accounts never share a connection. Callers with no auth
(config-level clients, login flows) use their own key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… connections, tighten proxy checks

- Send requests on the shared connections directly: retry only what the server confirmed
  it never processed (unusable conn, GOAWAY above last stream, REFUSED_STREAM); replay an
  abrupt close or PROTOCOL_ERROR once, only for idempotent or keyed requests.
- Close each attempt's body exactly once, including rewound retry bodies.
- A request whose context already ended reserves nothing and never evicts a connection.
- Claude token refreshes use the account's own shared connection.
- Drop an account's shared transports when it is removed or its proxy changes.
- Reject malformed host:port in proxy URLs (socks5://h:1080:80).
- A null proxy_url in an auth-file PATCH clears the live proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…peer PROTOCOL_ERROR

- Remove OnAuthReleased and the per-account pool release: it raced in-flight requests and
  global proxy changes. Upstreams close idle tunnels themselves, so a stale pool holds memory.
- Wait x/net's 1s, 2s, 4s... (plus jitter) between retries, stopping when the request ends.
- Replay after PROTOCOL_ERROR only when the server sent the reset; a malformed response
  detected locally means the server already answered.
- Drop the not-established branch: x/net already removes that connection from the pool.
- Tests for GOAWAY below/covering a POST, an idempotency-keyed POST, a malformed response
  and the backoff schedule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Restore MarkDead on 'client conn could not be established': x/net leaves such a
  conn reservable, so every later request failed on it without redialling.
- Rewind the body before the backoff wait, return a GetBody failure as its own
  error, and close the rewound body if the wait is cancelled.
- Tests: redial after a conn dies before its first stream; the RoundTrip backoff
  is used and stops on cancel with both bodies closed; a rewind error returns
  without waiting. Added to the release race gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op closed conns

- Track response bodies: a body ending by its context with nothing received since the last
  read retires the conn, and releases it when done.
- Close a retired conn with ClientConn.Close once no live-context request uses it, instead
  of Shutdown, which blocks behind a writer stuck on a full send buffer.
- Mark a conn dead when its read loop stops; drop such conns from the pool on reserve and
  after a failed round trip.
- Treat x/net's forced-close error as an abrupt close (safe requests only).
- README: one pool per proxy URL and account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request reserved a stream, then counted itself live in a separate step. A retire in
between saw zero live requests and closed the conn under the new request, aborting a
plain POST the server had already received. tryReserve now does both under the conn's
mutex, and retire and release decide to close under the same mutex, so a conn retire
has chosen to close is never handed out and is never closed under a reserved request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two parallel tests could share one transport through a reused proxy port, so
TestUtlsClientDropsClosedConnectionsFromThePool sometimes counted another
test's conn. Test-only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… requests

With 25 streams per connection and 40 requests in flight, and retry backoff off in tests,
one request could be refused above last-stream-id on 7 connections in a row and run out
of retries. Test only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@khairm
khairm merged commit fe7965d into edgee-oauth-base-url Nov 6, 2026
3 checks passed
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