Skip to content

Commit 01445d6

Browse files
committed
fix(webapp): recognize object-store 404 by status code for missing transcripts
Aws4FetchClient download errors now carry the HTTP status, and isTranscriptNotFound checks status 404 first so an empty reason phrase no longer surfaces as a 502. Fixes #4984
1 parent 07374a9 commit 01445d6

4 files changed

Lines changed: 77 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Fix missing session transcripts returning a download error instead of a not-found response

‎apps/webapp/app/services/realtime/transcriptDownload.server.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createServer, type Server } from "node:http";
2+
import { createServer as createTcpServer, type Server as TcpServer } from "node:net";
23
import { once } from "node:events";
34
import express from "express";
45
import { sendRemixResponse } from "@remix-run/express/dist/server";
@@ -114,6 +115,34 @@ test("does not treat permission or service failures as an absent transcript", ()
114115
).toBe(false);
115116
expect(isTranscriptNotFound({ $metadata: { httpStatusCode: 503 } })).toBe(false);
116117
expect(isTranscriptNotFound({ name: "NoSuchKey" })).toBe(true);
118+
expect(isTranscriptNotFound({ status: 404, message: "Failed to download from object store: " })).toBe(
119+
true
120+
);
121+
});
122+
123+
test("recognizes a 404 with an empty reason phrase as a missing transcript", async () => {
124+
const server: TcpServer = createTcpServer((socket) => {
125+
socket.once("data", () => {
126+
socket.end("HTTP/1.1 404 \r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
127+
});
128+
});
129+
server.listen(0, "127.0.0.1");
130+
await once(server, "listening");
131+
const address = server.address();
132+
if (!address || typeof address === "string") throw new Error("Expected TCP listener");
133+
const baseUrl = `http://127.0.0.1:${address.port}`;
134+
try {
135+
const client = ObjectStoreClient.create({
136+
baseUrl,
137+
accessKeyId: "test",
138+
secretAccessKey: "test",
139+
service: "s3",
140+
});
141+
await expect(client.getObjectResponse(key)).rejects.toSatisfy(isTranscriptNotFound);
142+
} finally {
143+
server.close();
144+
await once(server, "close");
145+
}
117146
});
118147

119148
minioTest(

‎apps/webapp/app/services/realtime/transcriptDownload.server.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,16 @@ export function downloadTranscript(
4545

4646
export function isTranscriptNotFound(error: unknown): boolean {
4747
if (!error || typeof error !== "object") return false;
48-
const { name, $metadata } = error as { name?: string; $metadata?: { httpStatusCode?: number } };
48+
const { name, $metadata, status } = error as {
49+
name?: string;
50+
$metadata?: { httpStatusCode?: number };
51+
status?: unknown;
52+
};
4953
if (name === "NoSuchKey" || name === "NotFound" || $metadata?.httpStatusCode === 404) return true;
54+
// The aws4fetch adapter reports the HTTP status on the error. Check it first:
55+
// S3-compatible stores may return an empty reason phrase, so the message alone
56+
// cannot distinguish a 404 from other failures.
57+
if (status === 404) return true;
5058
// The aws4fetch adapter currently reports the HTTP status text in its error.
5159
return (
5260
error instanceof Error &&

‎apps/webapp/app/v3/objectStoreClient.server.ts‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ export class ObjectVersionChangedError extends Error {
5858
}
5959
}
6060

61+
/**
62+
* A failed object-store download that preserves the HTTP status code.
63+
* Callers must not rely on `statusText`: S3-compatible stores may return an
64+
* empty reason phrase (e.g. `HTTP/1.1 404 `), which yields an empty message
65+
* suffix and breaks message-based 404 detection.
66+
*/
67+
export class ObjectStoreDownloadError extends Error {
68+
readonly status: number;
69+
readonly statusText: string;
70+
71+
constructor(message: string, status: number, statusText: string) {
72+
super(message);
73+
this.name = "ObjectStoreDownloadError";
74+
this.status = status;
75+
this.statusText = statusText;
76+
}
77+
}
78+
6179
/** `Range` header value for a byte range or a suffix. */
6280
function rangeHeader(range: { suffixLength: number } | { start: number; end: number }): string {
6381
return "suffixLength" in range
@@ -120,7 +138,11 @@ class Aws4FetchClient implements IObjectStoreClient {
120138
async getObject(key: string): Promise<string> {
121139
const response = await this.awsClient.fetch(this.buildUrl(key));
122140
if (!response.ok) {
123-
throw new Error(`Failed to download from object store: ${response.statusText}`);
141+
throw new ObjectStoreDownloadError(
142+
`Failed to download from object store: ${response.statusText}`,
143+
response.status,
144+
response.statusText
145+
);
124146
}
125147
return response.text();
126148
}
@@ -135,7 +157,11 @@ class Aws4FetchClient implements IObjectStoreClient {
135157
async getObjectResponse(key: string): Promise<Response> {
136158
const response = await this.awsClient.fetch(this.buildUrl(key));
137159
if (!response.ok) {
138-
throw new Error(`Failed to download from object store: ${response.statusText}`);
160+
throw new ObjectStoreDownloadError(
161+
`Failed to download from object store: ${response.statusText}`,
162+
response.status,
163+
response.statusText
164+
);
139165
}
140166
return response;
141167
}
@@ -155,7 +181,11 @@ class Aws4FetchClient implements IObjectStoreClient {
155181
throw new ObjectVersionChangedError(key);
156182
}
157183
if (!response.ok) {
158-
throw new Error(`Failed to download range from object store: ${response.statusText}`);
184+
throw new ObjectStoreDownloadError(
185+
`Failed to download range from object store: ${response.statusText}`,
186+
response.status,
187+
response.statusText
188+
);
159189
}
160190
const bytes = new Uint8Array(await response.arrayBuffer());
161191
return {

0 commit comments

Comments
 (0)