@@ -18,10 +18,20 @@ export type WABClientErrorCode =
1818 | 'WAB_NETWORK_ERROR'
1919 | 'WAB_TIMEOUT'
2020 | 'WAB_HTTP_ERROR'
21+ | 'WAB_ENDPOINT_MISMATCH'
2122 | 'WAB_REQUEST_TOO_LARGE'
2223 | 'WAB_RESPONSE_TOO_LARGE'
2324 | 'WAB_INVALID_RESPONSE'
2425
26+ export interface WABClientErrorOptions {
27+ cause ?: unknown
28+ correlationId ?: string
29+ operation ?: string
30+ route ?: string
31+ endpointMarkerPresent ?: boolean
32+ responseCorrelationMatched ?: boolean
33+ }
34+
2535/**
2636 * A privacy-safe WAB transport failure. Response bodies and request payloads
2737 * are deliberately excluded from the error.
@@ -32,11 +42,22 @@ export class WABClientError extends Error {
3242 message : string ,
3343 public readonly retryable : boolean ,
3444 public readonly status ?: number ,
35- options ?: { cause ?: unknown }
45+ options : WABClientErrorOptions = { }
3646 ) {
3747 super ( message , options )
3848 this . name = 'WABClientError'
49+ this . correlationId = options . correlationId
50+ this . operation = options . operation
51+ this . route = options . route
52+ this . endpointMarkerPresent = options . endpointMarkerPresent
53+ this . responseCorrelationMatched = options . responseCorrelationMatched
3954 }
55+
56+ public readonly correlationId ?: string
57+ public readonly operation ?: string
58+ public readonly route ?: string
59+ public readonly endpointMarkerPresent ?: boolean
60+ public readonly responseCorrelationMatched ?: boolean
4061}
4162
4263export interface WABTransportOptions {
@@ -125,6 +146,14 @@ function isRetryableStatus (status: number): boolean {
125146 return status === 408 || status === 425 || status === 429 || status >= 500
126147}
127148
149+ function isSafeCorrelationId ( value : string ) : boolean {
150+ return / ^ [ A - Z a - z 0 - 9 ] [ A - Z a - z 0 - 9 . _ : - ] { 0 , 127 } $ / . test ( value )
151+ }
152+
153+ function isWabResponse ( response : Response ) : boolean {
154+ return response . headers . get ( 'X-WAB-Service' ) ?. toLowerCase ( ) === 'wab-server'
155+ }
156+
128157/**
129158 * Centralized, bounded transport used by every WAB client operation.
130159 *
@@ -175,14 +204,25 @@ export class WABTransport {
175204 this . telemetry = new Telemetry ( options . telemetry )
176205 }
177206
178- createCorrelationId ( ) : string | undefined {
179- return this . telemetry . enabled ? this . telemetry . createCorrelationId ( ) : undefined
207+ createCorrelationId ( ) : string {
208+ const correlationId = this . telemetry . createCorrelationId ( )
209+ return isSafeCorrelationId ( correlationId )
210+ ? correlationId
211+ : new Telemetry ( ) . createCorrelationId ( )
180212 }
181213
182214 async request < T > ( path : string , options : WABRequestOptions ) : Promise < T > {
183215 assertSafePath ( path )
184216 const method = options . method ?? 'POST'
185- const correlationId = options . correlationId ?? this . createCorrelationId ( )
217+ const correlationId = options . correlationId != null &&
218+ isSafeCorrelationId ( options . correlationId )
219+ ? options . correlationId
220+ : this . createCorrelationId ( )
221+ const requestContext : WABClientErrorOptions = {
222+ correlationId,
223+ operation : options . operation ,
224+ route : path
225+ }
186226 const startedAt = Date . now ( )
187227 const controller = new AbortController ( )
188228 let timer : ReturnType < typeof setTimeout > | undefined
@@ -210,7 +250,7 @@ export class WABTransport {
210250 'WAB request payload could not be encoded.' ,
211251 false ,
212252 undefined ,
213- { cause : error }
253+ { ... requestContext , cause : error }
214254 )
215255 this . captureFailure ( options . operation , method , path , correlationId , startedAt , normalized )
216256 throw normalized
@@ -219,7 +259,9 @@ export class WABTransport {
219259 const error = new WABClientError (
220260 'WAB_INVALID_REQUEST' ,
221261 'WAB request payload must be JSON-serializable.' ,
222- false
262+ false ,
263+ undefined ,
264+ requestContext
223265 )
224266 this . captureFailure ( options . operation , method , path , correlationId , startedAt , error )
225267 throw error
@@ -228,7 +270,9 @@ export class WABTransport {
228270 const error = new WABClientError (
229271 'WAB_REQUEST_TOO_LARGE' ,
230272 'WAB request exceeded the configured size limit.' ,
231- false
273+ false ,
274+ undefined ,
275+ requestContext
232276 )
233277 this . captureFailure ( options . operation , method , path , correlationId , startedAt , error )
234278 throw error
@@ -240,7 +284,7 @@ export class WABTransport {
240284 headers : {
241285 Accept : 'application/json' ,
242286 ...( body !== undefined ? { 'Content-Type' : 'application/json' } : { } ) ,
243- ... ( correlationId !== undefined ? { 'X-Correlation-ID' : correlationId } : { } )
287+ 'X-Correlation-ID' : correlationId
244288 } ,
245289 ...( body !== undefined ? { body } : { } ) ,
246290 signal : controller . signal ,
@@ -255,7 +299,13 @@ export class WABTransport {
255299 timer = setTimeout ( ( ) => {
256300 timedOut = true
257301 controller . abort ( )
258- reject ( new WABClientError ( 'WAB_TIMEOUT' , 'WAB request timed out.' , true ) )
302+ reject ( new WABClientError (
303+ 'WAB_TIMEOUT' ,
304+ 'WAB request timed out.' ,
305+ true ,
306+ undefined ,
307+ requestContext
308+ ) )
259309 } , this . timeoutMs )
260310 } )
261311
@@ -271,19 +321,32 @@ export class WABTransport {
271321 timedOut ? 'WAB request timed out.' : 'WAB request failed before receiving a response.' ,
272322 true ,
273323 undefined ,
274- { cause : error }
324+ { ... requestContext , cause : error }
275325 )
276326 this . captureFailure ( options . operation , method , path , correlationId , startedAt , normalized )
277327 throw normalized
278328 }
279329
330+ const endpointMarkerPresent = isWabResponse ( response )
331+ const responseCorrelationMatched =
332+ response . headers . get ( 'X-Correlation-ID' ) === correlationId
333+ const responseContext : WABClientErrorOptions = {
334+ ...requestContext ,
335+ endpointMarkerPresent,
336+ responseCorrelationMatched
337+ }
338+
280339 if ( ! response . ok ) {
281340 if ( timer !== undefined ) clearTimeout ( timer )
341+ const endpointMismatch = response . status === 404 && ! endpointMarkerPresent
282342 const error = new WABClientError (
283- 'WAB_HTTP_ERROR' ,
284- `WAB request failed with HTTP status ${ response . status } .` ,
343+ endpointMismatch ? 'WAB_ENDPOINT_MISMATCH' : 'WAB_HTTP_ERROR' ,
344+ endpointMismatch
345+ ? 'Configured WAB endpoint did not return a compatible WAB response.'
346+ : `WAB request failed with HTTP status ${ response . status } .` ,
285347 isRetryableStatus ( response . status ) ,
286- response . status
348+ response . status ,
349+ responseContext
287350 )
288351 this . captureFailure ( options . operation , method , path , correlationId , startedAt , error )
289352 void response . body ?. cancel ( ) . catch ( ( ) => { /* best effort only */ } )
@@ -293,7 +356,7 @@ export class WABTransport {
293356 let responseText : string
294357 try {
295358 responseText = await Promise . race ( [
296- this . readBoundedResponse ( response ) ,
359+ this . readBoundedResponse ( response , responseContext ) ,
297360 timeoutPromise
298361 ] )
299362 } catch ( error ) {
@@ -304,7 +367,7 @@ export class WABTransport {
304367 timedOut ? 'WAB request timed out.' : 'WAB response could not be read.' ,
305368 true ,
306369 response . status ,
307- { cause : error }
370+ { ... responseContext , cause : error }
308371 )
309372 this . captureFailure ( options . operation , method , path , correlationId , startedAt , normalized )
310373 throw normalized
@@ -321,7 +384,7 @@ export class WABTransport {
321384 'WAB response was not valid JSON.' ,
322385 true ,
323386 response . status ,
324- { cause : error }
387+ { ... responseContext , cause : error }
325388 )
326389 this . captureFailure ( options . operation , method , path , correlationId , startedAt , normalized )
327390 throw normalized
@@ -331,7 +394,8 @@ export class WABTransport {
331394 'WAB_INVALID_RESPONSE' ,
332395 'WAB response must be a JSON object.' ,
333396 true ,
334- response . status
397+ response . status ,
398+ responseContext
335399 )
336400 this . captureFailure ( options . operation , method , path , correlationId , startedAt , error )
337401 throw error
@@ -348,43 +412,56 @@ export class WABTransport {
348412 route : path ,
349413 serverOrigin : this . serverOrigin ,
350414 status : response . status ,
415+ endpointMarkerPresent,
416+ responseCorrelationMatched,
351417 responseBytes : new TextEncoder ( ) . encode ( responseText ) . byteLength ,
352418 durationMs : Date . now ( ) - startedAt
353419 }
354420 } )
355421 return parsed as T
356422 }
357423
358- private async readBoundedResponse ( response : Response ) : Promise < string > {
359- const contentLength = Number ( response . headers . get ( 'content-length' ) )
360- if ( Number . isFinite ( contentLength ) && contentLength > this . maxResponseBytes ) {
361- try {
362- await response . body ?. cancel ( )
363- } catch {
364- // Best effort only.
365- }
366- throw new WABClientError (
367- 'WAB_RESPONSE_TOO_LARGE' ,
368- 'WAB response exceeded the configured size limit.' ,
369- false ,
370- response . status
371- )
372- }
424+ private async readBoundedResponse (
425+ response : Response ,
426+ responseContext : WABClientErrorOptions
427+ ) : Promise < string > {
428+ await this . rejectOversizedDeclaredResponse ( response , responseContext )
373429
374430 const reader = response . body ?. getReader ( )
375431 if ( reader == null ) {
376- const bytes = new Uint8Array ( await response . arrayBuffer ( ) )
377- if ( bytes . byteLength > this . maxResponseBytes ) {
378- throw new WABClientError (
379- 'WAB_RESPONSE_TOO_LARGE' ,
380- 'WAB response exceeded the configured size limit.' ,
381- false ,
382- response . status
383- )
384- }
385- return new TextDecoder ( ) . decode ( bytes )
432+ return await this . readBoundedArrayBuffer ( response , responseContext )
433+ }
434+
435+ return await this . readBoundedStream ( reader , response , responseContext )
436+ }
437+
438+ private async rejectOversizedDeclaredResponse (
439+ response : Response ,
440+ responseContext : WABClientErrorOptions
441+ ) : Promise < void > {
442+ const contentLength = Number ( response . headers . get ( 'content-length' ) )
443+ if ( ! Number . isFinite ( contentLength ) || contentLength <= this . maxResponseBytes ) return
444+
445+ await this . cancelResponseBody ( response )
446+ throw this . responseTooLargeError ( response , responseContext )
447+ }
448+
449+ private async readBoundedArrayBuffer (
450+ response : Response ,
451+ responseContext : WABClientErrorOptions
452+ ) : Promise < string > {
453+ const bytes = new Uint8Array ( await response . arrayBuffer ( ) )
454+ if ( bytes . byteLength > this . maxResponseBytes ) {
455+ throw this . responseTooLargeError ( response , responseContext )
386456 }
457+ return new TextDecoder ( ) . decode ( bytes )
458+ }
387459
460+ private async readBoundedStream (
461+ reader : ReadableStreamDefaultReader < Uint8Array > ,
462+ response : Response ,
463+ responseContext : WABClientErrorOptions
464+ ) : Promise < string > {
388465 const chunks : Uint8Array [ ] = [ ]
389466 let total = 0
390467 while ( true ) {
@@ -393,21 +470,16 @@ export class WABTransport {
393470 if ( value == null ) continue
394471 total += value . byteLength
395472 if ( total > this . maxResponseBytes ) {
396- try {
397- await reader . cancel ( )
398- } catch {
399- // Best effort only.
400- }
401- throw new WABClientError (
402- 'WAB_RESPONSE_TOO_LARGE' ,
403- 'WAB response exceeded the configured size limit.' ,
404- false ,
405- response . status
406- )
473+ await this . cancelResponseReader ( reader )
474+ throw this . responseTooLargeError ( response , responseContext )
407475 }
408476 chunks . push ( value )
409477 }
410478
479+ return this . decodeChunks ( chunks , total )
480+ }
481+
482+ private decodeChunks ( chunks : Uint8Array [ ] , total : number ) : string {
411483 const bytes = new Uint8Array ( total )
412484 let offset = 0
413485 for ( const chunk of chunks ) {
@@ -417,11 +489,42 @@ export class WABTransport {
417489 return new TextDecoder ( ) . decode ( bytes )
418490 }
419491
492+ private responseTooLargeError (
493+ response : Response ,
494+ responseContext : WABClientErrorOptions
495+ ) : WABClientError {
496+ return new WABClientError (
497+ 'WAB_RESPONSE_TOO_LARGE' ,
498+ 'WAB response exceeded the configured size limit.' ,
499+ false ,
500+ response . status ,
501+ responseContext
502+ )
503+ }
504+
505+ private async cancelResponseBody ( response : Response ) : Promise < void > {
506+ try {
507+ await response . body ?. cancel ( )
508+ } catch {
509+ // Best effort only.
510+ }
511+ }
512+
513+ private async cancelResponseReader (
514+ reader : ReadableStreamDefaultReader < Uint8Array >
515+ ) : Promise < void > {
516+ try {
517+ await reader . cancel ( )
518+ } catch {
519+ // Best effort only.
520+ }
521+ }
522+
420523 private captureFailure (
421524 operation : string ,
422525 method : 'GET' | 'POST' ,
423526 path : string ,
424- correlationId : string | undefined ,
527+ correlationId : string ,
425528 startedAt : number ,
426529 error : WABClientError
427530 ) : void {
@@ -437,6 +540,12 @@ export class WABTransport {
437540 serverOrigin : this . serverOrigin ,
438541 retryable : error . retryable ,
439542 ...( error . status !== undefined ? { status : error . status } : { } ) ,
543+ ...( error . endpointMarkerPresent !== undefined
544+ ? { endpointMarkerPresent : error . endpointMarkerPresent }
545+ : { } ) ,
546+ ...( error . responseCorrelationMatched !== undefined
547+ ? { responseCorrelationMatched : error . responseCorrelationMatched }
548+ : { } ) ,
440549 durationMs : Date . now ( ) - startedAt
441550 } ,
442551 error
0 commit comments