From 67c2d1b1ce25bffb0dd67ba402977cade475b061 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 1 Aug 2026 10:20:06 +0200 Subject: [PATCH 1/3] fix: prevent server crash on unauthenticated requests (ERR_HTTP_HEADERS_SENT) --- src/routes/responses.ts | 80 ++++++++++++++++++++++++----------------- tests/responses.test.js | 12 +++++++ 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/src/routes/responses.ts b/src/routes/responses.ts index d61206b..68ab62d 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -56,27 +56,53 @@ export const postCreateResponse = async ( req: ValidatedRequest, res: ExpressResponse ): Promise => { - // To avoid duplicated code, we run all requests as stream. - const events = runCreateResponseStream(req, res); - - // Then we return in the correct format depending on the user 'stream' flag. - if (req.body.stream) { - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Connection", "keep-alive"); - console.debug("Stream request"); - for await (const event of events) { - console.debug(`Event #${event.sequence_number}: ${event.type}`); - res.write(`data: ${JSON.stringify(event)}\n\n`); - } - res.end(); - } else { - console.debug("Non-stream request"); - for await (const event of events) { - if (event.type === "response.completed" || event.type === "response.failed") { - console.debug(event.type); - res.json(event.response); + // Auth is checked before any event is produced: once the stream has started + // writing, it is too late to send an error status. + const apiKey = req.headers.authorization?.split(" ")[1]; + if (!apiKey) { + res.status(401).json({ + success: false, + error: "Unauthorized", + }); + return; + } + + // Express 4 does not catch errors from async handlers: anything thrown here + // becomes an unhandled rejection and kills the process, so the whole handler + // is wrapped. + try { + // To avoid duplicated code, we run all requests as stream. + const events = runCreateResponseStream(req, apiKey); + + // Then we return in the correct format depending on the user 'stream' flag. + if (req.body.stream) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Connection", "keep-alive"); + console.debug("Stream request"); + for await (const event of events) { + console.debug(`Event #${event.sequence_number}: ${event.type}`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + res.end(); + } else { + console.debug("Non-stream request"); + for await (const event of events) { + if (event.type === "response.completed" || event.type === "response.failed") { + console.debug(event.type); + res.json(event.response); + } } } + } catch (error) { + console.error("Error in postCreateResponse:", error); + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : "Internal server error", + }); + } else { + res.end(); + } } }; @@ -88,7 +114,7 @@ export const postCreateResponse = async ( */ async function* runCreateResponseStream( req: ValidatedRequest, - res: ExpressResponse + apiKey: string ): AsyncGenerator { let sequenceNumber = 0; // Prepare response object that will be iteratively populated @@ -134,7 +160,7 @@ async function* runCreateResponseStream( // Any events (LLM call, MCP call, list tools, etc.) try { - for await (const event of innerRunStream(req, res, responseObject)) { + for await (const event of innerRunStream(req, apiKey, responseObject)) { yield { ...event, sequence_number: sequenceNumber++ }; } } catch (error) { @@ -173,19 +199,9 @@ async function* runCreateResponseStream( async function* innerRunStream( req: ValidatedRequest, - res: ExpressResponse, + apiKey: string, responseObject: IncompleteResponse ): AsyncGenerator { - // Retrieve API key from headers - const apiKey = req.headers.authorization?.split(" ")[1]; - if (!apiKey) { - res.status(401).json({ - success: false, - error: "Unauthorized", - }); - return; - } - // Forward headers (except authorization handled separately) const defaultHeaders = Object.fromEntries( Object.entries(req.headers).filter(([key]) => !NOT_FORWARDED_HEADERS.has(key.toLowerCase())) diff --git a/tests/responses.test.js b/tests/responses.test.js index 5088852..cba76b0 100644 --- a/tests/responses.test.js +++ b/tests/responses.test.js @@ -778,6 +778,18 @@ describe("responses.js", function () { assert.equal(output[1].content[0].type, "output_text"); assert.equal(typeof output[1].content[0].text, "string"); }); + + it("unauthenticated request gets a 401 and does not crash the server", async function () { + const response = await fetch("http://localhost:3000/v1/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "Qwen/Qwen2.5-VL-7B-Instruct", input: "hi" }), + }); + assert.equal(response.status, 401); + + const health = await fetch("http://localhost:3000/health"); + assert.equal(health.status, 200); + }); }); function dropConsecutiveEvents(events) { From 1666de2a42b44dc950c2c4807c5c4cc3ab5bf3c1 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 1 Aug 2026 10:23:50 +0200 Subject: [PATCH 2/3] refactor: address review cleanup (shared errorMessage helper, stale types/comments) --- src/routes/responses.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 68ab62d..759c5ea 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -90,6 +90,7 @@ export const postCreateResponse = async ( if (event.type === "response.completed" || event.type === "response.failed") { console.debug(event.type); res.json(event.response); + break; } } } @@ -98,7 +99,7 @@ export const postCreateResponse = async ( if (!res.headersSent) { res.status(500).json({ success: false, - error: error instanceof Error ? error.message : "Internal server error", + error: errorMessage(error, "Internal server error"), }); } else { res.end(); @@ -106,6 +107,15 @@ export const postCreateResponse = async ( } }; +function errorMessage(error: unknown, fallback: string): string { + return typeof error === "object" && + error && + "message" in error && + typeof (error as { message: unknown }).message === "string" + ? (error as { message: string }).message + : fallback; +} + /* * Top-level stream. * @@ -167,18 +177,10 @@ async function* runCreateResponseStream( // Error event => stop console.error("Error in stream:", error); - const message = - typeof error === "object" && - error && - "message" in error && - typeof (error as { message: unknown }).message === "string" - ? (error as { message: string }).message - : "An error occurred in stream"; - responseObject.status = "failed"; responseObject.error = { code: "server_error", - message, + message: errorMessage(error, "An error occurred in stream"), }; yield { type: "response.failed", @@ -202,7 +204,7 @@ async function* innerRunStream( apiKey: string, responseObject: IncompleteResponse ): AsyncGenerator { - // Forward headers (except authorization handled separately) + // Forward headers (the exclusion set covers authorization) const defaultHeaders = Object.fromEntries( Object.entries(req.headers).filter(([key]) => !NOT_FORWARDED_HEADERS.has(key.toLowerCase())) ) as Record; @@ -534,7 +536,7 @@ async function* listMcpToolsStream( * Call LLM and stream the response. */ async function* handleOneTurnStream( - apiKey: string | undefined, + apiKey: string, payload: ChatCompletionCreateParamsStreaming, responseObject: IncompleteResponse, mcpToolsMapping: Record, From 259da3fc56d5ae10854f7f609e6675a6a78f5047 Mon Sep 17 00:00:00 2001 From: Adrien Date: Sat, 1 Aug 2026 10:26:13 +0200 Subject: [PATCH 3/3] ci: fix lint workflow (node 22 for pnpm setup, prettier on workflow files) --- .github/workflows/deploy.yml | 16 ++++++++-------- .github/workflows/lint.yml | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b0e218..85e627e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,17 +7,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Login to Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Docker metadata id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: images: | huggingface/responses-js @@ -26,13 +26,13 @@ jobs: type=sha,enable=true,prefix=sha-,format=short,sha-len=8 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Inject slug/short variables - uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0 + uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0 - name: Build and Publish image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: Dockerfile @@ -50,7 +50,7 @@ jobs: needs: ["build-and-publish"] steps: - name: Inject slug/short variables - uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0 + uses: rlespinasse/github-slug-action@e6f261660910b273384c5c42b17a0217881b217a # v5.6.0 - name: Gen values run: | @@ -62,7 +62,7 @@ jobs: echo "VALUES=$(echo "$VALUES" | yq -o=json | jq tostring)" >> $GITHUB_ENV - name: Deploy on infra-deployments - uses: aurelien-baudet/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 # v4.0.0 + uses: aurelien-baudet/workflow-dispatch@3133c5d135c7dbe4be4f9793872b6ef331b53bc7 # v4.0.0 with: workflow: Update application single value repo: huggingface/infra-deployments diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4da12f1..2f1151b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,15 +12,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "18" + node-version: "22" - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 with: version: 10.10.0 @@ -30,7 +30,7 @@ jobs: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}