From 4a7f8a03617594570f9c37f25ab0d502f29d46ad Mon Sep 17 00:00:00 2001 From: kitsuyui Date: Wed, 3 Jun 2026 22:30:00 +0900 Subject: [PATCH 1/2] fix: return 502/504 instead of 204 on navigation failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When page.goto() throws a network error (DNS failure, connection refused, TLS error), the proxy now returns an appropriate error status rather than 204 No Content: - TimeoutError → 504 Gateway Timeout - Other network errors → 502 Bad Gateway A response event listener captures the main-frame server reply before page.goto() completes. If the server did reply but Playwright aborted the document load (e.g. because the origin returned 204 No Content), the captured response is forwarded instead of treating it as an error. Add a test that verifies a connection-refused URL returns 502. --- src/render/index.spec.ts | 10 ++++ src/render/index.ts | 104 ++++++++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/src/render/index.spec.ts b/src/render/index.spec.ts index 205e58ef..dc6d2318 100644 --- a/src/render/index.spec.ts +++ b/src/render/index.spec.ts @@ -127,6 +127,16 @@ describe('getRenderedContent', () => { expect(browser.contexts().length).toBe(0) }) + it('returns 502 when navigation fails (connection refused)', async () => { + const result = await getRenderedContent(browser, { + url: 'http://127.0.0.1:19999/', + }) + expect(result.status).toEqual(502) + expect(result.body.byteLength).toBe(0) + expect(result.evaluateResults).toHaveLength(0) + expect(browser.contexts.length).toBe(0) + }) + test('image/png', async () => { const result = await getRenderedContent(browser, { url: 'http://localhost:8003/test.png', diff --git a/src/render/index.ts b/src/render/index.ts index 37574355..b31c2983 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -1,4 +1,5 @@ import type { Browser, Page, Response as PlaywrightResponse } from 'playwright' +import { errors as PlaywrightErrors } from 'playwright' import { runWithDefer } from 'with-defer' import { excludeCacheValidationHeaders } from '../lib/headers' @@ -29,9 +30,9 @@ export interface RenderResult { evaluateResults: EvaluateResult[] } -function emptyRenderResult(): RenderResult { +function errorRenderResult(status: 502 | 504): RenderResult { return { - status: 204, + status, headers: {}, body: Buffer.from(''), evaluateResults: [], @@ -71,13 +72,40 @@ async function collectEvaluateResults( ) } -async function navigatePage( +type NavigationResult = + | { + ok: true + response: PlaywrightResponse | null + evaluateResults: EvaluateResult[] + } + | { ok: false; status: 502 | 504 } + +function classifyNavigationError(error: unknown): 502 | 504 { + return error instanceof PlaywrightErrors.TimeoutError ? 504 : 502 +} + +function captureNavigationResponse(page: Page): { + get: () => PlaywrightResponse | null + detach: () => void +} { + let captured: PlaywrightResponse | null = null + const handler = (response: PlaywrightResponse) => { + if (response.request().isNavigationRequest()) { + captured = response + } + } + page.on('response', handler) + return { + get: () => captured, + detach: () => page.off('response', handler), + } +} + +async function tryGoto( page: Page, request: Required> & RenderRequest, -): Promise<{ - response: PlaywrightResponse | null - evaluateResults: EvaluateResult[] -} | null> { + capture: ReturnType, +): Promise { try { const response = await page.goto(request.url, { waitUntil: request.waitUntil, @@ -87,13 +115,9 @@ async function navigatePage( page, request.evaluates, ) - return { - response, - evaluateResults, - } + return { ok: true, response: response ?? capture.get(), evaluateResults } } catch (error) { - console.error(`navigate failed: ${request.url}`, error) - return null + return { ok: false, status: classifyNavigationError(error) } } } @@ -107,10 +131,33 @@ function computeResponseHeaders(response: PlaywrightResponse): { return headers } +async function navigatePage( + page: Page, + request: Required> & RenderRequest, +): Promise { + // Track the main-frame response so we can detect server replies even when + // page.goto() throws (e.g. 204 No Content aborts the document load). + const capture = captureNavigationResponse(page) + const result = await tryGoto(page, request, capture) + capture.detach() + const serverResponse = capture.get() + if (!result.ok && serverResponse) { + return { ok: true, response: serverResponse, evaluateResults: [] } + } + return result +} + +function hasNoBody(status: number): boolean { + return status === 204 || status === 304 || (status >= 100 && status < 200) +} + async function readRenderedBody( page: Page, response: PlaywrightResponse, ): Promise { + if (hasNoBody(response.status())) { + return Buffer.from('') + } const headers = { ...response.headers() } if (isRenderableContentType(headers['content-type'] || '')) { return Buffer.from(await page.content()) @@ -118,6 +165,26 @@ async function readRenderedBody( return response.body() } +async function buildRenderResult( + page: Page, + navigationResult: NavigationResult, +): Promise { + if (!navigationResult.ok) { + return errorRenderResult(navigationResult.status) + } + if (!navigationResult.response) { + // page.goto() returned null without an exception (e.g. origin sent 204 No Content). + // This is a valid upstream response, not a gateway error. + return emptyRenderResult() + } + return { + status: navigationResult.response.status(), + headers: computeResponseHeaders(navigationResult.response), + body: await readRenderedBody(page, navigationResult.response), + evaluateResults: navigationResult.evaluateResults, + } +} + export async function getRenderedContent( browser: Browser, request: RenderRequest, @@ -133,15 +200,6 @@ export async function getRenderedContent( const page = await context.newPage() const navigationResult = await navigatePage(page, normalizedRequest) - if (!navigationResult?.response) { - return emptyRenderResult() - } - - return { - status: navigationResult.response.status(), - headers: computeResponseHeaders(navigationResult.response), - body: await readRenderedBody(page, navigationResult.response), - evaluateResults: navigationResult.evaluateResults, - } + return buildRenderResult(page, navigationResult) }) } From 8e2ac5656852cea16b6855c40263d78d92b56baa Mon Sep 17 00:00:00 2001 From: kitsuyui Date: Thu, 9 Jul 2026 08:10:49 +0900 Subject: [PATCH 2/2] fix: restore empty render result helper --- src/render/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/render/index.ts b/src/render/index.ts index b31c2983..2eeaf143 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -30,6 +30,15 @@ export interface RenderResult { evaluateResults: EvaluateResult[] } +function emptyRenderResult(): RenderResult { + return { + status: 204, + headers: {}, + body: Buffer.from(''), + evaluateResults: [], + } +} + function errorRenderResult(status: 502 | 504): RenderResult { return { status,