From a95243ea4324f2cdceca019b90f67572b0587a7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 21:47:18 -0700 Subject: [PATCH] fix(mistral): distinguish response size failures --- .../app/api/tools/mistral/parse/route.test.ts | 136 ++++++++++++++++++ apps/sim/app/api/tools/mistral/parse/route.ts | 62 ++++---- 2 files changed, 172 insertions(+), 26 deletions(-) create mode 100644 apps/sim/app/api/tools/mistral/parse/route.test.ts diff --git a/apps/sim/app/api/tools/mistral/parse/route.test.ts b/apps/sim/app/api/tools/mistral/parse/route.test.ts new file mode 100644 index 00000000000..5dc56bae1b4 --- /dev/null +++ b/apps/sim/app/api/tools/mistral/parse/route.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + hybridAuthMockFns, + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance' +import { + RESOLVED_SECRET_PROVENANCE_FIELD, + RESOLVED_SECRET_PROVENANCE_METADATA_V1, +} from '@/lib/execution/private-tool-metadata' +import { MISTRAL_OCR_REQUEST_POLICY } from '@/lib/knowledge/documents/ocr-request-policy' + +const { mockDownloadServableFile, mockIsModelSafeWorkspaceFileKey } = vi.hoisted(() => ({ + mockDownloadServableFile: vi.fn(), + mockIsModelSafeWorkspaceFileKey: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadServableFileFromStorage: mockDownloadServableFile, + resolveInternalFileUrl: vi.fn(), +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + isModelSafeWorkspaceFileKey: mockIsModelSafeWorkspaceFileKey, + MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: + 'File cannot be sent to a model because its secret provenance is unavailable', +})) + +import { POST } from '@/app/api/tools/mistral/parse/route' + +const PDF_FILE = { + key: 'workspace/workspace-1/document.pdf', + name: 'document.pdf', + size: 3, + type: 'application/pdf', +} + +function createVerifiedRequest() { + return createMockRequest( + 'POST', + { + apiKey: 'mistral-key', + file: PDF_FILE, + [RESOLVED_SECRET_PROVENANCE_FIELD]: { + version: 1, + complete: true, + entries: [], + }, + }, + { [PRIVATE_MODEL_INPUT_PROVENANCE_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1 } + ) +} + +describe('POST /api/tools/mistral/parse', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({ + isValid: true, + resolvedIP: '93.184.216.34', + originalHostname: 'api.mistral.ai', + }) + mockIsModelSafeWorkspaceFileKey.mockResolvedValue(true) + mockDownloadServableFile.mockResolvedValue({ + buffer: Buffer.from('pdf'), + contentType: 'application/pdf', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValue( + Response.json({ pages: [], usage_info: { pages_processed: 0 } }) + ) + }) + + it('returns 413 when the input file exceeds Mistral request limits', async () => { + mockDownloadServableFile.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'storage file download', + maxBytes: MISTRAL_OCR_REQUEST_POLICY.maxBytes, + observedBytes: MISTRAL_OCR_REQUEST_POLICY.maxBytes + 1, + }) + ) + + const response = await POST(createVerifiedRequest()) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + success: false, + error: `File exceeds Mistral OCR's ${MISTRAL_OCR_REQUEST_POLICY.maxBytes.toLocaleString()}-byte request limit`, + }) + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() + }) + + it('returns 502 when Mistral response bytes exceed the secure-fetch cap', async () => { + const responseLimitError = new PayloadSizeLimitError({ + label: 'response body', + maxBytes: 100, + observedBytes: 101, + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + body: null, + text: async () => { + throw responseLimitError + }, + json: async () => { + throw responseLimitError + }, + arrayBuffer: async () => { + throw responseLimitError + }, + }) + + const response = await POST(createVerifiedRequest()) + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Mistral API response exceeded the safe size limit', + }) + }) +}) diff --git a/apps/sim/app/api/tools/mistral/parse/route.ts b/apps/sim/app/api/tools/mistral/parse/route.ts index 3e8ab0ef9dd..436384ffbc9 100644 --- a/apps/sim/app/api/tools/mistral/parse/route.ts +++ b/apps/sim/app/api/tools/mistral/parse/route.ts @@ -28,6 +28,7 @@ import { import { downloadServableFileFromStorage, resolveInternalFileUrl, + type ServableFile, } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -36,6 +37,16 @@ export const dynamic = 'force-dynamic' const logger = createLogger('MistralParseAPI') +function fileSizeLimitResponse() { + return NextResponse.json( + { + success: false, + error: `File exceeds Mistral OCR's ${MISTRAL_OCR_REQUEST_POLICY.maxBytes.toLocaleString()}-byte request limit`, + }, + { status: 413 } + ) +} + export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -159,14 +170,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - const { buffer, contentType } = await downloadServableFileFromStorage( - userFile, - requestId, - logger, - { + let servableFile: ServableFile + try { + servableFile = await downloadServableFileFromStorage(userFile, requestId, logger, { maxBytes: MISTRAL_OCR_REQUEST_POLICY.maxBytes, - } - ) + }) + } catch (error) { + if (!isPayloadSizeLimitError(error)) throw error + return fileSizeLimitResponse() + } + const { buffer, contentType } = servableFile base64 = buffer.toString('base64') if (contentType && contentType !== 'application/octet-stream') { mimeType = contentType @@ -180,25 +193,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : Buffer.byteLength(base64, 'base64') } catch (error) { const status = isFileParserError(error) && error.code === 'complexity_limit' ? 413 : 400 - return NextResponse.json( - { - success: false, - error: - status === 413 - ? `File exceeds Mistral OCR's ${MISTRAL_OCR_REQUEST_POLICY.maxBytes.toLocaleString()}-byte request limit` - : getErrorMessage(error, 'Invalid inline file data'), - }, - { status } - ) + return status === 413 + ? fileSizeLimitResponse() + : NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Invalid inline file data'), + }, + { status } + ) } if (inlineBytes > MISTRAL_OCR_REQUEST_POLICY.maxBytes) { - return NextResponse.json( - { - success: false, - error: `File exceeds Mistral OCR's ${MISTRAL_OCR_REQUEST_POLICY.maxBytes.toLocaleString()}-byte request limit`, - }, - { status: 413 } - ) + return fileSizeLimitResponse() } const base64Payload = base64.startsWith('data:') @@ -355,12 +361,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (notReady) return notReady if (isPayloadSizeLimitError(error)) { + logger.error(`[${requestId}] Mistral API response exceeded the safe size limit`, { + maxBytes: error.maxBytes, + observedBytes: error.observedBytes, + }) return NextResponse.json( { success: false, - error: `File exceeds Mistral OCR's ${MISTRAL_OCR_REQUEST_POLICY.maxBytes.toLocaleString()}-byte request limit`, + error: 'Mistral API response exceeded the safe size limit', }, - { status: 413 } + { status: 502 } ) }