Download src/proxy/captcha-retry.ts from bolikoto/zcode-api: direct link, hf CLI and curl.
- Browser
- Download file 7.39 kB
-
https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/proxy/captcha-retry.ts
- Command line
-
hf download hf://spaces/bolikoto/zcode-api/src/proxy/captcha-retry.ts
-
curl -L -o captcha-retry.ts https://huggingface.co/spaces/bolikoto/zcode-api/resolve/main/src/proxy/captcha-retry.ts
7.39 kB
| /** | |
| * Shared captcha (3007) challenge detection + single-retry flow. | |
| * | |
| * The challenge arrives in two variants (observed 2026-08-29, PR #38): | |
| * 1. response-header variant β `x-aliyun-captcha-verify-param` set on a | |
| * non-2xx response (`captcha.detectCaptchaChallenge`); | |
| * 2. in-body variant β HTTP 400 with `{"code":3007,...}` in the JSON body | |
| * and no captcha header. | |
| * | |
| * Both the chat hot path (handler.ts) and /v1/responses (responses-handler.ts) | |
| * used to hand-roll this detection and the re-solve/retry, and the copies had | |
| * drifted (error mapping, peek handling). This module is the unified seam. | |
| * | |
| * Runtime-import discipline: only TYPE imports from ./captcha.js β the real | |
| * module (which drags in the happy-dom solver) is lazily loaded by the | |
| * callers and passed in as a value, so non-start-plan processes never pay | |
| * its startup cost. | |
| */ | |
| import type * as CaptchaExports from "./captcha.js"; | |
| /** Shape the callers pass in β satisfied by the real captcha module or test fakes. */ | |
| export type CaptchaModuleLike = Pick<typeof CaptchaExports, "detectCaptchaChallenge" | "getCaptchaToken" | "RETRY_HEADERS">; | |
| /** Magic strings of the in-body challenge, for both JSON spacing styles. */ | |
| export const IN_BODY_CHALLENGE_MARKERS = ['"code":3007', '"code": 3007'] as const; | |
| /** Bound on how many raw (possibly compressed) body bytes a challenge peek reads. */ | |
| export const MAX_CHALLENGE_PEEK_BYTES = 64 * 1024; | |
| /** Bound on decompressed sniff output β error bodies are small; more is waste. */ | |
| const MAX_SNIFF_INFLATED_BYTES = 256 * 1024; | |
| /** | |
| * Peek (bounded) the body of a non-ok, non-SSE response and detect the | |
| * in-body 3007 challenge. The response stays consumable for the error path: | |
| * the peek reads a CLONE, so the original body is untouched. | |
| * | |
| * Covers both passthrough transports where the body may arrive still | |
| * gzip-compressed (Bun fetch `decompress:false`, ordered raw-TCP): when the | |
| * response advertises `content-encoding: gzip|x-gzip`, the peek inflates | |
| * before sniffing. On a runtime that already auto-inflated the body while | |
| * keeping the header (Node undici), inflation fails and the raw bytes are | |
| * sniffed as-is. | |
| */ | |
| export async function detectInBodyChallenge(resp: Response): Promise<boolean> { | |
| if (resp.ok) return false; | |
| const ctype = resp.headers.get("content-type") ?? ""; | |
| if (ctype.includes("text/event-stream")) return false; | |
| let bytes: Uint8Array; | |
| try { | |
| bytes = await peekBodyBytes(resp, MAX_CHALLENGE_PEEK_BYTES); | |
| } catch { | |
| return false; | |
| } | |
| if (bytes.byteLength === 0) return false; | |
| const text = await decodeMaybeGzip(bytes, resp.headers.get("content-encoding")); | |
| return IN_BODY_CHALLENGE_MARKERS.some((marker) => text.includes(marker)); | |
| } | |
| /** Unified challenge check: response-header variant first, then in-body peek. */ | |
| export async function isCaptchaChallenged( | |
| resp: Response, | |
| captcha: Pick<typeof CaptchaExports, "detectCaptchaChallenge">, | |
| ): Promise<boolean> { | |
| if (captcha.detectCaptchaChallenge(resp) !== null) return true; | |
| return detectInBodyChallenge(resp); | |
| } | |
| export interface CaptchaRetryArgs { | |
| captcha: CaptchaModuleLike; | |
| appVersion: string; | |
| /** The challenged response; its body is cancelled before the retry. */ | |
| challengedResp: Response; | |
| /** Dispatch the retry. Receives the fresh captcha headers; must return the upstream Response. */ | |
| solveAndRetry: (retryHeaders: Record<string, string>) => Promise<Response>; | |
| /** | |
| * Error mapping (unified contract, mirrors handler.ts's correct shape): | |
| * "solver" β the re-solve (getCaptchaToken) failed | |
| * "dispatch" β the retry dispatch failed at the network level | |
| */ | |
| mapError: (err: Error, phase: "solver" | "dispatch") => Response; | |
| /** Optional debug line sink. */ | |
| debug?: (message: string) => void; | |
| } | |
| /** | |
| * Shared single-retry flow for a detected captcha challenge: cancel the old | |
| * body, re-solve a fresh token, rebuild + re-dispatch ONCE (the challenged | |
| * token was already consumed by this request). Never loops. | |
| * | |
| * The caller MUST return `ok:false` responses directly to the client β they | |
| * are already fully-formed error responses (solver β 503 captcha_solver_failed, | |
| * dispatch β 502 upstream_unreachable); feeding them back through the | |
| * upstream `!ok` branch would wrap the error body a second time. | |
| */ | |
| export async function retryOnCaptchaChallenge(args: CaptchaRetryArgs): Promise<{ ok: true; resp: Response } | { ok: false; resp: Response }> { | |
| const { captcha, appVersion, challengedResp, solveAndRetry, mapError, debug } = args; | |
| debug?.("captcha challenge β re-solving and retrying once"); | |
| try { | |
| await challengedResp.body?.cancel(); | |
| } catch { | |
| // already drained/cancelled | |
| } | |
| let fresh: { verifyParam: string; region: string }; | |
| try { | |
| fresh = await captcha.getCaptchaToken(appVersion); | |
| } catch (err) { | |
| return { ok: false, resp: mapError(err as Error, "solver") }; | |
| } | |
| const retryHeaders: Record<string, string> = { | |
| [captcha.RETRY_HEADERS.PARAM]: fresh.verifyParam, | |
| [captcha.RETRY_HEADERS.REGION]: fresh.region, | |
| }; | |
| try { | |
| return { ok: true, resp: await solveAndRetry(retryHeaders) }; | |
| } catch (err) { | |
| return { ok: false, resp: mapError(err as Error, "dispatch") }; | |
| } | |
| } | |
| /** Read at most `limit` bytes from a clone of the response body. */ | |
| async function peekBodyBytes(resp: Response, limit: number): Promise<Uint8Array> { | |
| const body = resp.clone().body; | |
| if (!body) return new Uint8Array(0); | |
| const reader = body.getReader(); | |
| const parts: Uint8Array[] = []; | |
| let total = 0; | |
| try { | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| parts.push(value); | |
| total += value.byteLength; | |
| if (total >= limit) break; | |
| } | |
| } finally { | |
| reader.cancel().catch(() => {}); | |
| reader.releaseLock?.(); | |
| } | |
| const all = Buffer.concat(parts); | |
| return all.byteLength > limit ? all.subarray(0, limit) : all; | |
| } | |
| /** | |
| * Decode sniff bytes, inflating first when the response is labeled gzip. | |
| * TextDecoder is non-fatal, so invalid UTF-8 degrades to replacement chars β | |
| * the ASCII magic strings still match. | |
| */ | |
| async function decodeMaybeGzip(bytes: Uint8Array, encodingHeader: string | null): Promise<string> { | |
| const enc = encodingHeader?.toLowerCase().trim() ?? ""; | |
| if (enc === "gzip" || enc === "x-gzip") { | |
| try { | |
| return new TextDecoder().decode(await inflateBounded(bytes)); | |
| } catch { | |
| // Runtime already inflated the body while keeping the header (Node | |
| // undici) β sniff the bytes as-is. | |
| } | |
| } | |
| return new TextDecoder().decode(bytes); | |
| } | |
| async function inflateBounded(bytes: Uint8Array): Promise<Uint8Array> { | |
| const gunzip = new DecompressionStream("gzip") as unknown as ReadableWritablePair<Uint8Array, Uint8Array>; | |
| const source = new ReadableStream<Uint8Array>({ | |
| start(controller) { | |
| controller.enqueue(bytes); | |
| controller.close(); | |
| }, | |
| }); | |
| const reader = source.pipeThrough(gunzip).getReader(); | |
| const parts: Uint8Array[] = []; | |
| let total = 0; | |
| try { | |
| for (;;) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| parts.push(value); | |
| total += value.byteLength; | |
| if (total >= MAX_SNIFF_INFLATED_BYTES) break; | |
| } | |
| } finally { | |
| reader.cancel().catch(() => {}); | |
| reader.releaseLock?.(); | |
| } | |
| return Buffer.concat(parts); | |
| } | |