Skip to content

Commit 55a3e9e

Browse files
error handling for stream path
1 parent 5c624cd commit 55a3e9e

3 files changed

Lines changed: 45 additions & 18 deletions

File tree

packages/web/src/app/[domain]/search/useStreamedSearch.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export const useStreamedSearch = ({ query, matches, contextLines, whole, isRegex
222222
} : {}),
223223
}));
224224
break;
225+
case 'error':
226+
throw new ServiceErrorException(response.error);
225227
}
226228

227229
numMessagesProcessed++;

packages/web/src/features/search/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CodeHostType } from "@sourcebot/db";
22
import { z } from "zod";
3+
import { serviceErrorSchema } from "@/lib/serviceError";
34

45
export const locationSchema = z.object({
56
byteOffset: z.number(), // 0-based byte offset from the beginning of the file
@@ -126,10 +127,19 @@ export const streamedSearchFinalResponseSchema = z.object({
126127
});
127128
export type StreamedSearchFinalResponse = z.infer<typeof streamedSearchFinalResponseSchema>;
128129

130+
/**
131+
* Sent when an error occurs during streaming.
132+
*/
133+
export const streamedSearchErrorResponseSchema = z.object({
134+
type: z.literal('error'),
135+
error: serviceErrorSchema,
136+
});
137+
export type StreamedSearchErrorResponse = z.infer<typeof streamedSearchErrorResponseSchema>;
129138

130139
export const streamedSearchResponseSchema = z.discriminatedUnion('type', [
131140
streamedSearchChunkResponseSchema,
132141
streamedSearchFinalResponseSchema,
142+
streamedSearchErrorResponseSchema,
133143
]);
134144
export type StreamedSearchResponse = z.infer<typeof streamedSearchResponseSchema>;
135145

packages/web/src/features/search/zoektSearcher.ts

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getCodeHostBrowseFileAtBranchUrl } from "@/lib/utils";
2+
import { unexpectedError } from "@/lib/serviceError";
23
import type { ProtoGrpcType } from '@/proto/webserver';
34
import { FileMatch__Output as ZoektGrpcFileMatch } from "@/proto/zoekt/webserver/v1/FileMatch";
45
import { FlushReason as ZoektGrpcFlushReason } from "@/proto/zoekt/webserver/v1/FlushReason";
@@ -15,7 +16,7 @@ import { PrismaClient, Repo } from "@sourcebot/db";
1516
import { createLogger, env } from "@sourcebot/shared";
1617
import path from 'path';
1718
import { QueryIR, someInQueryIR } from './ir';
18-
import { RepositoryInfo, SearchResponse, SearchResultFile, SearchStats, SourceRange, StreamedSearchResponse } from "./types";
19+
import { RepositoryInfo, SearchResponse, SearchResultFile, SearchStats, SourceRange, StreamedSearchErrorResponse, StreamedSearchResponse } from "./types";
1920

2021
const logger = createLogger("zoekt-searcher");
2122

@@ -177,8 +178,8 @@ export const zoektStreamSearch = async (searchRequest: ZoektGrpcSearchRequest, p
177178
isSearchExhaustive: accumulatedStats.totalMatchCount <= accumulatedStats.actualMatchCount,
178179
}
179180

180-
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finalResponse)}\n\n`));
181-
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
181+
controller.enqueue(encodeSSEREsponseChunk(finalResponse));
182+
controller.enqueue(encodeSSEREsponseChunk('[DONE]'));
182183
controller.close();
183184
client.close();
184185
logger.debug('SSE stream closed');
@@ -231,10 +232,18 @@ export const zoektStreamSearch = async (searchRequest: ZoektGrpcSearchRequest, p
231232
stats
232233
}
233234

234-
const sseData = `data: ${JSON.stringify(response)}\n\n`;
235-
controller.enqueue(new TextEncoder().encode(sseData));
235+
controller.enqueue(encodeSSEREsponseChunk(response));
236236
} catch (error) {
237-
console.error('Error encoding chunk:', error);
237+
logger.error('Error processing chunk:', error);
238+
Sentry.captureException(error);
239+
isStreamActive = false;
240+
241+
const errorMessage = error instanceof Error ? error.message : 'Unknown error processing chunk';
242+
const errorResponse: StreamedSearchErrorResponse = {
243+
type: 'error',
244+
error: unexpectedError(errorMessage),
245+
};
246+
controller.enqueue(encodeSSEREsponseChunk(errorResponse));
238247
} finally {
239248
pendingChunks--;
240249
grpcStream?.resume();
@@ -270,26 +279,26 @@ export const zoektStreamSearch = async (searchRequest: ZoektGrpcSearchRequest, p
270279
}
271280
isStreamActive = false;
272281

273-
// Send error as SSE event
274-
const errorData = `data: ${JSON.stringify({
275-
error: {
276-
code: error.code,
277-
message: error.details || error.message,
278-
}
279-
})}\n\n`;
280-
controller.enqueue(new TextEncoder().encode(errorData));
282+
// Send properly typed error response
283+
const errorResponse: StreamedSearchErrorResponse = {
284+
type: 'error',
285+
error: unexpectedError(error.details || error.message),
286+
};
287+
controller.enqueue(encodeSSEREsponseChunk(errorResponse));
281288

282289
controller.close();
283290
client.close();
284291
});
285292
} catch (error) {
286293
logger.error('Stream initialization error:', error);
294+
Sentry.captureException(error);
287295

288296
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
289-
const errorData = `data: ${JSON.stringify({
290-
error: { message: errorMessage }
291-
})}\n\n`;
292-
controller.enqueue(new TextEncoder().encode(errorData));
297+
const errorResponse: StreamedSearchErrorResponse = {
298+
type: 'error',
299+
error: unexpectedError(errorMessage),
300+
};
301+
controller.enqueue(encodeSSEREsponseChunk(errorResponse));
293302

294303
controller.close();
295304
client.close();
@@ -309,6 +318,12 @@ export const zoektStreamSearch = async (searchRequest: ZoektGrpcSearchRequest, p
309318
});
310319
}
311320

321+
// Encodes a response chunk into a SSE-compatible format.
322+
const encodeSSEREsponseChunk = (response: object | string) => {
323+
const data = typeof response === 'string' ? response : JSON.stringify(response);
324+
return new TextEncoder().encode(`data: ${data}\n\n`);
325+
}
326+
312327
// Creates a mapping between all repository ids in a given response
313328
// chunk. The mapping allows us to efficiently lookup repository metadata.
314329
const createReposMapForChunk = async (chunk: ZoektGrpcSearchResponse, reposMapCache: Map<string | number, Repo>, prisma: PrismaClient): Promise<Map<string | number, Repo>> => {

0 commit comments

Comments
 (0)