File tree Expand file tree Collapse file tree
Expand file tree Collapse file tree Original file line number Diff line number Diff line change @@ -88,6 +88,28 @@ describe('githubConnector.getDocument', () => {
8888 } )
8989 } )
9090
91+ it ( 'rejects a bodyless blob response instead of misreporting it as oversized' , async ( ) => {
92+ const fetchMock = vi
93+ . fn ( )
94+ . mockResolvedValueOnce (
95+ new Response (
96+ JSON . stringify ( {
97+ sha : 'blob-sha' ,
98+ size : 2 * 1024 * 1024 ,
99+ content : '' ,
100+ encoding : 'none' ,
101+ } ) ,
102+ { status : 200 }
103+ )
104+ )
105+ . mockResolvedValueOnce ( new Response ( null , { status : 200 } ) )
106+ vi . stubGlobal ( 'fetch' , fetchMock )
107+
108+ await expect (
109+ githubConnector . getDocument ( 'token' , { repository : 'owner/repo' } , 'missing-body.md' )
110+ ) . rejects . toThrow ( 'GitHub git blob blob-sha returned no body' )
111+ } )
112+
91113 it ( 'surfaces a non-rate-limit 403 as a document failure' , async ( ) => {
92114 vi . stubGlobal ( 'fetch' , vi . fn ( ) . mockResolvedValue ( new Response ( null , { status : 403 } ) ) )
93115
Original file line number Diff line number Diff line change @@ -168,6 +168,14 @@ async function fetchBlobContent(
168168 throw new Error ( `Failed to fetch git blob ${ sha } : ${ response . status } ` )
169169 }
170170
171+ if ( ! response . body ) {
172+ const contentLength = Number . parseInt ( response . headers . get ( 'content-length' ) ?? '' , 10 )
173+ if ( Number . isFinite ( contentLength ) && contentLength > MAX_FILE_SIZE ) {
174+ throw new ConnectorFileTooLargeError ( MAX_FILE_SIZE )
175+ }
176+ throw new Error ( `GitHub git blob ${ sha } returned no body` )
177+ }
178+
171179 const buffer = await readBodyWithLimit ( response , MAX_FILE_SIZE )
172180 if ( ! buffer ) {
173181 throw new ConnectorFileTooLargeError ( MAX_FILE_SIZE )
Original file line number Diff line number Diff line change @@ -12,13 +12,12 @@ const PERMISSION_REASONS = new Set([
1212const POLICY_REASONS = new Set ( [ 'domainPolicy' , 'download_restricted_for_revision' ] )
1313const UNSUPPORTED_EXPORT_REASONS = new Set ( [ 'fileNotDownloadable' , 'fileNotExportable' ] )
1414const QUOTA_REASONS = new Set ( [ 'dailyLimitExceeded' , 'quotaExceeded' ] )
15- const TRANSIENT_REASONS = new Set ( [
16- 'backendError' ,
17- 'internalError' ,
15+ const RATE_LIMIT_REASONS = new Set ( [
1816 'rateLimitExceeded' ,
1917 'sharingRateLimitExceeded' ,
2018 'userRateLimitExceeded' ,
2119] )
20+ const TRANSIENT_REASONS = new Set ( [ 'backendError' , 'internalError' , ...RATE_LIMIT_REASONS ] )
2221
2322export type GoogleDriveErrorKind =
2423 | 'authorization'
@@ -99,6 +98,7 @@ function classifyGoogleDriveError(
9998
10099export class GoogleDriveApiError extends Error {
101100 retryAfterMs ?: number
101+ readonly rateLimited : boolean
102102
103103 constructor (
104104 readonly status : number ,
@@ -108,6 +108,7 @@ export class GoogleDriveApiError extends Error {
108108 const reasonSuffix = reasons . length > 0 ? ` (${ reasons . join ( ', ' ) } )` : ''
109109 super ( `Google Drive API request failed with HTTP ${ status } ${ reasonSuffix } ` )
110110 this . name = 'GoogleDriveApiError'
111+ this . rateLimited = status === 429 || reasons . some ( ( reason ) => RATE_LIMIT_REASONS . has ( reason ) )
111112 }
112113}
113114
Original file line number Diff line number Diff line change @@ -94,6 +94,18 @@ describe('Google Drive API error parsing', () => {
9494 expect ( error . message ) . not . toContain ( 'upstream unavailable' )
9595 } )
9696
97+ it ( 'normalizes only structured rate-limit reasons into the shared throttle signal' , async ( ) => {
98+ const rateLimit = await readGoogleDriveApiError (
99+ driveErrorResponse ( 'userRateLimitExceeded' , 'Provider message' )
100+ )
101+ const backendFailure = await readGoogleDriveApiError (
102+ driveErrorResponse ( 'backendError' , 'Provider message' , 503 )
103+ )
104+
105+ expect ( rateLimit . rateLimited ) . toBe ( true )
106+ expect ( backendFailure . rateLimited ) . toBe ( false )
107+ } )
108+
97109 it ( 'omits provider messages from diagnostics' , async ( ) => {
98110 const message = `Authorization: Bearer private-token\ncontext ${ 'x' . repeat ( 700 ) } `
99111 const error = await readGoogleDriveApiError (
Original file line number Diff line number Diff line change @@ -663,6 +663,17 @@ describe('isRateLimitError', () => {
663663 expect ( isRateLimitError ( new Error ( 'hydration failed' , { cause : providerError } ) ) ) . toBe ( true )
664664 } )
665665
666+ it ( 'accepts a provider-normalized throttle without HTTP rate-limit headers' , ( ) => {
667+ expect (
668+ isRateLimitError (
669+ Object . assign ( new Error ( 'Google Drive API request failed with HTTP 403' ) , {
670+ status : 403 ,
671+ rateLimited : true ,
672+ } )
673+ )
674+ ) . toBe ( true )
675+ } )
676+
666677 it ( 'does not classify retryable text or transient HTTP failures as provider throttling' , ( ) => {
667678 expect ( isRateLimitError ( new Error ( 'rate limit exceeded' ) ) ) . toBe ( false )
668679 expect ( isRateLimitError ( Object . assign ( new Error ( 'unavailable' ) , { status : 503 } ) ) ) . toBe ( false )
Original file line number Diff line number Diff line change @@ -26,6 +26,8 @@ export interface HTTPError extends Error {
2626 status ?: number
2727 statusText ?: string
2828 retryAfterMs ?: number
29+ /** Provider-normalized signal for throttles that do not carry standard HTTP evidence. */
30+ rateLimited ?: boolean
2931 /**
3032 * Response headers carried onto the error so the retry loop can re-evaluate
3133 * rate-limit evidence (`isRetryableError` runs again on the thrown error).
@@ -239,6 +241,7 @@ export function isRateLimitError(error: unknown): boolean {
239241
240242 while ( isRetryableErrorType ( current ) && ! seen . has ( current ) && seen . size < 10 ) {
241243 seen . add ( current )
244+ if ( ( current as HTTPError ) . rateLimited === true ) return true
242245 if (
243246 hasStatus ( current ) &&
244247 ( current . status === 429 ||
You can’t perform that action at this time.
0 commit comments