Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/improve-browser-run-binding-error-diagnostics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniflare": patch
---

Improve error diagnostics in the Browser Run binding worker

When the local Browser Run binding failed to reach an upstream — for example when Chrome failed to launch and miniflare's loopback `/browser/launch` endpoint returned a 500 with a stack-trace text body — the binding worker would call `response.json()` on the non-JSON body and throw an opaque `SyntaxError: Unexpected token X, "..." is not valid JSON`. The actual upstream error message (e.g. `Chrome readiness probe at ... timed out after 5000ms`) was discarded.

The binding worker now reads the response body as text first, surfaces the HTTP status and body content in the thrown error, and chains the original `SyntaxError` via `cause` when the body was a 2xx response that didn't parse as JSON. This makes both local-dev failures and CI test flakes self-diagnosing.
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,46 @@ function isRetryableFetchError(error: unknown): boolean {
);
}

const MAX_BODY_PREVIEW = 2000;

function truncateBody(text: string): string {
if (text.length <= MAX_BODY_PREVIEW) {
return text;
}
return `${text.slice(0, MAX_BODY_PREVIEW)}... (truncated, ${text.length} bytes total)`;
Comment thread
petebacondarwin marked this conversation as resolved.
}

/**
* Read a JSON response with diagnostic error reporting.
*
* Standard `await resp.json()` produces opaque "Unexpected token X, ..."
* errors when an upstream returns non-JSON (e.g. a 500 with a stack-trace
* text body from the loopback `/browser/launch` handler when Chrome fails
* to start). This helper reads the body as text first, includes the status
* and (truncated) text in any thrown error, and chains the original
* `SyntaxError` via `cause` so the underlying parse failure is still
* inspectable.
*/
async function parseJsonResponse<T = unknown>(
resp: Response,
context: string
): Promise<T> {
const text = await resp.text();
if (!resp.ok) {
throw new Error(
`${context}: upstream returned ${resp.status} ${resp.statusText}\n${truncateBody(text)}`
);
}
try {
return JSON.parse(text) as T;
} catch (cause) {
throw new Error(
`${context}: expected JSON, got non-JSON response (${resp.status} ${resp.statusText})\n${truncateBody(text)}`,
{ cause }
);
}
}

/**
* Wrapper around `fetch` that retries on transient connection failures.
*
Expand Down Expand Up @@ -445,11 +485,13 @@ class BrowserRenderingRouter extends Router {
}

async #acquireSession(): Promise<SessionInfo> {
const sessionInfo: SessionInfo = await this.env[
SharedBindings.MAYBE_SERVICE_LOOPBACK
]
.fetch("http://localhost/browser/launch")
.then((r) => r.json());
const resp = await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK].fetch(
"http://localhost/browser/launch"
);
const sessionInfo = await parseJsonResponse<SessionInfo>(
resp,
"Failed to launch local browser via miniflare loopback (/browser/launch)"
);
await this.#fetchSession(sessionInfo.sessionId, "/session-info", {
method: "POST",
body: JSON.stringify(sessionInfo),
Expand All @@ -458,17 +500,24 @@ class BrowserRenderingRouter extends Router {
}

async #getActiveSessions() {
const sessionIds = (await this.env[SharedBindings.MAYBE_SERVICE_LOOPBACK]
.fetch("http://localhost/browser/sessionIds")
.then((r) => r.json())) as string[];
const sessionIdsResp = await this.env[
SharedBindings.MAYBE_SERVICE_LOOPBACK
].fetch("http://localhost/browser/sessionIds");
const sessionIds = await parseJsonResponse<string[]>(
sessionIdsResp,
"Failed to list active browser sessions via miniflare loopback (/browser/sessionIds)"
);

const sessions = await Promise.all(
sessionIds.map(async (sessionId) => {
const resp = await this.#fetchSession(sessionId, "/session-info");
if (resp.status === 204) {
return null;
}
const sessionInfo: SessionInfo = await resp.json();
const sessionInfo = await parseJsonResponse<SessionInfo>(
resp,
`Failed to read session-info for browser session ${sessionId}`
);
return {
sessionId: sessionInfo.sessionId,
startTime: sessionInfo.startTime,
Expand Down
Loading