diff --git a/apps/web/app/api/calendar/user/route.ts b/apps/web/app/api/calendar/user/route.ts index 38d3131f..0a2f5f9a 100644 --- a/apps/web/app/api/calendar/user/route.ts +++ b/apps/web/app/api/calendar/user/route.ts @@ -38,7 +38,7 @@ export async function GET(request: NextRequest) { const validation = await validateCalendarFeedToken(token); if (!validation.valid) { - logger.warn({ reason: validation.reason }, "invalid calendar feed token"); + logger.debug({ reason: validation.reason }, "invalid calendar feed token"); // Use same error message for both cases to prevent enumeration return NextResponse.json( diff --git a/apps/web/app/api/chat/[runId]/stream/route.ts b/apps/web/app/api/chat/[runId]/stream/route.ts index 8165bf42..cf922e75 100644 --- a/apps/web/app/api/chat/[runId]/stream/route.ts +++ b/apps/web/app/api/chat/[runId]/stream/route.ts @@ -27,7 +27,7 @@ export async function GET(request: NextRequest, context: RouteContext<"/api/chat const session = await auth.api.getSession({ headers: request.headers }); userId = session?.user?.id ?? null; } catch (err) { - logger.debug({ err }, "auth session check failed, treating as anonymous"); + logger.warn({ err }, "auth session check failed, treating as anonymous"); } const rateLimitConfig = userId ? RATE_LIMIT_AUTHENTICATED.stream : RATE_LIMIT_ANONYMOUS.stream; diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 6aa72f47..e8ebb4b0 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -58,7 +58,7 @@ export async function POST(request: Request) { userId = session?.user?.id ?? null; } catch (err) { // Auth error - treat as anonymous, but log for debugging - logger.debug({ err }, "auth session check failed, treating as anonymous"); + logger.warn({ err }, "auth session check failed, treating as anonymous"); } // Apply rate limits based on auth status @@ -80,7 +80,7 @@ export async function POST(request: Request) { try { rawBody = await bodyPromise; } catch (err) { - logger.warn({ err }, "failed to read chat request body"); + logger.debug({ err }, "failed to read chat request body"); return NextResponse.json( { error: "Invalid request body" }, { status: 400, headers: { ...rateLimit.headers } }, @@ -98,7 +98,7 @@ export async function POST(request: Request) { try { body = JSON.parse(rawBody); } catch (err) { - logger.warn({ err }, "invalid JSON in chat request body"); + logger.debug({ err }, "invalid JSON in chat request body"); return NextResponse.json( { error: "Invalid JSON in request body" }, { status: 400, headers: { ...rateLimit.headers } }, @@ -124,7 +124,7 @@ export async function POST(request: Request) { const truncatedMessages = rawMessages.slice(-MAX_CONVERSATION_MESSAGES); const validatedMessages = await validateChatMessages(truncatedMessages); if (!validatedMessages.success) { - logger.warn({ err: validatedMessages.error }, "chat history failed tool validation"); + logger.debug({ err: validatedMessages.error }, "chat history failed tool validation"); return NextResponse.json( { error: "Validation failed", diff --git a/apps/web/app/api/cron/sync-blocklist/route.ts b/apps/web/app/api/cron/sync-blocklist/route.ts index 5b31b48f..583c26b3 100644 --- a/apps/web/app/api/cron/sync-blocklist/route.ts +++ b/apps/web/app/api/cron/sync-blocklist/route.ts @@ -115,7 +115,7 @@ export async function GET(request: Request) { if (result.status === "fulfilled") { allDomains.push(...result.value); } else { - logger.error({ err: result.reason, sourceUrl: sources[i] }, "Error fetching blocklist"); + logger.warn({ err: result.reason, sourceUrl: sources[i] }, "Error fetching blocklist"); } } diff --git a/apps/web/app/api/cron/warm-cache/route.ts b/apps/web/app/api/cron/warm-cache/route.ts index a82e58cf..2c4eafa7 100644 --- a/apps/web/app/api/cron/warm-cache/route.ts +++ b/apps/web/app/api/cron/warm-cache/route.ts @@ -58,30 +58,34 @@ const sectionFetchers: Record Promise> = { /** * Check if a section is stale for a given domain. */ -async function isSectionStale(domain: string, section: Section): Promise { +async function isSectionStale( + domain: string, + section: Section, +): Promise<{ stale: boolean; failed: boolean }> { try { const result = await sectionCacheGetters[section](domain); - return result.stale || result.data === null; + return { stale: result.stale || result.data === null, failed: false }; } catch (err) { logger.error({ domain, section, err }, "failed to check staleness, assuming stale"); - return true; + return { stale: true, failed: true }; } } /** * Get all stale sections for a domain. */ -async function getStaleSections(domain: string): Promise { +async function getStaleSections(domain: string): Promise<{ sections: Section[]; failed: boolean }> { const staleChecks = await Promise.all( ALL_SECTIONS.map(async (section) => ({ section, - stale: await isSectionStale(domain, section), + result: await isSectionStale(domain, section), })), ); - return staleChecks - .filter((c): c is { section: Section; stale: true } => c.stale) - .map((c) => c.section); + return { + sections: staleChecks.filter(({ result }) => result.stale).map(({ section }) => section), + failed: staleChecks.some(({ result }) => result.failed), + }; } /** @@ -119,14 +123,12 @@ export async function GET(request: Request) { // Check staleness for all domains in parallel await Promise.all( recentDomains.map(async (domain) => { - try { - const staleSections = await getStaleSections(domain); - for (const section of staleSections) { - jobs.push({ domain, section }); - } - } catch (err) { + const { sections: staleSections, failed } = await getStaleSections(domain); + if (failed) { domainsFailed++; - logger.error({ domain, err }, "Failed to check staleness for domain"); + } + for (const section of staleSections) { + jobs.push({ domain, section }); } }), ); @@ -154,7 +156,7 @@ export async function GET(request: Request) { sectionsStarted++; } catch (err) { // Log but don't fail the cron - other sections may succeed - logger.error({ domain, section, err }, "Failed to refresh section"); + logger.debug({ domain, section, err }, "Section unavailable during cache warm"); } }), ); diff --git a/apps/web/app/api/og/route.tsx b/apps/web/app/api/og/route.tsx index 51b7cd28..4127bbd2 100644 --- a/apps/web/app/api/og/route.tsx +++ b/apps/web/app/api/og/route.tsx @@ -118,7 +118,7 @@ async function fetchProviderData(domain: string): Promise { return { providers }; } catch (err) { - logger.warn({ err, domain }, "failed to fetch provider data"); + logger.debug({ err, domain }, "provider data unavailable for OG image"); } return { providers: [] }; diff --git a/apps/web/app/api/screenshot/route.ts b/apps/web/app/api/screenshot/route.ts index 7739b095..bd2c9cdb 100644 --- a/apps/web/app/api/screenshot/route.ts +++ b/apps/web/app/api/screenshot/route.ts @@ -64,7 +64,7 @@ export async function POST( ]); if (!domain) { - logger.warn({ domainId }, "screenshot requested for unknown domain"); + logger.debug({ domainId }, "screenshot requested for unknown domain"); return NextResponse.json({ error: "Domain not found" }, { status: 404 }); } @@ -87,7 +87,7 @@ export async function POST( } catch (err) { // Log error but fall back to unblocked to avoid breaking screenshots // for transient database issues. Blocked domains are a soft protection. - logger.warn( + logger.error( { err, domain: domain.name }, "failed to check block status, defaulting to unblocked", ); @@ -189,7 +189,7 @@ export async function GET( // Still running return NextResponse.json({ status: "running" }, { headers: rateLimit.headers }); } catch (err) { - logger.warn({ err, runId }, "failed to get workflow run status"); + logger.debug({ err, runId }, "workflow run unavailable"); return NextResponse.json({ error: "Run not found" }, { status: 404 }); } } diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts index ee0524aa..d611af43 100644 --- a/apps/web/app/api/trpc/[trpc]/route.ts +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -11,12 +11,6 @@ const handler = async (req: Request) => { req, router: appRouter, createContext: () => ctx, - onError: ({ path, error }) => { - void (async () => { - const { logger } = await import("@domainstack/logger"); - logger.error({ err: error, source: "trpc", path }); - })(); - }, }); }; diff --git a/apps/web/lib/geoip.ts b/apps/web/lib/geoip.ts index 7c0e437e..e2a13411 100644 --- a/apps/web/lib/geoip.ts +++ b/apps/web/lib/geoip.ts @@ -79,25 +79,14 @@ interface IplocateApiResponse { * Fetch raw GeoIP data from iplocate.io API. * Returns the raw response for caching. */ -async function fetchFromApi(ip: string): Promise { - const apiKey = process.env.IPLOCATE_API_KEY; - - if (!apiKey) { - logger.warn("IPLOCATE_API_KEY not configured, skipping IP lookup"); - throw new Error("IPLOCATE_API_KEY not configured"); - } - +async function fetchFromApi(ip: string, apiKey: string): Promise { const url = new URL(`https://www.iplocate.io/api/lookup/${encodeURIComponent(ip)}`); url.searchParams.set("apikey", apiKey); const res = await fetch(url.toString()); if (!res.ok) { - const body = await res.text().catch(() => ""); - logger.error( - { status: res.status, body: body.slice(0, 500) }, - "iplocate.io lookup failed with non-OK status", - ); + await res.body?.cancel(); throw new Error(`Upstream error looking up IP metadata: ${res.status}`); } @@ -105,7 +94,6 @@ async function fetchFromApi(ip: string): Promise { // Check for API error response if (data.error) { - logger.error({ error: data.error }, "iplocate.io returned error message"); throw new Error(`iplocate.io error: ${data.error}`); } @@ -133,8 +121,14 @@ async function getOrFetchApiResponse(ip: string): Promise { imageBuffer: result.imageBase64, }; } catch (err) { - logger.warn({ err, domain }, "screenshot capture failed, caching miss"); + logger.debug({ err, domain }, "screenshot unavailable, caching miss"); return { success: false }; } } diff --git a/packages/api/src/middleware/domain-access.ts b/packages/api/src/middleware/domain-access.ts index c84f16bb..0d46736d 100644 --- a/packages/api/src/middleware/domain-access.ts +++ b/packages/api/src/middleware/domain-access.ts @@ -40,7 +40,7 @@ export const withDomainAccessUpdate = t.middleware(async ({ input, next, getRawI try { const updated = await updateLastAccessed(domain); if (!updated) { - logger.error({ domain }, "failed to record domain access"); + logger.debug({ domain }, "domain access record not found"); } } catch (err: unknown) { logger.error({ err, domain }, "failed to record domain access"); diff --git a/packages/api/src/middleware/logging.test.ts b/packages/api/src/middleware/logging.test.ts new file mode 100644 index 00000000..6a09e80d --- /dev/null +++ b/packages/api/src/middleware/logging.test.ts @@ -0,0 +1,68 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const log = vi.hoisted(() => ({ + error: vi.fn<(...args: unknown[]) => void>(), + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), +})); + +vi.mock("@domainstack/logger", () => ({ + createLogger: () => log, +})); + +const { publicProcedure } = await import("../procedures"); +const { t } = await import("../trpc"); + +const router = t.router({ + expectedFailure: publicProcedure.query(() => { + throw new TRPCError({ code: "NOT_FOUND", message: "missing" }); + }), + unexpectedFailure: publicProcedure.query(() => { + throw new Error("broken"); + }), + success: publicProcedure.query(() => "ok"), +}); + +const caller = router.createCaller({ ip: null, req: undefined, session: null }); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("withLogging", () => { + it("logs successful procedures at info", async () => { + await expect(caller.success()).resolves.toBe("ok"); + + expect(log.info).toHaveBeenCalledWith( + expect.objectContaining({ outcome: "ok", path: "success" }), + "procedure completed", + ); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + }); + + it("logs expected caller errors at info", async () => { + await expect(caller.expectedFailure()).rejects.toThrow("missing"); + + expect(log.info).toHaveBeenCalledWith( + expect.objectContaining({ code: "NOT_FOUND", outcome: "error", path: "expectedFailure" }), + "procedure completed", + ); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + }); + + it("logs unexpected server errors at error", async () => { + await expect(caller.unexpectedFailure()).rejects.toThrow("broken"); + + expect(log.error).toHaveBeenCalledWith( + expect.objectContaining({ + code: "INTERNAL_SERVER_ERROR", + outcome: "error", + path: "unexpectedFailure", + }), + "procedure completed", + ); + }); +}); diff --git a/packages/api/src/middleware/logging.ts b/packages/api/src/middleware/logging.ts index b125ad11..d8ff2d79 100644 --- a/packages/api/src/middleware/logging.ts +++ b/packages/api/src/middleware/logging.ts @@ -4,6 +4,24 @@ import { t } from "../trpc"; const logger = createLogger({ source: "trpc" }); +const EXPECTED_ERROR_CODES = new Set([ + "PARSE_ERROR", + "BAD_REQUEST", + "UNAUTHORIZED", + "PAYMENT_REQUIRED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "UNPROCESSABLE_CONTENT", + "PRECONDITION_REQUIRED", + "TOO_MANY_REQUESTS", + "CLIENT_CLOSED_REQUEST", +]); + /** * One canonical log line per procedure: path, type, duration, outcome, * and posthogDistinctId when the caller is authenticated. @@ -25,8 +43,10 @@ export const withLogging = t.middleware(async ({ path, type, ctx, next }) => { if (result.ok) { logger.info(fields, "procedure completed"); + } else if (EXPECTED_ERROR_CODES.has(result.error.code)) { + logger.info({ ...fields, code: result.error.code, err: result.error }, "procedure completed"); } else { - logger.error({ ...fields, err: result.error }, "procedure completed"); + logger.error({ ...fields, code: result.error.code, err: result.error }, "procedure completed"); } return result; diff --git a/packages/auth/src/analytics.ts b/packages/auth/src/analytics.ts index ce414e1b..39fa909d 100644 --- a/packages/auth/src/analytics.ts +++ b/packages/auth/src/analytics.ts @@ -22,7 +22,7 @@ export const analytics = { properties, }), }).catch((err) => { - console.error("failed to track event", err); + console.debug("failed to track event", err); }), ); }, diff --git a/packages/blob/src/providers/vercel.ts b/packages/blob/src/providers/vercel.ts index abf00601..05f02b7c 100644 --- a/packages/blob/src/providers/vercel.ts +++ b/packages/blob/src/providers/vercel.ts @@ -11,22 +11,17 @@ const logger = createLogger({ source: "blob/vercel" }); */ export class VercelBlobProvider implements BlobProvider { async put(options: PutBlobOptions): Promise { - try { - const blob = await put(options.pathname, options.body, { - access: "public", - contentType: options.contentType, - cacheControlMaxAge: options.cacheControlMaxAge, - allowOverwrite: true, - }); + const blob = await put(options.pathname, options.body, { + access: "public", + contentType: options.contentType, + cacheControlMaxAge: options.cacheControlMaxAge, + allowOverwrite: true, + }); - return { - url: blob.url, - pathname: options.pathname, - }; - } catch (err) { - logger.error({ err, pathname: options.pathname }, "Failed to upload blob"); - throw err; - } + return { + url: blob.url, + pathname: options.pathname, + }; } async delete(urls: string[]): Promise { diff --git a/packages/blob/src/storage.ts b/packages/blob/src/storage.ts index 3532338d..e2fa3f56 100644 --- a/packages/blob/src/storage.ts +++ b/packages/blob/src/storage.ts @@ -77,11 +77,9 @@ async function uploadWithRetry( } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); - logger.warn({ err, pathname, attempt: attempt + 1, maxAttempts }); - if (attempt < maxAttempts - 1) { const delay = backoffDelayMs(attempt, UPLOAD_BACKOFF_BASE_MS, UPLOAD_BACKOFF_MAX_MS); - logger.warn({ err, pathname, retryDelay: delay }); + logger.warn({ err, pathname, attempt: attempt + 1, maxAttempts, retryDelay: delay }); await sleep(delay); } } diff --git a/packages/edge-config/src/index.ts b/packages/edge-config/src/index.ts index 32b57b6e..71e79f71 100644 --- a/packages/edge-config/src/index.ts +++ b/packages/edge-config/src/index.ts @@ -35,7 +35,7 @@ export const getDefaultSuggestions = cache(async (): Promise => { const suggestions = await edgeConfig.get("domain_suggestions"); return suggestions ?? []; } catch (err) { - logger.error(err, "failed to fetch domain suggestions"); + logger.warn(err, "failed to fetch domain suggestions"); return []; } }); @@ -72,7 +72,7 @@ export const getProviderCatalog = cache(async (): Promise { const sources = await get("screenshot_blocklist_sources"); return sources ?? []; } catch (err) { - logger.error(err, "failed to fetch screenshot blocklist sources"); + logger.warn(err, "failed to fetch screenshot blocklist sources"); return []; } } @@ -120,7 +120,7 @@ export async function getAiChatModel(): Promise { const model = await get("ai_chat_model"); return model ?? null; } catch (err) { - logger.error(err, "failed to fetch AI chat model"); + logger.warn(err, "failed to fetch AI chat model"); return null; } } diff --git a/packages/polar/src/analytics.ts b/packages/polar/src/analytics.ts index f688c701..06345a1c 100644 --- a/packages/polar/src/analytics.ts +++ b/packages/polar/src/analytics.ts @@ -22,7 +22,7 @@ export const analytics = { properties, }), }).catch((err) => { - console.error("failed to track event", err); + console.debug("failed to track event", err); }), ); }, diff --git a/packages/polar/src/products.ts b/packages/polar/src/products.ts index 22db557f..b4681e75 100644 --- a/packages/polar/src/products.ts +++ b/packages/polar/src/products.ts @@ -15,7 +15,7 @@ function getProductIds(): { monthlyId?: string; yearlyId?: string } { const monthlyId = process.env.NEXT_PUBLIC_POLAR_MONTHLY_PRODUCT_ID; const yearlyId = process.env.NEXT_PUBLIC_POLAR_YEARLY_PRODUCT_ID; - if ((!monthlyId || !yearlyId) && !missingProductIdsWarned) { + if ((!monthlyId || !yearlyId) && !missingProductIdsWarned && process.env.NODE_ENV !== "test") { missingProductIdsWarned = true; // `products.ts` is imported from client components (checkout UI). Do not // pull `@domainstack/logger` / pino-pretty into the browser bundle. diff --git a/packages/screenshot/src/browser.ts b/packages/screenshot/src/browser.ts index 5a0b7122..e16ccdca 100644 --- a/packages/screenshot/src/browser.ts +++ b/packages/screenshot/src/browser.ts @@ -113,7 +113,7 @@ export async function closeBrowser(): Promise { const browser = await browserPromise; await browser.close(); } catch (err) { - logger.error(err, "failed to close browser"); + logger.warn(err, "failed to close browser"); } finally { browserPromise = null; } diff --git a/packages/screenshot/src/capture.ts b/packages/screenshot/src/capture.ts index 19994b1f..6dcc9c24 100644 --- a/packages/screenshot/src/capture.ts +++ b/packages/screenshot/src/capture.ts @@ -1,10 +1,6 @@ -import { createLogger } from "@domainstack/logger"; - import { type Browser, getBrowser, type Page } from "./browser"; import { createPage } from "./page"; -const logger = createLogger({ source: "screenshot/capture" }); - const DEFAULT_VIEWPORT_WIDTH = 1200; const DEFAULT_VIEWPORT_HEIGHT = 630; @@ -80,9 +76,6 @@ export async function captureScreenshot( width: actualWidth, height: actualHeight, }; - } catch (err) { - logger.error(err, "screenshot capture failed"); - throw err; } finally { // Close page in background to avoid blocking void page?.close(); diff --git a/packages/screenshot/src/page.ts b/packages/screenshot/src/page.ts index 09f4462e..aea8a5e8 100644 --- a/packages/screenshot/src/page.ts +++ b/packages/screenshot/src/page.ts @@ -87,7 +87,6 @@ export async function createPage( if (page) { await page.close().catch(() => {}); } - logger.warn(err, "failed to create page"); throw err; } } diff --git a/packages/server/src/services/certificates.ts b/packages/server/src/services/certificates.ts index 86a45236..fa349bb3 100644 --- a/packages/server/src/services/certificates.ts +++ b/packages/server/src/services/certificates.ts @@ -15,6 +15,7 @@ import { detectCertificateAuthority, getProvidersFromCatalog } from "@domainstac import { fetchCertificateChain, type RawCertificate, type TlsFetchSuccess } from "../tls"; import { ttlForCertificates } from "../ttl"; +import { RemoteDataUnavailableError } from "./fetch-errors"; // ============================================================================ // Types @@ -91,7 +92,7 @@ async function fetchCertificateChainInternal(domain: string): Promise { // 1. Fetch from DoH providers (throws DnsProviderError on failure) - const fetchData = await fetchDnsRecords(domain); + let fetchData: DnsFetchData; + try { + fetchData = await fetchDnsRecords(domain); + } catch (err) { + if (err instanceof DnsProviderError) { + throw new RemoteDataUnavailableError("DNS data unavailable", { cause: err }); + } + throw err; + } // 2. Persist to database await persistDnsRecords(domain, fetchData); diff --git a/packages/server/src/services/favicon.ts b/packages/server/src/services/favicon.ts index fc8e28e4..badb6a03 100644 --- a/packages/server/src/services/favicon.ts +++ b/packages/server/src/services/favicon.ts @@ -12,7 +12,7 @@ import { safeFetch } from "@domainstack/safe-fetch"; import type { FaviconResponse } from "@domainstack/types"; import { ttlForFavicon } from "../ttl"; -import { isDefinitiveNotFoundError } from "./fetch-errors"; +import { isDefinitiveNotFoundError, RemoteDataUnavailableError } from "./fetch-errors"; // ============================================================================ // Types @@ -70,7 +70,7 @@ export async function fetchFavicon(domain: string): Promise { // If at least one source failed with a transient error (not 404/400), // throw so TanStack Query can retry instead of caching failure if (!fetchResult.allNotFound) { - throw new Error(`Favicon fetch failed for ${domain} (transient)`); + throw new RemoteDataUnavailableError(`Favicon unavailable for ${domain}`); } // Persist "no favicon found" as a cached state (all sources returned 404) diff --git a/packages/server/src/services/fetch-errors.ts b/packages/server/src/services/fetch-errors.ts index 454b9805..f137ce66 100644 --- a/packages/server/src/services/fetch-errors.ts +++ b/packages/server/src/services/fetch-errors.ts @@ -4,6 +4,15 @@ import { type SafeFetchErrorCode, } from "@domainstack/safe-fetch"; +/** + * A remote domain or provider could not supply data, but the application is + * otherwise healthy. Routers use this boundary to keep target-specific + * failures separate from persistence, parsing, and other internal failures. + */ +export class RemoteDataUnavailableError extends Error { + readonly name = "RemoteDataUnavailableError"; +} + /** * Failures that mean "this URL will never serve us an asset", as opposed to * "the attempt failed this time". Retrying any of these produces the same diff --git a/packages/server/src/services/headers.ts b/packages/server/src/services/headers.ts index 33d120dd..e03594c3 100644 --- a/packages/server/src/services/headers.ts +++ b/packages/server/src/services/headers.ts @@ -10,8 +10,14 @@ import { ensureDomainRecord } from "@domainstack/db/queries/domains"; import { replaceHeaders } from "@domainstack/db/queries/headers"; import type { HeadersResponse } from "@domainstack/types"; -import { fetchHttpHeaders, type HeadersError, type HeadersFetchData } from "../headers"; +import { + fetchHttpHeaders, + HeadersFetchError, + type HeadersError, + type HeadersFetchData, +} from "../headers"; import { ttlForHeaders } from "../ttl"; +import { RemoteDataUnavailableError } from "./fetch-errors"; export { getHttpStatusMessage } from "../headers"; @@ -39,7 +45,15 @@ export type HeadersResult = */ export async function fetchHeaders(domain: string): Promise { // 1. Fetch headers from domain (throws HeadersFetchError on transient failure) - const fetchResult = await fetchHttpHeaders(domain); + let fetchResult; + try { + fetchResult = await fetchHttpHeaders(domain); + } catch (err) { + if (err instanceof HeadersFetchError) { + throw new RemoteDataUnavailableError("HTTP headers unavailable", { cause: err }); + } + throw err; + } if (!fetchResult.success) { return { success: false, error: fetchResult.error }; diff --git a/packages/server/src/services/hosting.ts b/packages/server/src/services/hosting.ts index 946a872a..0b525f5b 100644 --- a/packages/server/src/services/hosting.ts +++ b/packages/server/src/services/hosting.ts @@ -161,7 +161,7 @@ async function lookupGeoIp(ip: string): Promise { const apiKey = process.env.IPLOCATE_API_KEY; if (!apiKey) { - logger.warn("IPLOCATE_API_KEY not configured, skipping IP lookup"); + logger.debug("IPLOCATE_API_KEY not configured, skipping IP lookup"); return null; } @@ -181,7 +181,7 @@ async function lookupGeoIp(ip: string): Promise { if (!res.ok) { const body = await res.text().catch(() => ""); - logger.error( + logger.warn( { status: res.status, body: body.slice(0, 500) }, "iplocate.io lookup failed with non-OK status", ); @@ -191,7 +191,7 @@ async function lookupGeoIp(ip: string): Promise { const data = (await res.json()) as IplocateApiResponse; if (data.error) { - logger.error({ error: data.error }, "iplocate.io returned error message"); + logger.warn({ error: data.error }, "iplocate.io returned error message"); return null; } @@ -204,7 +204,7 @@ async function lookupGeoIp(ip: string): Promise { return transformApiResponse(data); } catch (err) { - logger.error({ err }, "iplocate.io lookup failed"); + logger.warn({ err }, "iplocate.io lookup failed"); return null; } } diff --git a/packages/server/src/services/provider-logo.ts b/packages/server/src/services/provider-logo.ts index 23c743fe..54703250 100644 --- a/packages/server/src/services/provider-logo.ts +++ b/packages/server/src/services/provider-logo.ts @@ -10,7 +10,7 @@ import { safeFetch } from "@domainstack/safe-fetch"; import type { ProviderLogoResponse } from "@domainstack/types"; import { ttlForProviderIcon } from "../ttl"; -import { isDefinitiveNotFoundError } from "./fetch-errors"; +import { isDefinitiveNotFoundError, RemoteDataUnavailableError } from "./fetch-errors"; // ============================================================================ // Types @@ -70,7 +70,7 @@ export async function fetchProviderLogo( // If at least one source failed with a transient error (not 404/400), // throw so TanStack Query can retry instead of caching failure if (!fetchResult.allNotFound) { - throw new Error(`Provider logo fetch failed for ${providerDomain} (transient)`); + throw new RemoteDataUnavailableError(`Provider logo unavailable for ${providerDomain}`); } // Persist "no logo found" as a cached state (all sources returned 404) diff --git a/packages/server/src/services/registration.ts b/packages/server/src/services/registration.ts index 074d6d1d..de453e70 100644 --- a/packages/server/src/services/registration.ts +++ b/packages/server/src/services/registration.ts @@ -24,6 +24,7 @@ import { import { ttlForRegistration } from "../ttl"; import { lookupWhois as lookup } from "../whois"; +import { RemoteDataUnavailableError } from "./fetch-errors"; // ============================================================================ // Types @@ -85,7 +86,7 @@ async function lookupWhois(domain: string): Promise { if (!result.success) { // Transient errors throw - let TanStack Query retry if (result.error === "retry" || result.error === "timeout") { - throw new Error(`WHOIS lookup failed: ${result.error}`); + throw new RemoteDataUnavailableError(`WHOIS lookup failed: ${result.error}`); } // Permanent errors return as result return { success: false, error: result.error }; diff --git a/packages/server/src/services/seo.ts b/packages/server/src/services/seo.ts index f47a5997..dc62a515 100644 --- a/packages/server/src/services/seo.ts +++ b/packages/server/src/services/seo.ts @@ -21,6 +21,7 @@ import type { import { parseHtmlMeta, parseRobotsTxt, selectPreview } from "../seo"; import { isExpectedTlsError } from "../tls"; import { ttlForSeo } from "../ttl"; +import { RemoteDataUnavailableError } from "./fetch-errors"; // ============================================================================ // Types @@ -214,7 +215,7 @@ async function fetchHtml(domain: string): Promise { } // Transient failure - throw for TanStack Query to retry - throw new Error("HTML fetch failed", { cause: err }); + throw new RemoteDataUnavailableError("HTML data unavailable", { cause: err }); } }