diff --git a/.server-changes/transcript-download-empty-reason-404.md b/.server-changes/transcript-download-empty-reason-404.md new file mode 100644 index 00000000000..967242c8689 --- /dev/null +++ b/.server-changes/transcript-download-empty-reason-404.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Return a not-found response when a missing transcript receives an object-store 404 without a reason phrase diff --git a/apps/webapp/app/services/realtime/transcriptDownload.server.test.ts b/apps/webapp/app/services/realtime/transcriptDownload.server.test.ts index 6768b6eb0f1..91c7fb32794 100644 --- a/apps/webapp/app/services/realtime/transcriptDownload.server.test.ts +++ b/apps/webapp/app/services/realtime/transcriptDownload.server.test.ts @@ -1,5 +1,6 @@ import { createServer, type Server } from "node:http"; import { once } from "node:events"; +import { createServer as createTcpServer } from "node:net"; import express from "express"; import { sendRemixResponse } from "@remix-run/express/dist/server"; import { expect, test } from "vitest"; @@ -116,6 +117,34 @@ test("does not treat permission or service failures as an absent transcript", () expect(isTranscriptNotFound({ name: "NoSuchKey" })).toBe(true); }); +test("recognizes a missing transcript when the object store omits the reason phrase", async () => { + const server = createTcpServer((socket) => { + socket.once("data", () => { + socket.end("HTTP/1.1 404 \r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP listener"); + + try { + const client = ObjectStoreClient.create({ + baseUrl: `http://127.0.0.1:${address.port}`, + accessKeyId: "test-access-key", + secretAccessKey: "test-secret-key", + service: "s3", + }); + + // @crumbs Exercise the adapter with a valid 404 response whose statusText is empty. + await expect(client.getObjectResponse(key)).rejects.toSatisfy(isTranscriptNotFound); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + } +}); + minioTest( "HEAD distinguishes permission failures from missing objects", async ({ minioConfig }) => { diff --git a/apps/webapp/app/services/realtime/transcriptDownload.server.ts b/apps/webapp/app/services/realtime/transcriptDownload.server.ts index da477a9ca5a..3faec71fb17 100644 --- a/apps/webapp/app/services/realtime/transcriptDownload.server.ts +++ b/apps/webapp/app/services/realtime/transcriptDownload.server.ts @@ -45,8 +45,20 @@ export function downloadTranscript( export function isTranscriptNotFound(error: unknown): boolean { if (!error || typeof error !== "object") return false; - const { name, $metadata } = error as { name?: string; $metadata?: { httpStatusCode?: number } }; - if (name === "NoSuchKey" || name === "NotFound" || $metadata?.httpStatusCode === 404) return true; + const { name, status, $metadata } = error as { + name?: string; + status?: number; + $metadata?: { httpStatusCode?: number }; + }; + // @crumbs Prefer status codes because HTTP reason phrases are optional. + if ( + name === "NoSuchKey" || + name === "NotFound" || + status === 404 || + $metadata?.httpStatusCode === 404 + ) { + return true; + } // The aws4fetch adapter currently reports the HTTP status text in its error. return ( error instanceof Error && diff --git a/apps/webapp/app/v3/objectStoreClient.server.ts b/apps/webapp/app/v3/objectStoreClient.server.ts index f7b921be0a5..fd8868dca1b 100644 --- a/apps/webapp/app/v3/objectStoreClient.server.ts +++ b/apps/webapp/app/v3/objectStoreClient.server.ts @@ -135,7 +135,11 @@ class Aws4FetchClient implements IObjectStoreClient { async getObjectResponse(key: string): Promise { const response = await this.awsClient.fetch(this.buildUrl(key)); if (!response.ok) { - throw new Error(`Failed to download from object store: ${response.statusText}`); + // @crumbs Preserve the numeric status because statusText may be empty for valid responses. + throw Object.assign( + new Error(`Failed to download from object store: ${response.statusText}`), + { status: response.status } + ); } return response; }