@@ -10,6 +10,27 @@ import { config } from "../config";
1010// the in-cluster GCP Stage 1 service relies on cluster network isolation.
1111
1212const REQUEST_TIMEOUT_MS = 30000 ;
13+ const MAX_BATCH_ATTEMPTS = 2 ;
14+ const RETRY_DELAY_MS = 50 ;
15+
16+ export type HighlightFailureReason =
17+ | "timeout"
18+ | "network"
19+ | "http_4xx"
20+ | "http_5xx"
21+ | "invalid_response"
22+ | "unknown" ;
23+
24+ class HighlightHttpError extends Error {
25+ constructor (
26+ readonly status : number ,
27+ body : string ,
28+ ) {
29+ super ( `highlight model HTTP ${ status } : ${ body . slice ( 0 , 200 ) } ` ) ;
30+ }
31+ }
32+
33+ class HighlightInvalidResponseError extends Error { }
1334
1435// One highlight entry as returned by the service. Field semantics are
1536// intentionally left undocumented here.
@@ -47,6 +68,68 @@ interface HighlightResult {
4768 markdown : string ;
4869}
4970
71+ function failureReason ( error : unknown ) : HighlightFailureReason {
72+ if ( error instanceof HighlightHttpError ) {
73+ return error . status >= 500 ? "http_5xx" : "http_4xx" ;
74+ }
75+ if ( error instanceof SyntaxError ) {
76+ return "invalid_response" ;
77+ }
78+ if ( error instanceof HighlightInvalidResponseError ) {
79+ return "invalid_response" ;
80+ }
81+ if ( error instanceof Error && error . name === "AbortError" ) {
82+ return "timeout" ;
83+ }
84+ if ( error instanceof TypeError ) {
85+ return "network" ;
86+ }
87+ return "unknown" ;
88+ }
89+
90+ function waitForRetry ( signal : AbortSignal ) : Promise < void > {
91+ if ( signal . aborted ) {
92+ return Promise . reject ( signal . reason ) ;
93+ }
94+
95+ return new Promise ( ( resolve , reject ) => {
96+ const onAbort = ( ) => {
97+ clearTimeout ( timer ) ;
98+ reject ( signal . reason ) ;
99+ } ;
100+ const timer = setTimeout ( ( ) => {
101+ signal . removeEventListener ( "abort" , onAbort ) ;
102+ resolve ( ) ;
103+ } , RETRY_DELAY_MS ) ;
104+ signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
105+ } ) ;
106+ }
107+
108+ async function fetchBatchWithRetry (
109+ url : string ,
110+ init : RequestInit ,
111+ signal : AbortSignal ,
112+ ) : Promise < Response > {
113+ let lastError : unknown ;
114+ for ( let attempt = 1 ; attempt <= MAX_BATCH_ATTEMPTS ; attempt ++ ) {
115+ try {
116+ const response = await fetch ( url , init ) ;
117+ if ( response . status < 500 || attempt === MAX_BATCH_ATTEMPTS ) {
118+ return response ;
119+ }
120+ await response . body ?. cancel ( ) . catch ( ( ) => undefined ) ;
121+ lastError = new HighlightHttpError ( response . status , "" ) ;
122+ } catch ( error ) {
123+ lastError = error ;
124+ if ( signal . aborted || attempt === MAX_BATCH_ATTEMPTS ) {
125+ throw error ;
126+ }
127+ }
128+ await waitForRetry ( signal ) ;
129+ }
130+ throw lastError ;
131+ }
132+
50133function requestHeaders ( requestId ?: string ) : Record < string , string > {
51134 const headers : Record < string , string > = {
52135 "Content-Type" : "application/json" ,
@@ -74,6 +157,7 @@ export async function generateHighlightsBatch(
74157 logPayload ?: boolean ;
75158 allowLegacyFallback ?: boolean ;
76159 requestId ?: string ;
160+ onFailure ?: ( reason : HighlightFailureReason ) => void ;
77161 } ,
78162) : Promise < Map < string , HighlightResult > | null > {
79163 if ( pages . length === 0 ) {
@@ -85,12 +169,16 @@ export async function generateHighlightsBatch(
85169 const start = Date . now ( ) ;
86170 try {
87171 const baseUrl = config . HIGHLIGHT_MODEL_URL ! . replace ( / \/ $ / , "" ) ;
88- const res = await fetch ( `${ baseUrl } /batch_highlight` , {
89- method : "POST" ,
90- headers : requestHeaders ( opts . requestId ) ,
91- body : JSON . stringify ( { query, pages } ) ,
92- signal : controller . signal ,
93- } ) ;
172+ const res = await fetchBatchWithRetry (
173+ `${ baseUrl } /batch_highlight` ,
174+ {
175+ method : "POST" ,
176+ headers : requestHeaders ( opts . requestId ) ,
177+ body : JSON . stringify ( { query, pages } ) ,
178+ signal : controller . signal ,
179+ } ,
180+ controller . signal ,
181+ ) ;
94182
95183 if ( ! res . ok ) {
96184 const body = await res . text ( ) . catch ( ( ) => "" ) ;
@@ -113,14 +201,23 @@ export async function generateHighlightsBatch(
113201 opts ,
114202 ) ;
115203 }
116- throw new Error (
117- `highlight model HTTP ${ res . status } : ${ body . slice ( 0 , 200 ) } ` ,
118- ) ;
204+ throw new HighlightHttpError ( res . status , body ) ;
119205 }
120206
121- const data = ( await res . json ( ) ) as HighlightBatchResponse ;
207+ const data : unknown = await res . json ( ) ;
208+ if (
209+ typeof data !== "object" ||
210+ data === null ||
211+ Array . isArray ( data ) ||
212+ ( "pages" in data && ! Array . isArray ( data . pages ) )
213+ ) {
214+ throw new HighlightInvalidResponseError (
215+ "highlight model returned an invalid response" ,
216+ ) ;
217+ }
122218 const results = new Map < string , HighlightResult > ( ) ;
123- for ( const page of data . pages ?? [ ] ) {
219+ for ( const page of ( data as HighlightBatchResponse ) . pages ?? [ ] ) {
220+ if ( typeof page !== "object" || page === null ) continue ;
124221 if ( typeof page . id !== "string" || ! page . output ) continue ;
125222 results . set ( page . id , {
126223 highlights : page . output . highlights ?? [ ] ,
@@ -145,6 +242,7 @@ export async function generateHighlightsBatch(
145242
146243 return results ;
147244 } catch ( error ) {
245+ opts . onFailure ?.( failureReason ( error ) ) ;
148246 opts . logger . warn ( "query highlights batch failed" , {
149247 canonicalLog : "search/highlights" ,
150248 error : error instanceof Error ? error . message : String ( error ) ,
0 commit comments