Skip to content

Commit 34276b9

Browse files
authored
fix(connectors): stop hydration after rate limits (#7255)
* fix(connectors): stop hydration after rate limits * fix(connectors): normalize provider throttles * fix(connectors): inspect all drive error reasons
1 parent 5bb8977 commit 34276b9

8 files changed

Lines changed: 371 additions & 63 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { githubConnector } from '@/connectors/github/github'
6+
7+
describe('githubConnector.getDocument', () => {
8+
afterEach(() => {
9+
vi.unstubAllGlobals()
10+
})
11+
12+
it('uses the object media type and hydrates large file content through the blob API', async () => {
13+
const fetchMock = vi
14+
.fn()
15+
.mockResolvedValueOnce(
16+
new Response(
17+
JSON.stringify({
18+
sha: 'blob-sha',
19+
size: 2 * 1024 * 1024,
20+
content: '',
21+
encoding: 'none',
22+
}),
23+
{ status: 200, headers: { 'last-modified': 'Fri, 28 Aug 2026 12:00:00 GMT' } }
24+
)
25+
)
26+
.mockResolvedValueOnce(
27+
new Response('large text file', { status: 200, headers: { 'content-length': '15' } })
28+
)
29+
vi.stubGlobal('fetch', fetchMock)
30+
31+
const document = await githubConnector.getDocument(
32+
'token',
33+
{ repository: 'owner/repo', branch: 'main' },
34+
'docs/large.md'
35+
)
36+
37+
expect(fetchMock).toHaveBeenCalledTimes(2)
38+
expect(fetchMock.mock.calls[0][1]).toMatchObject({
39+
headers: expect.objectContaining({ Accept: 'application/vnd.github.object+json' }),
40+
})
41+
expect(fetchMock.mock.calls[1][1]).toMatchObject({
42+
headers: expect.objectContaining({ Accept: 'application/vnd.github.raw+json' }),
43+
})
44+
expect(document).toMatchObject({
45+
externalId: 'docs/large.md',
46+
content: 'large text file',
47+
contentDeferred: false,
48+
contentHash: 'git-sha:blob-sha',
49+
})
50+
})
51+
52+
it('returns null only when a listed path is no longer present', async () => {
53+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })))
54+
55+
await expect(
56+
githubConnector.getDocument('token', { repository: 'owner/repo' }, 'deleted.md')
57+
).resolves.toBeNull()
58+
})
59+
60+
it('records a blob that exceeds the byte cap as a visible skipped document', async () => {
61+
const fetchMock = vi
62+
.fn()
63+
.mockResolvedValueOnce(
64+
new Response(
65+
JSON.stringify({
66+
sha: 'blob-sha',
67+
size: 2 * 1024 * 1024,
68+
content: '',
69+
encoding: 'none',
70+
}),
71+
{ status: 200 }
72+
)
73+
)
74+
.mockResolvedValueOnce(
75+
new Response('oversized', {
76+
status: 200,
77+
headers: { 'content-length': String(100 * 1024 * 1024 + 1) },
78+
})
79+
)
80+
vi.stubGlobal('fetch', fetchMock)
81+
82+
await expect(
83+
githubConnector.getDocument('token', { repository: 'owner/repo' }, 'oversized.md')
84+
).resolves.toMatchObject({
85+
externalId: 'oversized.md',
86+
content: '',
87+
skippedReason: 'File exceeds the 100MB size limit and was not indexed',
88+
})
89+
})
90+
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+
113+
it('surfaces a non-rate-limit 403 as a document failure', async () => {
114+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 403 })))
115+
116+
await expect(
117+
githubConnector.getDocument('token', { repository: 'owner/repo' }, 'private.md')
118+
).rejects.toThrow('Failed to fetch file private.md: 403')
119+
})
120+
})

apps/sim/connectors/github/github.ts

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { githubConnectorMeta } from '@/connectors/github/meta'
55
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
66
import {
77
CONNECTOR_MAX_FILE_BYTES,
8+
ConnectorFileTooLargeError,
89
markSkipped,
910
parseTagDate,
11+
readBodyWithLimit,
1012
sizeLimitSkipReason,
1113
stubOrSkipBySize,
1214
takeIndexableWithinCap,
@@ -156,7 +158,7 @@ async function fetchBlobContent(
156158
const response = await fetchWithRetry(url, {
157159
method: 'GET',
158160
headers: {
159-
Accept: 'application/vnd.github+json',
161+
Accept: 'application/vnd.github.raw+json',
160162
Authorization: `Bearer ${accessToken}`,
161163
'X-GitHub-Api-Version': '2022-11-28',
162164
},
@@ -166,25 +168,20 @@ async function fetchBlobContent(
166168
throw new Error(`Failed to fetch git blob ${sha}: ${response.status}`)
167169
}
168170

169-
const data = await response.json()
170-
const content = (data.content as string) || ''
171-
const encoding = data.encoding as string | undefined
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+
}
172178

173-
if (encoding === 'base64') {
174-
const buf = Buffer.from(content, 'base64')
175-
if (isBinaryBuffer(buf)) return null
176-
return buf.toString('utf8')
179+
const buffer = await readBodyWithLimit(response, MAX_FILE_SIZE)
180+
if (!buffer) {
181+
throw new ConnectorFileTooLargeError(MAX_FILE_SIZE)
177182
}
178-
/**
179-
* `GET /repos/{owner}/{repo}/git/blobs/{sha}` documents a single response
180-
* encoding: "The `content` in the response will always be Base64 encoded."
181-
* The "Currently, `utf-8` and `base64` are supported" sentence belongs to the
182-
* `encoding` REQUEST parameter of `POST .../git/blobs` (Create a blob) and does
183-
* not describe this response, so no `utf-8` branch is warranted here. Any other
184-
* encoding would silently persist empty content, so it throws and surfaces as a
185-
* failed document instead.
186-
*/
187-
throw new Error(`Unexpected git blob encoding for ${sha}: ${encoding ?? 'undefined'}`)
183+
if (isBinaryBuffer(buffer)) return null
184+
return buffer.toString('utf8')
188185
}
189186

190187
/**
@@ -325,33 +322,14 @@ export const githubConnector: ConnectorConfig = {
325322
const response = await fetchWithRetry(url, {
326323
method: 'GET',
327324
headers: {
328-
Accept: 'application/vnd.github+json',
325+
Accept: 'application/vnd.github.object+json',
329326
Authorization: `Bearer ${accessToken}`,
330327
'X-GitHub-Api-Version': '2022-11-28',
331328
},
332329
})
333330

334331
if (!response.ok) {
335332
if (response.status === 404) return null
336-
/**
337-
* A rate-limit 403 never reaches here: `fetchWithRetry` treats a 403 carrying
338-
* `retry-after` or `x-ratelimit-remaining: 0` as retryable and throws once the
339-
* retries are spent, so it lands in the catch below as a failure.
340-
*
341-
* A 403 that survives is usually an authorization denial, but NOT always: this
342-
* request sends `application/vnd.github+json`, and the Contents API documents
343-
* that files between 1-100 MB support "only the `raw` or `object` custom media
344-
* types". A >1 MB text file therefore also lands here and is dropped, which on
345-
* an `add` is silent (a fulfilled `null` records no failure). Reconciliation is
346-
* unaffected — the file is already in `seenExternalIds` from the listing.
347-
*/
348-
if (response.status === 403) {
349-
logger.info('Skipping GitHub file rejected by Contents API', {
350-
path,
351-
status: response.status,
352-
})
353-
return null
354-
}
355333
throw new Error(`Failed to fetch file ${path}: ${response.status}`)
356334
}
357335

@@ -392,15 +370,18 @@ export const githubConnector: ConnectorConfig = {
392370
* "only the `raw` or `object` custom media types are supported", and it is
393371
* specifically "when using the `object` media type" that "the `content` field
394372
* will be an empty string and the `encoding` field will be `none`".
395-
*
396-
* This request sends `application/vnd.github+json`, so that precondition does
397-
* not hold and this branch is currently unreachable — such files 403 above
398-
* instead. Reaching it would require requesting
399-
* `application/vnd.github.object+json`. The fallback itself is correct: the Git
400-
* Blobs API returns the same blob as JSON and is documented to support blobs up
401-
* to 100 MB.
373+
* The Git Blobs fallback streams that same blob through GitHub's documented raw
374+
* media type and supports blobs up to 100 MB.
402375
*/
403-
const blobContent = await fetchBlobContent(accessToken, owner, repo, data.sha as string)
376+
let blobContent: string | null
377+
try {
378+
blobContent = await fetchBlobContent(accessToken, owner, repo, data.sha as string)
379+
} catch (error) {
380+
if (error instanceof ConnectorFileTooLargeError) {
381+
return markSkipped(stub, sizeLimitSkipReason(MAX_FILE_SIZE))
382+
}
383+
throw error
384+
}
404385
if (blobContent === null) {
405386
logger.info('Skipping binary GitHub file', { path, size })
406387
return markSkipped(stub, BINARY_SKIP_REASON)

apps/sim/connectors/google-drive/google-drive-errors.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ const PERMISSION_REASONS = new Set([
1212
const POLICY_REASONS = new Set(['domainPolicy', 'download_restricted_for_revision'])
1313
const UNSUPPORTED_EXPORT_REASONS = new Set(['fileNotDownloadable', 'fileNotExportable'])
1414
const 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

2322
export type GoogleDriveErrorKind =
2423
| 'authorization'
@@ -99,15 +98,22 @@ function classifyGoogleDriveError(
9998

10099
export class GoogleDriveApiError extends Error {
101100
retryAfterMs?: number
101+
readonly reasons: readonly string[]
102+
readonly kind: GoogleDriveErrorKind
103+
readonly rateLimited: boolean
102104

103105
constructor(
104106
readonly status: number,
105-
readonly reasons: readonly string[],
106-
readonly kind: GoogleDriveErrorKind
107+
normalizedReasons: readonly string[]
107108
) {
108-
const reasonSuffix = reasons.length > 0 ? ` (${reasons.join(', ')})` : ''
109+
const diagnosticReasons = normalizedReasons.slice(0, GOOGLE_ERROR_REASON_MAX_COUNT)
110+
const reasonSuffix = diagnosticReasons.length > 0 ? ` (${diagnosticReasons.join(', ')})` : ''
109111
super(`Google Drive API request failed with HTTP ${status}${reasonSuffix}`)
110112
this.name = 'GoogleDriveApiError'
113+
this.reasons = diagnosticReasons
114+
this.kind = classifyGoogleDriveError(status, normalizedReasons)
115+
this.rateLimited =
116+
status === 429 || normalizedReasons.some((reason) => RATE_LIMIT_REASONS.has(reason))
111117
}
112118
}
113119

@@ -130,13 +136,8 @@ export async function readGoogleDriveApiError(response: Response): Promise<Googl
130136

131137
const entries = parsedBody?.error?.errors ?? []
132138
const rawReasons = [...new Set(entries.flatMap((entry) => (entry.reason ? [entry.reason] : [])))]
133-
const reasons = [...new Set(rawReasons.flatMap((reason) => normalizeReason(reason) ?? []))].slice(
134-
0,
135-
GOOGLE_ERROR_REASON_MAX_COUNT
136-
)
137-
return new GoogleDriveApiError(
138-
response.status,
139-
reasons,
140-
classifyGoogleDriveError(response.status, rawReasons)
141-
)
139+
const normalizedReasons = [
140+
...new Set(rawReasons.flatMap((reason) => normalizeReason(reason) ?? [])),
141+
]
142+
return new GoogleDriveApiError(response.status, normalizedReasons)
142143
}

apps/sim/connectors/google-drive/google-drive.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,39 @@ 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+
109+
it('detects a structured rate limit beyond the bounded diagnostic reasons', async () => {
110+
const reasons = [
111+
...Array.from({ length: 16 }, (_, index) => `providerReason${index}`),
112+
'userRateLimitExceeded',
113+
]
114+
const error = await readGoogleDriveApiError(
115+
jsonResponse(
116+
{
117+
error: {
118+
errors: reasons.map((reason) => ({ reason })),
119+
},
120+
},
121+
403
122+
)
123+
)
124+
125+
expect(error.reasons).toEqual(reasons.slice(0, 16))
126+
expect(error.kind).toBe('transient')
127+
expect(error.rateLimited).toBe(true)
128+
})
129+
97130
it('omits provider messages from diagnostics', async () => {
98131
const message = `Authorization: Bearer private-token\ncontext ${'x'.repeat(700)}`
99132
const error = await readGoogleDriveApiError(

0 commit comments

Comments
 (0)