From e39de22f9610a7bb07b4f03a8e38e53ce7677482 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:54:14 -0400 Subject: [PATCH] Fix: don't mask DataForSEO 40501 validation errors as empty results (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: don't mask DataForSEO 40501 validation errors as empty results isNoResultsTask classified any task with status_code 40501 as a successful empty result, but 40501 is not unique to "No Search Results" — DataForSEO also returns it for validation rejections like "Invalid Field: 'target'.". After the Business Listings and Q&A endpoints opted into treatNoResultsAsEmpty, invalid-field 40501 responses (reachable via the MCP search_local_businesses categories input, which accepts arbitrary strings) were silently masked as empty successes — billed and tracked, but hiding the real provider validation error. Match on the status message ("no search results") instead of the ambiguous code so charged validation failures flow through to DataforseoChargedTaskError with the diagnostic message, while genuine no-results responses still return as empty successes. Fixes all call sites uniformly (Business, SERP live, SERP task_get polling). * Format isNoResultsTask per prettier --- src/server/lib/dataforseo/envelope.test.ts | 25 ++++++++++++++++++++++ src/server/lib/dataforseo/envelope.ts | 10 ++++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/server/lib/dataforseo/envelope.test.ts b/src/server/lib/dataforseo/envelope.test.ts index 1f994eb..b95998a 100644 --- a/src/server/lib/dataforseo/envelope.test.ts +++ b/src/server/lib/dataforseo/envelope.test.ts @@ -148,4 +148,29 @@ describe("assertOk", () => { ), ).toBe(task); }); + + it("still surfaces a charged 40501 'Invalid Field' failure even with treatNoResultsAsEmpty", () => { + // 40501 is not unique to no-results — it also covers validation rejections, + // which are real charged failures we must not mask as empty results. + const task = { + status_code: 40501, + status_message: "Invalid Field: 'categories'.", + path: ["v3", "business_data", "business_listings", "search", "live"], + cost: 0.02, + result_count: 0, + data: { categories: ["not_a_real_category"] }, + }; + try { + assertOk( + { status_code: 20000, tasks: [task] }, + { treatNoResultsAsEmpty: true }, + ); + throw new Error("expected assertOk to throw"); + } catch (error) { + expect(error).toBeInstanceOf(DataforseoChargedTaskError); + if (error instanceof DataforseoChargedTaskError) { + expect(error.billing).toEqual({ path: task.path, costUsd: 0.02 }); + } + } + }); }); diff --git a/src/server/lib/dataforseo/envelope.ts b/src/server/lib/dataforseo/envelope.ts index 88e1de9..ae61344 100644 --- a/src/server/lib/dataforseo/envelope.ts +++ b/src/server/lib/dataforseo/envelope.ts @@ -102,11 +102,15 @@ function describeInvalidField( return `${message} (sent ${field}=${JSON.stringify(value)})`; } -/** DataForSEO's "No Search Results" (40501) — a successful empty result, not a failure. */ +/** + * DataForSEO's "No Search Results" (40501) — a successful empty result, not a + * failure. Match on the status message, not the code alone: 40501 also covers + * validation rejections like "Invalid Field: 'target'.", which are real charged + * failures we must surface rather than mask as empty results. + */ export function isNoResultsTask(task: DataforseoTaskLike): boolean { return ( - task.status_code === 40501 || - (task.status_message?.toLowerCase().includes("no search results") ?? false) + task.status_message?.toLowerCase().includes("no search results") ?? false ); }