From 0f585497ea05f15e2624fc0431b52880c9bb0b39 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 1 Sep 2026 16:52:48 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=AE=20fix:=20Replace=20Backtracking=20?= =?UTF-8?q?LangChain=20Error=20Regexes=20(#15473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/data-provider/src/langchain.spec.ts | 27 +++++ packages/data-provider/src/langchain.ts | 116 ++++++++++++++++++- 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/packages/data-provider/src/langchain.spec.ts b/packages/data-provider/src/langchain.spec.ts index 839083f571..3e4bb693fe 100644 --- a/packages/data-provider/src/langchain.spec.ts +++ b/packages/data-provider/src/langchain.spec.ts @@ -16,6 +16,12 @@ describe('stripLangChainTroubleshootingUrl', () => { expect(stripLangChainTroubleshootingUrl(message)).toBe('outer inner'); }); + it('removes through the final classification when one URL carries more than one error path', () => { + const message = + 'failed Troubleshooting URL: https://docs.langchain.com/errors/FIRST/wrapped/errors/MODEL_NOT_FOUND/'; + expect(stripLangChainTroubleshootingUrl(message)).toBe('failed'); + }); + it('leaves provider text without the tail untouched', () => { const message = '400 Bad Request\nRequest contains an invalid argument'; expect(stripLangChainTroubleshootingUrl(message)).toBe(message); @@ -29,6 +35,14 @@ describe('stripLangChainTroubleshootingUrl', () => { it.each([undefined, null])('treats an absent message as empty', (message) => { expect(stripLangChainTroubleshootingUrl(message)).toBe(''); }); + + it('handles adversarial non-matching URLs without regex backtracking', () => { + const message = `Troubleshooting URL: https://${'langchain/'.repeat(8_000)}missing`; + const startedAt = Date.now(); + + expect(stripLangChainTroubleshootingUrl(message)).toBe(message); + expect(Date.now() - startedAt).toBeLessThan(500); + }); }); describe('parseLangChainErrorCode', () => { @@ -39,10 +53,23 @@ describe('parseLangChainErrorCode', () => { }, ); + it('reads the final classification from a URL that carries more than one error path', () => { + const message = 'https://docs.langchain.com/errors/FIRST/wrapped/errors/MODEL_NOT_FOUND/'; + expect(parseLangChainErrorCode(message)).toBe('MODEL_NOT_FOUND'); + }); + it.each(['429 Too Many Requests', '', undefined, { message: 'nested' }])( 'returns undefined when the text carries no classification', (message) => { expect(parseLangChainErrorCode(message)).toBeUndefined(); }, ); + + it('handles adversarial non-matching URLs without regex backtracking', () => { + const message = `https://${'langchain/'.repeat(8_000)}missing`; + const startedAt = Date.now(); + + expect(parseLangChainErrorCode(message)).toBeUndefined(); + expect(Date.now() - startedAt).toBeLessThan(500); + }); }); diff --git a/packages/data-provider/src/langchain.ts b/packages/data-provider/src/langchain.ts index df4a0c6889..4b5de23aa6 100644 --- a/packages/data-provider/src/langchain.ts +++ b/packages/data-provider/src/langchain.ts @@ -4,10 +4,10 @@ * because the server strips the URL before persisting the message, while the client still reads the * code back out of messages persisted before it did. */ -const TROUBLESHOOTING_URL = - /\s*Troubleshooting URL:\s*https?:\/\/\S*langchain\S*\/errors\/[A-Za-z_]+\/?\s*/g; - -const ERROR_CODE_URL = /langchain\S*\/errors\/([A-Za-z_]+)/i; +const LANGCHAIN = 'langchain'; +const ERROR_PATH = '/errors/'; +const TROUBLESHOOTING_LABEL = 'Troubleshooting URL:'; +const WHITESPACE = /\s/; /** Provider errors cross untyped boundaries, so a `message` is not guaranteed to be a string. */ function toMessageText(message: unknown): string { @@ -17,12 +17,116 @@ function toMessageText(message: unknown): string { return message == null ? '' : String(message); } +function isErrorCodeCharacter(character: string): boolean { + const code = character.charCodeAt(0); + return character === '_' || (code >= 65 && code <= 90) || (code >= 97 && code <= 122); +} + +function findTokenEnd(text: string, start: number, limit = text.length): number { + let end = start; + while (end < limit && !WHITESPACE.test(text[end])) { + end += 1; + } + return end; +} + +function findErrorCode( + text: string, + searchableText: string, + start: number, + end: number, +): { start: number; end: number } | undefined { + const langChainIndex = searchableText.indexOf(LANGCHAIN, start); + if (langChainIndex < 0 || langChainIndex >= end) { + return undefined; + } + + let errorCode: { start: number; end: number } | undefined; + let pathIndex = searchableText.indexOf(ERROR_PATH, langChainIndex + LANGCHAIN.length); + while (pathIndex >= 0 && pathIndex < end) { + const codeStart = pathIndex + ERROR_PATH.length; + let codeEnd = codeStart; + while (codeEnd < end && isErrorCodeCharacter(text[codeEnd])) { + codeEnd += 1; + } + if (codeEnd > codeStart) { + errorCode = { start: codeStart, end: codeEnd }; + } + pathIndex = searchableText.indexOf(ERROR_PATH, Math.max(codeStart, codeEnd)); + } + + return errorCode; +} + /** Removes LangChain's appended docs URL so provider text carries no third-party attribution. */ export function stripLangChainTroubleshootingUrl(message: unknown): string { - return toMessageText(message).replace(TROUBLESHOOTING_URL, ' ').trim(); + const text = toMessageText(message); + const parts: string[] = []; + let copiedUntil = 0; + let searchFrom = 0; + + while (searchFrom < text.length) { + const labelStart = text.indexOf(TROUBLESHOOTING_LABEL, searchFrom); + if (labelStart < 0) { + break; + } + + let matchStart = labelStart; + while (matchStart > copiedUntil && WHITESPACE.test(text[matchStart - 1])) { + matchStart -= 1; + } + + let urlStart = labelStart + TROUBLESHOOTING_LABEL.length; + while (urlStart < text.length && WHITESPACE.test(text[urlStart])) { + urlStart += 1; + } + if (!text.startsWith('https://', urlStart) && !text.startsWith('http://', urlStart)) { + searchFrom = urlStart; + continue; + } + + const urlEnd = findTokenEnd(text, urlStart); + const errorCode = findErrorCode(text, text, urlStart, urlEnd); + if (errorCode == null) { + searchFrom = urlEnd; + continue; + } + + let matchEnd = errorCode.end; + if (text[matchEnd] === '/') { + matchEnd += 1; + } + while (matchEnd < text.length && WHITESPACE.test(text[matchEnd])) { + matchEnd += 1; + } + + parts.push(text.slice(copiedUntil, matchStart), ' '); + copiedUntil = matchEnd; + searchFrom = matchEnd; + } + + parts.push(text.slice(copiedUntil)); + return parts.join('').trim(); } /** The classification LangChain encoded in the docs URL it appended, when the text carries one. */ export function parseLangChainErrorCode(message: unknown): string | undefined { - return toMessageText(message).match(ERROR_CODE_URL)?.[1]?.toUpperCase(); + const text = toMessageText(message); + const searchableText = text.toLowerCase(); + let searchFrom = 0; + + while (searchFrom < text.length) { + const langChainIndex = searchableText.indexOf(LANGCHAIN, searchFrom); + if (langChainIndex < 0) { + return undefined; + } + const tokenEnd = findTokenEnd(text, langChainIndex); + const errorCode = findErrorCode(text, searchableText, langChainIndex, tokenEnd); + if (errorCode != null) { + return text.slice(errorCode.start, errorCode.end).toUpperCase(); + } + searchFrom = tokenEnd + 1; + } + + return undefined; }