Skip to content
Open
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
24 changes: 24 additions & 0 deletions apps/mobile/src/api-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Read an API response without assuming every route answers with JSON.
* Plain-text 404s, proxy HTML pages and empty bodies must not surface as
* "Unexpected token" parse errors in the UI.
*/
export async function readApiPayload<T>(response: Response, fallback?: string): Promise<T> {
const text = await response.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
payload = undefined;
}
if (!response.ok) {
const error =
payload && typeof payload === "object" ? (payload as { error?: unknown }).error : undefined;
throw new Error(
typeof error === "string" ? error : (fallback ?? `Request failed (${response.status})`),
);
}
if (payload === undefined)
throw new Error(`The server returned an unreadable response (${response.status}).`);
return payload as T;
}
13 changes: 6 additions & 7 deletions apps/mobile/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Platform } from "react-native";
import { readApiPayload } from "./api-response";

export const API_URL = (
process.env.EXPO_PUBLIC_API_URL ||
Expand All @@ -18,11 +19,7 @@ export class MuseApi {
},
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok)
throw new Error(
typeof payload.error === "string" ? payload.error : `Request failed (${response.status})`,
);
const payload = await readApiPayload<T>(response);
return payload;
}
url(path: string) {
Expand All @@ -38,7 +35,9 @@ export async function createSession(
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessKey }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Could not open your workspace.");
const payload = await readApiPayload<{ token: string; mode: "sample" | "live" }>(
response,
"Could not open your workspace.",
);
return payload;
}
54 changes: 54 additions & 0 deletions apps/mobile/test/api-response.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readApiPayload } from "../src/api-response.ts";

test("JSON responses pass through and server errors keep their message", async () => {
assert.deepEqual(
await readApiPayload(new Response(JSON.stringify({ mode: "sample" }), { status: 200 })),
{ mode: "sample" },
);
await assert.rejects(
readApiPayload(
new Response(JSON.stringify({ error: "Access key is incorrect" }), { status: 401 }),
),
/Access key is incorrect/,
);
});

test("plain-text and HTML failures stay readable instead of a JSON parse error", async () => {
for (const [status, body] of [
[404, "404 Not Found"],
[502, "<html><body>Bad Gateway</body></html>"],
] as const) {
const error = await readApiPayload(new Response(body, { status })).catch((e: unknown) => e);
assert.ok(error instanceof Error);
assert.equal(error.message, `Request failed (${status})`);
assert.equal(error instanceof SyntaxError, false);
assert.doesNotMatch(error.message, /Unexpected|JSON/i);
}
});

test("a caller fallback covers error bodies without a message", async () => {
await assert.rejects(
readApiPayload(
new Response(JSON.stringify({}), { status: 500 }),
"Could not open your workspace.",
),
/Could not open your workspace\./,
);
await assert.rejects(
readApiPayload(
new Response("<html>down</html>", { status: 503 }),
"Could not open your workspace.",
),
/Could not open your workspace\./,
);
});

test("successful responses without a readable body fail clearly", async () => {
await assert.rejects(
readApiPayload(new Response(null, { status: 204 })),
/unreadable response \(204\)/,
);
await assert.rejects(readApiPayload(new Response("OK", { status: 200 })), /unreadable response/);
});