From 17298da7eece5d78da2c650427aae4e30d2b3fe6 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:16:20 -0400 Subject: [PATCH] fix: capture DataForSEO errors with context (#138) --- .../rank-tracking/RankTrackingConfigModal.tsx | 10 ++++- src/middleware/errorHandling.ts | 1 + src/server/lib/dataforseo.ts | 39 ++++++++++++++----- src/server/lib/errors.ts | 1 + 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx index 87b93b9..8fd4fac 100644 --- a/src/client/features/rank-tracking/RankTrackingConfigModal.tsx +++ b/src/client/features/rank-tracking/RankTrackingConfigModal.tsx @@ -116,6 +116,14 @@ export function RankTrackingConfigModal({ } }; + const handleDomainBlur = () => { + try { + setDomain(normalizeDomain(domain)); + } catch { + // Keep invalid partial input editable; submit validation will show the error. + } + }; + const isPending = createMutation.isPending || updateMutation.isPending; if (step === "keywords" && createdConfigId) { @@ -156,7 +164,7 @@ export function RankTrackingConfigModal({ className="input input-bordered w-full" value={domain} onChange={(e) => setDomain(e.target.value)} - onBlur={() => setDomain(normalizeDomain(domain))} + onBlur={handleDomainBlur} /> diff --git a/src/middleware/errorHandling.ts b/src/middleware/errorHandling.ts index 189d29e..74a3d9c 100644 --- a/src/middleware/errorHandling.ts +++ b/src/middleware/errorHandling.ts @@ -29,6 +29,7 @@ export const errorHandlingMiddleware = createMiddleware({ errorCode: appError?.code ?? "INTERNAL_ERROR", method: request.method, path: url.pathname, + ...appError?.details, }), ); } diff --git a/src/server/lib/dataforseo.ts b/src/server/lib/dataforseo.ts index 9e1c85a..32e8e39 100644 --- a/src/server/lib/dataforseo.ts +++ b/src/server/lib/dataforseo.ts @@ -40,7 +40,7 @@ export type { // --------------------------------------------------------------------------- function createAuthenticatedFetch() { - return (url: RequestInfo, init?: RequestInit): Promise => { + return async (url: RequestInfo, init?: RequestInit): Promise => { const headers = new Headers(init?.headers); headers.set("Authorization", `Basic ${env.DATAFORSEO_API_KEY}`); @@ -48,7 +48,26 @@ function createAuthenticatedFetch() { ...init, headers, }; - return fetch(url, newInit); + const response = await fetch(url, newInit); + + if (!response.ok) { + const rawText = await response.text(); + const path = formatDataforseoRequestPath(url); + const err = new AppError( + response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR", + `DataForSEO HTTP ${response.status} on ${path}`, + { + provider: "dataforseo", + providerStatus: String(response.status), + providerPath: path, + responseBody: formatDataforseoErrorPayload(rawText), + }, + ); + err.name = "DataForSEOHttpError"; + throw err; + } + + return response; }; } @@ -59,6 +78,15 @@ function getLabsApi() { return new DataforseoLabsApi(API_BASE, { fetch: createAuthenticatedFetch() }); } +function formatDataforseoRequestPath(url: RequestInfo): string { + const rawUrl = typeof url === "string" ? url : url.url; + try { + return new URL(rawUrl).pathname; + } catch { + return rawUrl; + } +} + async function postDataforseo( path: string, payload: unknown, @@ -74,13 +102,6 @@ async function postDataforseo( const rawText = await response.text(); - if (!response.ok) { - throw new AppError( - "INTERNAL_ERROR", - `DataForSEO HTTP ${response.status} on ${path}. Response: ${formatDataforseoErrorPayload(rawText)}`, - ); - } - try { return JSON.parse(rawText); } catch { diff --git a/src/server/lib/errors.ts b/src/server/lib/errors.ts index e6e4b0c..27f142b 100644 --- a/src/server/lib/errors.ts +++ b/src/server/lib/errors.ts @@ -4,6 +4,7 @@ export class AppError extends Error { constructor( public readonly code: ErrorCode, message?: string, + public readonly details?: Record, ) { super(message ?? code); this.name = "AppError";