Skip to content

egress: control and name the source IP the WAF sees (socks5h, egress-tagged parse failures); fail over before first block; Bun body fix - #176

Open
maxff77 wants to merge 9 commits into
Rfym21:mainfrom
maxff77:fix/egress-naming-and-failover
Open

maxff77 wants to merge 9 commits into
Rfym21:mainfrom
maxff77:fix/egress-naming-and-failover

Conversation

@maxff77

@maxff77 maxff77 commented Sep 16, 2026

Copy link
Copy Markdown

What

Nine commits on top of main (928f816), all around outbound egress and what the logs say when it breaks.

  • socks5h:// accepted (afd1a79). DNS resolves at the proxy instead of on the host. resolveProxyUrl used to reject it as invalid and fall back to DIRECT, which on a shared host means a different source IP than intended — and Qwen's WAF is per source IP.
  • Every parse failure names its egress (6807ac1, cb28e12, 81b5526). /files/parse, /files/parse/status, the STS/OSS upload and the breaker-open path all attach error.egress = protocol//host:port (credentials stripped) and end the message with (file_id, via <egress>). Before this, a dead SOCKS sidecar and a dead direct route produced the same bare axios error.
  • Bun: stop bridging undici bodies through web streams (b2ebd25). Under Bun the previous bridge closed the socket mid-response on large SSE bodies; the transport now hands undici's Node stream straight through.
  • Anthropic /v1/messages: fail over before the first block (64f8593, 7ae0af0). If the upstream stream dies before any content block was emitted, retry on another account instead of surfacing Connection lost mid-response; once a block has been sent the failure is delivered as before. One transport classifier (TRANSPORT_INTERRUPTION_CODES / _MESSAGE_RE) shared with the OpenAI path; the stream tags the account that failed.
  • Lint (4b39af1): the four no-useless-assignment errors that turned main red after fix(usage): report Qwen's real usage instead of the local estimate #175 (verify / regression, run 35110797317). Usage tokens declared where assigned; empty stream fails without a yield-less generator.
  • Refuse to boot on an API_KEY with empty slots (dc54f99). A compose file that builds API_KEY=${ADMIN},${CLIENT} and is deployed without ADMIN in scope expands to ,sk-client…; parseApiKeys filtered the empty slot away and silently promoted the CLIENT key to admin, so the dashboard answered "invalid API key" to the real admin key. An empty slot is never intentional, so it now throws at startup naming how many slots are empty and where. An unset API_KEY is untouched (the separate "no keys configured" path).
  • Docs: proxy sections in .env.example and the three READMEs list SOCKS5H and say what the h buys.

Tests

  • bun run lint: 0 errors.
  • bun test gate: 1181 tests / 137 suites, 0 fail. tests/expected-counts.json blessed 1146/134 → 1181/137 (+35 tests, +3 suites). New suites: anthropic-midstream-failover, proxy-egress-describe, proxy-socks5h, proxy-transport-no-bridge, sse-interrupt-metrics, config-api-key-slots. Extended: upload-parse-status (transport failure on the parse POST names the egress, secret absent), upstream-quota-429.
  • Fork CI on this exact head (dc54f99): https://github.com/maxff77/qwen2api/actions/runs/35129316759 (green — lint + the full gate).

Live

Running on a staging gateway behind a socks5h:// WARP sidecar since 2026-09-16 (image head-dc54f99): /v1/models 200, /v1/messages non-stream 200 with real upstream usage, admin and client keys resolving to their own roles (403 for the client key on admin routes), no [ERROR] lines. The boot guard was exercised against the built image: API_KEY=",sk-client" exits 1 with the diagnostic instead of starting.

🤖 Generated with Claude Code

PEDRO LOBATO CARCAMO and others added 8 commits September 16, 2026 10:17
Qwen's WAF challenges POST /api/v2/files/parse per source IP, not per
account. The 529s on qwen-next on 2026-09-16 were one WARP egress
(104.28.222.16) challenged with all 236 accounts behind it; the logs said
WAF_CAPTCHA and nothing about which proxy, so the branch was suspected
before the IP was.

describeEgress(account) returns the proxy an account leaves through
(credentials masked) or 'direct'. Parse failures carry it in the message
(`via socks5://host:port`) and as error.egress; the rate-limiter and
breaker warnings name it too. Groundwork for keying both by egress once
more than one egress exists (.scratch/egress-waf).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… on breaker-open too

Review findings on 3ec9b9b:

- describeEgress now returns protocol//host:port via new URL(); userinfo is
  dropped, not masked. The old regex (/\/\/[^@]*@/) cut at the FIRST "@", so
  "http://user:p@ss@proxy:3128" leaked "ss@proxy", and "http://proxy?token=a@b"
  lost its host. The regex survives only as the fallback when URL() throws,
  now anchored to the authority (/\/\/[^/?#]*@/).
- assertParseBreakerClosed(account) names the egress like every other parse
  failure; both callers already had the account in hand. Without it, a burnt
  proxy was indistinguishable from a Qwen outage on the one log line that
  matters while the breaker is open.
- Tests: "@" in password/query/path, unparseable URL still masked, breaker-open
  error carries egress and no credentials. expected-counts 1151 -> 1154.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Under Bun, Readable.toWeb/fromWeb drops undici's body error on a mid-body
close: the SSE consumer hangs and the rejection escapes as an unhandled
rejection, which the runtime turns into a process exit. qwen-next died 6x
in 2.5 h this way (2026-09-16, UND_ERR_SOCKET "other side closed" at
5.7 KB into the stream, always via the socks5 hop).

The proxy transport now talks to undici directly: stream responses get
undici's own Node Readable, everything else is buffered in the transport,
and the fetch wrapper returns a buffered Response. Transport failures are
mapped onto the codes request.js already retries (UND_ERR_SOCKET ->
ECONNRESET, connect/headers/body timeouts -> ETIMEDOUT/ECONNABORTED) and
axios' timeout becomes headersTimeout/bodyTimeout, so the HTTP-proxy path
no longer cuts slow first bytes at the 10 s pool default.

Repro: tools/dev-probes/repro-transport.js under bun -> 3/3 caught,
0 escapes (before: 2 escaped rejections + hang per cut).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
socks5:// makes socks-proxy-agent resolve the target hostname locally and
hand the proxy a bare IP, so the DNS query leaves through the host resolver
while the TCP goes through the proxy — two egresses for one request. On
qwen-next 76/80 upstream connections reached sing-box as bare IPs.

socks5h:// (curl semantics) delegates resolution to the proxy: the agent
reads the scheme itself and sets shouldLookup=false. Accept it in the
backend validator, the dashboard regex it mirrors, the accounts API error
text and the three locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he first block

Live on qwen-next (2026-09-16, 8 h): 3 of 70 /v1/messages streams ended in
undici `UND_ERR_SOCKET: other side closed` with 63–90 KiB already read, and
Claude Code showed "Connection lost mid-response". Until now that surfaced
as a 500 with nothing the client could do.

- upstream-error.js#isTransportInterruption: socket closes and timeouts as
  Node, undici and streams emit them. Excludes client cancels (AbortError /
  ERR_CANCELED) and anything carrying an HTTP response.
- sse.js#consumeSSEStream counts bytes, and stamps upstreamBytesRead /
  upstreamEventCount on whatever error escapes — the controller uses it to
  decide whether a replay is safe, the [EGRESS] log to tell early from late
  cuts.
- anthropic.js#handleAnthropicStream: one failover per request, only while
  no content block has been emitted (replaying after visible text would
  duplicate it), for transport cuts and for a RateLimited first frame. The
  burned account is paused (recordFailedAccount) before the pool is asked
  again with excludeEmails, so the retry never lands on it.
- request.js#sendChatRequest forwards excludeEmails to the rotator.
- The outer 429 handler no longer marks an account the failover already
  marked (error.accountFailureRecorded) — same guard chat.js already had.
  Without it, a single-account deployment logged the quota exhaustion twice.

Tests: anthropic-midstream-failover (9), sse-interrupt-metrics, and the
upstream-quota-429 harness now honours excludeEmails like the real rotator.
Gate blessed at 1175 tests / 136 suites (was 1159/134).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iled

- request.js#isRetryableNetworkError delegates to upstream-error.js#isTransportInterruption
  and only adds the two connect-phase codes (ECONNREFUSED, EAI_AGAIN). Same guard against
  HTTP responses, same message sniff, one list to maintain. proxy-helper's UNDICI_ERROR_CODES
  stays: it is a mapping onto Node codes, not a classifier.
- anthropic.js: when the stream dies and no failover is left, the error carries
  failedAccountEmail (email only) so the handler's catch marks the account that actually
  served -- same idiom as chat.js. Drops the `let ctx = null` hoist.
- anthropic.js: attemptsMade counts only turns the model answered; failover iterations
  skip the increment instead of decrementing after the fact.

Tests: +1 (isRetryableNetworkError); two failover assertions now check failedAccountEmail.
Gate 1175/136 -> 1176/137.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y stream without a yield-less generator

CI on main (928f816, PR Rfym21#175) is red on four no-useless-assignment errors:
`let promptTokens = 0` / `let completionTokens = 0` at the top of both
Anthropic handlers are overwritten unconditionally after the upstream loop,
so the zero is never read. Declare them `const` at the assignment instead.

tests/sse-interrupt-metrics: the "failure before any byte" case built its
stream from an async generator whose only statement is `throw`, which trips
require-yield. A Readable that destroys itself on first read models the same
thing (error before any data) without the generator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…status polls

A socket hang-up or ECONNRESET on the parse POST (or on /files/parse/status)
used to surface as a bare axios error: no hint of which proxy the request
left through, so a broken WARP sidecar and a broken direct route looked the
same in the logs. Wrap both calls with the same helper the OSS/STS path
already uses: the error keeps its code, gains `egress` (protocol//host:port,
credentials dropped) and the message ends with `(file_id, via <egress>)`.

Docs: the proxy sections listed HTTP/HTTPS/SOCKS5 only; `socks5h://` has
been accepted for a while and is what qwen-next actually runs, so list it
and say what the `h` buys (DNS resolved at the proxy).

Test: transport failure on the parse POST through
`socks5h://user:s3cr3t@lohari-warp-qwen:9091` -> code ECONNRESET, egress
`socks5h://lohari-warp-qwen:9091`, secret absent from the message, zero
status polls. Suite blessed 1176 -> 1177.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@maxff77 maxff77 changed the title proxy/egress: socks5h, egress-named parse failures, Bun undici body fix, pre-first-block failover egress: control and name the source IP the WAF sees (socks5h, egress-tagged parse failures); fail over before first block; Bun body fix Sep 16, 2026
@maxff77

maxff77 commented Sep 16, 2026

Copy link
Copy Markdown
Author

Heads-up: the CI run for this head is waiting on maintainer approval (fork PR → action_required). The identical SHA is green on my fork: https://github.com/maxff77/qwen2api/actions/runs/35122057445. 4b39af1 also fixes the four no-useless-assignment errors that turned main red after #175 (run 35110797317).

`docker-compose.next.yml` sets `API_KEY=${QWEN2API_ADMIN_KEY_V2},${QWEN2API_API_KEY}`.
Deployed without the admin variable in scope it expands to `,sk-client…`; the parser
filtered the empty slot away, so `apiKeys` held one key and `adminKey` silently became
the CLIENT key. The dashboard then answered "invalid API key" to the real admin key —
an auth symptom for what was a deploy bug. qwen-next lost its dashboard this way on
2026-09-16 (recreating with `doppler run --` restored both slots).

Nobody writes an empty slot on purpose, so parseApiKeys now throws instead, naming how
many slots are empty and which positions. An unset API_KEY is untouched: that is the
separate already-handled "no keys configured" case. parseApiKeys takes the raw value as
an argument (defaulting to process.env) and is exported so the refusal is testable.

Gate 1177 -> 1181.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.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