refactor: update logging levels for various procedures

- Changed logging levels from `warn` to `debug` for non-critical issues in the calendar and chat API routes to reduce log noise.
- Updated error handling in the cron and screenshot routes to use `warn` for more significant issues while maintaining `debug` for less critical logs.
- Introduced a new `RemoteDataUnavailableError` class to better categorize remote data fetch failures across services, enhancing error handling consistency.
- Adjusted logging in the domain and provider routers to differentiate between expected and unexpected errors, improving clarity in logs.
This commit is contained in:
2026-09-10 11:22:20 -04:00
parent c849842788
commit c7887bf2db
37 changed files with 234 additions and 122 deletions
+1 -1
View File
@@ -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(
@@ -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;
+4 -4
View File
@@ -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",
@@ -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");
}
}
+18 -16
View File
@@ -58,30 +58,34 @@ const sectionFetchers: Record<Section, (domain: string) => Promise<unknown>> = {
/**
* Check if a section is stale for a given domain.
*/
async function isSectionStale(domain: string, section: Section): Promise<boolean> {
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<Section[]> {
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");
}
}),
);
+1 -1
View File
@@ -118,7 +118,7 @@ async function fetchProviderData(domain: string): Promise<ProviderData> {
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: [] };
+3 -3
View File
@@ -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 });
}
}
-6
View File
@@ -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 });
})();
},
});
};
+10 -16
View File
@@ -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<IplocateApiResponse> {
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<IplocateApiResponse> {
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<IplocateApiResponse> {
// 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<IplocateApiResponse |
}
// Cache miss or Redis unavailable - fetch from API
const apiKey = process.env.IPLOCATE_API_KEY;
if (!apiKey) {
logger.debug("IPLOCATE_API_KEY not configured, skipping IP lookup");
return null;
}
try {
const raw = await fetchFromApi(ip);
const raw = await fetchFromApi(ip, apiKey);
// Store raw response in Redis (fire-and-forget)
if (redis) {
@@ -145,7 +139,7 @@ async function getOrFetchApiResponse(ip: string): Promise<IplocateApiResponse |
return raw;
} catch (err) {
logger.error({ err }, "iplocate.io lookup failed");
logger.warn({ err }, "iplocate.io lookup failed");
return null;
}
}
+2 -2
View File
@@ -79,7 +79,7 @@ function createPricingProvider(
});
if (!res.ok) {
logger.error({ provider: name, status: res.status }, "upstream error");
logger.warn({ provider: name, status: res.status }, "upstream error");
throw new Error(`${name} API returned ${res.status}`);
}
@@ -160,7 +160,7 @@ const dynadotProvider = createPricingProvider(
// Check for API errors (Dynadot returns 200 OK with error payloads)
if (data?.code !== 200) {
logger.error(
logger.warn(
{
err: data?.error,
provider: "dynadot",
+1 -1
View File
@@ -181,7 +181,7 @@ export async function checkRateLimit(
};
} catch (err) {
// Redis error - fail open to prevent blocking requests
logger.warn({ err }, "rate limit check failed, allowing request");
logger.error({ err }, "rate limit check failed, allowing request");
return { success: true };
}
}
+21 -7
View File
@@ -8,6 +8,7 @@ import type { RateLimitConfig } from "@domainstack/redis/ratelimit";
import { fetchCertificates } from "@domainstack/server/services/certificates";
import { fetchDns } from "@domainstack/server/services/dns";
import { fetchFavicon } from "@domainstack/server/services/favicon";
import { RemoteDataUnavailableError } from "@domainstack/server/services/fetch-errors";
import { fetchHeaders, getHttpStatusMessage } from "@domainstack/server/services/headers";
import { fetchHosting } from "@domainstack/server/services/hosting";
import { fetchRegistration } from "@domainstack/server/services/registration";
@@ -36,6 +37,19 @@ const DomainInputSchema = z.object({ domain: z.string().min(1) }).transform(({ d
return { domain: registrable };
});
function logFlex(domain: string, section: string, err: unknown, optional = false): void {
const fields = { domain, section, err };
if (err instanceof RemoteDataUnavailableError) {
if (optional) {
logger.debug(fields, `${section} unavailable`);
} else {
logger.warn(fields, `${section} unavailable`);
}
return;
}
logger.error(fields, `${section} failed unexpectedly`);
}
export const domainRouter = createTRPCRouter({
/**
* Get registration data for a domain.
@@ -68,7 +82,7 @@ export const domainRouter = createTRPCRouter({
}
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "registration fetch failed");
logFlex(input.domain, "registration", err);
return {
success: false,
cached: false,
@@ -101,7 +115,7 @@ export const domainRouter = createTRPCRouter({
const result = await fetchDns(input.domain);
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "dns fetch failed");
logFlex(input.domain, "dns", err);
return {
success: false,
cached: false,
@@ -134,7 +148,7 @@ export const domainRouter = createTRPCRouter({
const result = await fetchHosting(input.domain);
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "hosting fetch failed");
logFlex(input.domain, "hosting", err);
return {
success: false,
cached: false,
@@ -175,7 +189,7 @@ export const domainRouter = createTRPCRouter({
}
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "certificates fetch failed");
logFlex(input.domain, "certificates", err);
return {
success: false,
cached: false,
@@ -223,7 +237,7 @@ export const domainRouter = createTRPCRouter({
}
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "headers fetch failed");
logFlex(input.domain, "headers", err);
return {
success: false,
cached: false,
@@ -264,7 +278,7 @@ export const domainRouter = createTRPCRouter({
}
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "seo fetch failed");
logFlex(input.domain, "seo", err);
return {
success: false,
cached: false,
@@ -295,7 +309,7 @@ export const domainRouter = createTRPCRouter({
const result = await fetchFavicon(input.domain);
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ domain: input.domain, err }, "favicon fetch failed");
logFlex(input.domain, "favicon", err, true);
return {
success: false,
cached: false,
+6 -1
View File
@@ -4,6 +4,7 @@ import { createTRPCRouter, rateLimit, publicProcedure } from "@/trpc/init";
import { getProviderLogo } from "@domainstack/db/queries/provider-logos";
import { getProviderById } from "@domainstack/db/queries/providers";
import { createLogger } from "@domainstack/logger";
import { RemoteDataUnavailableError } from "@domainstack/server/services/fetch-errors";
import { fetchProviderLogo } from "@domainstack/server/services/provider-logo";
const logger = createLogger({ source: "routers/provider" });
@@ -36,7 +37,11 @@ export const providerRouter = createTRPCRouter({
const result = await fetchProviderLogo(input.providerId, providerDomain);
return { success: true, cached: false, data: result.data };
} catch (err) {
logger.error({ providerId: input.providerId, err }, "provider logo fetch failed");
if (err instanceof RemoteDataUnavailableError) {
logger.debug({ providerId: input.providerId, err }, "provider logo unavailable");
} else {
logger.error({ providerId: input.providerId, err }, "provider logo failed unexpectedly");
}
return {
success: false,
cached: false,
+2 -3
View File
@@ -599,10 +599,10 @@ export const trackingRouter = createTRPCRouter({
);
if (error) {
logger.error({ err: error, trackedDomainId });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email",
cause: error,
});
}
@@ -614,12 +614,11 @@ export const trackingRouter = createTRPCRouter({
return { success: true };
} catch (error) {
logger.error({ err: error, trackedDomainId });
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send email",
cause: error,
});
}
}),
+3 -7
View File
@@ -19,7 +19,7 @@ import {
type DomainToolProcedure,
type DomainToolResult,
} from "@/lib/chat/domain-tools";
import { getTrpcErrorCode, isExpectedTrpcError } from "@/lib/trpc/errors";
import { isExpectedTrpcError } from "@/lib/trpc/errors";
export interface ToolContext {
ip: string | null;
@@ -48,17 +48,13 @@ async function domainLookupStep(procedure: DomainToolProcedure, domain: string,
}
return result.data;
} catch (err) {
const { createLogger } = await import("@domainstack/logger");
const logger = createLogger({ source: "chat/tools" });
// Domain lookups return `{ success: false }` instead of throwing.
// Throws here are tRPC validation/rate-limit errors, or unexpected bugs.
const trpcCode = getTrpcErrorCode(err);
if (isExpectedTrpcError(err)) {
logger.warn({ err, domain, procedure, code: trpcCode }, "tool step failed (expected)");
return { error: getDomainToolErrorMessage(err) };
}
logger.error({ err, domain, procedure }, "tool step failed (unexpected)");
throw new RetryableError(`domain tool ${procedure} failed`, { retryAfter: "5s" });
const reason = err instanceof Error ? err.message : String(err);
throw new RetryableError(`domain tool ${procedure} failed: ${reason}`, { retryAfter: "5s" });
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ async function captureScreenshot(domain: string): Promise<CaptureResult> {
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 };
}
}
+1 -1
View File
@@ -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");
@@ -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",
);
});
});
+21 -1
View File
@@ -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;
+1 -1
View File
@@ -22,7 +22,7 @@ export const analytics = {
properties,
}),
}).catch((err) => {
console.error("failed to track event", err);
console.debug("failed to track event", err);
}),
);
},
+10 -15
View File
@@ -11,22 +11,17 @@ const logger = createLogger({ source: "blob/vercel" });
*/
export class VercelBlobProvider implements BlobProvider {
async put(options: PutBlobOptions): Promise<PutBlobResult> {
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<DeleteBlobResult[]> {
+1 -3
View File
@@ -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);
}
}
+4 -4
View File
@@ -35,7 +35,7 @@ export const getDefaultSuggestions = cache(async (): Promise<string[]> => {
const suggestions = await edgeConfig.get<string[]>("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<ProviderCatalog | null
return result.data;
} catch (err) {
logger.error(err, "failed to fetch provider catalog");
logger.warn(err, "failed to fetch provider catalog");
return null;
}
});
@@ -96,7 +96,7 @@ export async function getBlocklistSources(): Promise<string[]> {
const sources = await get<string[]>("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<string | null> {
const model = await get<string>("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;
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ export const analytics = {
properties,
}),
}).catch((err) => {
console.error("failed to track event", err);
console.debug("failed to track event", err);
}),
);
},
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -113,7 +113,7 @@ export async function closeBrowser(): Promise<void> {
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;
}
-7
View File
@@ -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();
-1
View File
@@ -87,7 +87,6 @@ export async function createPage(
if (page) {
await page.close().catch(() => {});
}
logger.warn(err, "failed to create page");
throw err;
}
}
+2 -1
View File
@@ -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<FetchResul
if (!result.success) {
// Transient failures - throw for TanStack Query to retry
if (result.error === "fetch_error" || result.error === "timeout") {
throw new Error("Certificate fetch failed");
throw new RemoteDataUnavailableError("Certificate data unavailable");
}
// Permanent failures (dns_error, tls_error) - return error result
+11 -2
View File
@@ -10,7 +10,8 @@ import { replaceDns } from "@domainstack/db/queries/dns";
import { ensureDomainRecord } from "@domainstack/db/queries/domains";
import type { DnsRecordType, DnsRecordsResponse } from "@domainstack/types";
import { type DnsFetchData, fetchDnsRecords } from "../dns";
import { DnsProviderError, type DnsFetchData, fetchDnsRecords } from "../dns";
import { RemoteDataUnavailableError } from "./fetch-errors";
// ============================================================================
// Types
@@ -32,7 +33,15 @@ export type DnsResult = { success: true; data: DnsRecordsResponse };
*/
export async function fetchDns(domain: string): Promise<DnsResult> {
// 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);
+2 -2
View File
@@ -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<FaviconResult> {
// 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)
@@ -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
+16 -2
View File
@@ -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<HeadersResult> {
// 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 };
+4 -4
View File
@@ -161,7 +161,7 @@ async function lookupGeoIp(ip: string): Promise<GeoIpData | null> {
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<GeoIpData | null> {
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<GeoIpData | null> {
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<GeoIpData | null> {
return transformApiResponse(data);
} catch (err) {
logger.error({ err }, "iplocate.io lookup failed");
logger.warn({ err }, "iplocate.io lookup failed");
return null;
}
}
@@ -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)
+2 -1
View File
@@ -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<LookupResult> {
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 };
+2 -1
View File
@@ -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<HtmlFetchData> {
}
// Transient failure - throw for TanStack Query to retry
throw new Error("HTML fetch failed", { cause: err });
throw new RemoteDataUnavailableError("HTML data unavailable", { cause: err });
}
}