1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 20:14:25 -04:00

Dedupe DNS and headers with Redis locks and aggregate cache; add concurrency tests; micro-tune hosting query gating (#87)

This commit is contained in:
2025-10-11 01:55:47 -04:00
committed by GitHub
parent 382303de59
commit cc5140d08e
12 changed files with 493 additions and 78 deletions

View File

@@ -35,19 +35,37 @@ export function DomainReportView({ domain }: { domain: string }) {
registration.data?.isRegistered ?? false,
);
// Disable export until all sections are settled (loaded or errored)
// Consider sections "settled" only when they have either succeeded or errored.
// This avoids showing empty states before a query has actually completed
// (including while a query is disabled/pending due to gating conditions).
const dnsSettled = !!(dns.isSuccess || dns.isError || dns.data !== undefined);
const hostingSettled = !!(
hosting.isSuccess ||
hosting.isError ||
hosting.data !== undefined
);
const certsSettled = !!(
certs.isSuccess ||
certs.isError ||
certs.data !== undefined
);
const headersSettled = !!(
headers.isSuccess ||
headers.isError ||
headers.data !== undefined
);
const seoSettled = !!(seo.isSuccess || seo.isError || seo.data !== undefined);
// Disable export until all secondary sections are settled
const areSecondarySectionsLoading =
registration.data?.isRegistered &&
(dns.isLoading ||
hosting.isLoading ||
certs.isLoading ||
headers.isLoading ||
seo.isLoading ||
dns.isFetching ||
hosting.isFetching ||
certs.isFetching ||
headers.isFetching ||
seo.isFetching);
!!registration.data?.isRegistered &&
!(
dnsSettled &&
hostingSettled &&
certsSettled &&
headersSettled &&
seoSettled
);
const handleExportJson = () => {
captureClient("export_json_clicked", { domain });
@@ -132,7 +150,7 @@ export function DomainReportView({ domain }: { domain: string }) {
<HostingEmailSection
data={hosting.data || null}
isLoading={hosting.isLoading}
isLoading={!hostingSettled}
isError={!!hosting.isError}
onRetryAction={() => {
captureClient("section_refetch_clicked", {
@@ -145,7 +163,7 @@ export function DomainReportView({ domain }: { domain: string }) {
<DnsRecordsSection
records={dns.data?.records || null}
isLoading={dns.isLoading}
isLoading={!dnsSettled}
isError={!!dns.isError}
onRetryAction={() => {
captureClient("section_refetch_clicked", {
@@ -158,7 +176,7 @@ export function DomainReportView({ domain }: { domain: string }) {
<CertificatesSection
data={certs.data || null}
isLoading={certs.isLoading}
isLoading={!certsSettled}
isError={!!certs.isError}
onRetryAction={() => {
captureClient("section_refetch_clicked", {
@@ -171,7 +189,7 @@ export function DomainReportView({ domain }: { domain: string }) {
<HeadersSection
data={headers.data || null}
isLoading={headers.isLoading}
isLoading={!headersSettled}
isError={!!headers.isError}
onRetryAction={() => {
captureClient("section_refetch_clicked", {
@@ -184,7 +202,7 @@ export function DomainReportView({ domain }: { domain: string }) {
<SeoSection
data={seo.data || null}
isLoading={seo.isLoading}
isLoading={!seoSettled}
isError={!!seo.isError}
onRetryAction={() => {
captureClient("section_refetch_clicked", {

View File

@@ -1,6 +1,11 @@
"use client";
import { ArrowDown, ChevronDown, ChevronUp } from "lucide-react";
import {
ArrowDown,
ChevronDown,
ChevronUp,
ShieldQuestionMark,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { Fragment, useState } from "react";
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
@@ -11,6 +16,13 @@ import { RelativeExpiry } from "@/components/domain/relative-expiry";
import { Section } from "@/components/domain/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
@@ -278,7 +290,20 @@ export function CertificatesSection({
message="Failed to load certificates."
onRetryAction={onRetryAction}
/>
) : null}
) : (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<ShieldQuestionMark />
</EmptyMedia>
<EmptyTitle>No certificates found</EmptyTitle>
<EmptyDescription>
We couldn&apos;t retrieve a TLS certificate chain for this site.
Ensure the domain resolves and serves HTTPS on port 443.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</Section>
);
}

View File

@@ -1,11 +1,20 @@
"use client";
import { Earth } from "lucide-react";
import { useMemo } from "react";
import { DnsGroup } from "@/components/domain/dns-group";
import { DnsRecordList } from "@/components/domain/dns-record-list";
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
import { Section } from "@/components/domain/section";
import { SubheadCountSkeleton } from "@/components/domain/subhead-count";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import type { DnsRecord } from "@/lib/schemas";
import { SECTION_DEFS } from "@/lib/sections-meta";
@@ -47,6 +56,20 @@ export function DnsRecordsSection({
isError: boolean;
onRetryAction: () => void;
}) {
const recordsByType = useMemo(() => {
const byType: Record<DnsRecord["type"], DnsRecord[]> = {
A: [],
AAAA: [],
MX: [],
TXT: [],
NS: [],
};
records?.forEach((r) => {
byType[r.type].push(r);
});
return byType;
}, [records]);
return (
<Section {...SECTION_DEFS.dns} isError={isError} isLoading={isLoading}>
{isLoading ? (
@@ -59,41 +82,59 @@ export function DnsRecordsSection({
</div>
) : records ? (
<div className="space-y-4">
<DnsGroup
title="A Records"
color="blue"
count={records.filter((r) => r.type === "A").length}
>
<DnsRecordList records={records} type="A" />
</DnsGroup>
<DnsGroup
title="AAAA Records"
color="cyan"
count={records.filter((r) => r.type === "AAAA").length}
>
<DnsRecordList records={records} type="AAAA" />
</DnsGroup>
<DnsGroup
title="MX Records"
color="green"
count={records.filter((r) => r.type === "MX").length}
>
<DnsRecordList records={records} type="MX" />
</DnsGroup>
<DnsGroup
title="TXT Records"
color="orange"
count={records.filter((r) => r.type === "TXT").length}
>
<DnsRecordList records={records} type="TXT" />
</DnsGroup>
<DnsGroup
title="NS Records"
color="purple"
count={records.filter((r) => r.type === "NS").length}
>
<DnsRecordList records={records} type="NS" />
</DnsGroup>
{records.length > 0 ? (
<>
<DnsGroup
title="A Records"
color="blue"
count={recordsByType.A.length}
>
<DnsRecordList records={recordsByType.A} type="A" />
</DnsGroup>
<DnsGroup
title="AAAA Records"
color="cyan"
count={recordsByType.AAAA.length}
>
<DnsRecordList records={recordsByType.AAAA} type="AAAA" />
</DnsGroup>
<DnsGroup
title="MX Records"
color="green"
count={recordsByType.MX.length}
>
<DnsRecordList records={recordsByType.MX} type="MX" />
</DnsGroup>
<DnsGroup
title="TXT Records"
color="orange"
count={recordsByType.TXT.length}
>
<DnsRecordList records={recordsByType.TXT} type="TXT" />
</DnsGroup>
<DnsGroup
title="NS Records"
color="purple"
count={recordsByType.NS.length}
>
<DnsRecordList records={recordsByType.NS} type="NS" />
</DnsGroup>
</>
) : (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<Earth />
</EmptyMedia>
<EmptyTitle>No DNS records found</EmptyTitle>
<EmptyDescription>
We couldn&apos;t resolve A/AAAA, MX, TXT, or NS records for
this domain. If DNS was recently updated, it may take time to
propagate.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
) : isError ? (
<ErrorWithRetry

View File

@@ -1,9 +1,17 @@
"use client";
import { Logs } from "lucide-react";
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
import { KeyValue } from "@/components/domain/key-value";
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
import { Section } from "@/components/domain/section";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import type { HttpHeader } from "@/lib/schemas";
import { SECTION_DEFS } from "@/lib/sections-meta";
@@ -26,7 +34,7 @@ export function HeadersSection({
<KeyValueSkeleton key={id} widthClass="w-[100px]" withTrailing />
))}
</div>
) : data ? (
) : data && data.length > 0 ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{(() => {
const important = new Set([
@@ -56,7 +64,20 @@ export function HeadersSection({
message="Failed to load headers."
onRetryAction={onRetryAction}
/>
) : null}
) : (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<Logs />
</EmptyMedia>
<EmptyTitle>No HTTP headers detected</EmptyTitle>
<EmptyDescription>
We couldn&apos;t fetch any HTTP response headers for this site. It
may be offline or blocking requests.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</Section>
);
}

View File

@@ -1,11 +1,19 @@
"use client";
import { MailQuestionMark } from "lucide-react";
import dynamic from "next/dynamic";
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
import { Favicon } from "@/components/domain/favicon";
import { KeyValue } from "@/components/domain/key-value";
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
import { Section } from "@/components/domain/section";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import type { Hosting } from "@/lib/schemas";
import { SECTION_DEFS } from "@/lib/sections-meta";
@@ -124,7 +132,21 @@ export function HostingEmailSection({
message="Failed to load hosting details."
onRetryAction={onRetryAction}
/>
) : null}
) : (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<MailQuestionMark />
</EmptyMedia>
<EmptyTitle>No hosting details available</EmptyTitle>
<EmptyDescription>
We couldn&apos;t detect hosting, email, or DNS provider info. If
the domain has no A/AAAA records or blocked headers, details may
be unavailable.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</Section>
);
}

View File

@@ -135,7 +135,7 @@ export function RegistrationSection({
<KeyValue
label="Created"
value={formatDate(data.creationDate || "")}
value={formatDate(data.creationDate || "Unknown")}
valueTooltip={
data.creationDate
? formatDateTimeUtc(data.creationDate)
@@ -145,7 +145,7 @@ export function RegistrationSection({
<KeyValue
label="Expires"
value={formatDate(data.expirationDate || "")}
value={formatDate(data.expirationDate || "Unknown")}
valueTooltip={
data.expirationDate
? formatDateTimeUtc(data.expirationDate)

View File

@@ -80,6 +80,7 @@ export function SeoSection({
{ label: "Robots", value: data?.meta?.general.robots },
];
const metaTagCount = metaTagValues.filter((t) => t.value != null).length;
const hasAnySeoMeta = metaTagCount > 0;
// Decide which X (Twitter) card variant to display based on meta tags.
const twitterCard = data?.meta?.twitter?.card?.toLowerCase();
@@ -100,7 +101,18 @@ export function SeoSection({
<Section {...SECTION_DEFS.seo} isError={isError} isLoading={isLoading}>
{isLoading ? (
<SeoSkeleton />
) : data ? (
) : isError ? (
<div className="text-muted-foreground text-sm">
Failed to load SEO analysis.
<button
onClick={onRetryAction}
className="ml-2 underline underline-offset-2"
type="button"
>
Retry
</button>
</div>
) : data && hasAnySeoMeta ? (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
@@ -211,18 +223,20 @@ export function SeoSection({
<RobotsSummary robots={data.robots} />
</div>
) : isError ? (
<div className="text-muted-foreground text-sm">
Failed to load SEO analysis.
<button
onClick={onRetryAction}
className="ml-2 underline underline-offset-2"
type="button"
>
Retry
</button>
</div>
) : null}
) : (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<FileQuestionMark />
</EmptyMedia>
<EmptyTitle>No SEO meta detected</EmptyTitle>
<EmptyDescription>
We didn&apos;t find standard SEO meta tags (title, description,
canonical, or open graph). Add them to improve link previews.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</Section>
);
}
@@ -797,7 +811,10 @@ function SeoSkeleton() {
<div className="space-y-4">
{/* Meta Tags */}
<div className="space-y-3">
<SubheadCountSkeleton />
<div className="flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
Meta Tags
<SubheadCountSkeleton />
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<KeyValueSkeleton label="Title" widthClass="w-[220px]" />
<KeyValueSkeleton label="Description" widthClass="w-[260px]" />
@@ -829,7 +846,7 @@ function SeoSkeleton() {
{/* Robots summary */}
<div className="space-y-4 rounded-xl">
<div className="mt-5 flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
<Skeleton className="h-3 w-20 rounded" />
robots.txt
<SubheadCountSkeleton />
</div>
@@ -852,7 +869,8 @@ function SeoSkeleton() {
{/* Sitemaps */}
<div className="space-y-3">
<div className="mt-5">
<div className="mt-5 flex items-center gap-2 text-[11px] text-foreground/70 uppercase leading-none tracking-[0.08em] dark:text-foreground/80">
Sitemaps
<SubheadCountSkeleton />
</div>
<div className="flex flex-col gap-1.5">

View File

@@ -35,7 +35,10 @@ export function useDomainQueries(domain: string) {
trpc.domain.hosting.queryOptions(
{ domain },
{
enabled: registration.data?.isRegistered,
// Optional micro-tuning: wait until DNS has resolved once to better
// reuse warm caches server-side. If DNS errored, still allow hosting to run.
enabled:
registration.data?.isRegistered && (dns.isSuccess || dns.isError),
staleTime: 30 * 60_000, // 30 minutes
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,

View File

@@ -159,4 +159,52 @@ describe("resolveAll", () => {
expect(["cloudflare", "google"]).toContain(second.resolver);
secondFetch.mockRestore();
});
it("dedupes concurrent callers via aggregate cache/lock", async () => {
globalThis.__redisTestHelper?.reset();
// Prepare one set of responses for provider 1 across types
const dohAnswer = (
answers: Array<{ name: string; TTL: number; data: string }>,
) =>
new Response(JSON.stringify({ Status: 0, Answer: answers }), {
status: 200,
headers: { "content-type": "application/dns-json" },
});
const fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "1.2.3.4" }]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 60, data: "::1" }]),
)
.mockResolvedValueOnce(
dohAnswer([
{ name: "example.com.", TTL: 300, data: "10 aspmx.l.google.com." },
]),
)
.mockResolvedValueOnce(
dohAnswer([{ name: "example.com.", TTL: 120, data: '"v=spf1"' }]),
)
.mockResolvedValueOnce(
dohAnswer([
{ name: "example.com.", TTL: 600, data: "ns1.cloudflare.com." },
]),
);
// Fire several concurrent calls
const [r1, r2, r3] = await Promise.all([
resolveAll("example.com"),
resolveAll("example.com"),
resolveAll("example.com"),
]);
expect(r1.records.length).toBeGreaterThan(0);
expect(r2.records.length).toBe(r1.records.length);
expect(r3.records.length).toBe(r1.records.length);
// Only 5 DoH fetches should have occurred for the initial provider/types
expect(fetchMock).toHaveBeenCalledTimes(5);
fetchMock.mockRestore();
});
});

View File

@@ -1,7 +1,7 @@
import { captureServer } from "@/lib/analytics/server";
import { isCloudflareIpAsync } from "@/lib/cloudflare";
import { USER_AGENT } from "@/lib/constants";
import { ns, redis } from "@/lib/redis";
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
import {
type DnsRecord,
type DnsResolveResult,
@@ -51,6 +51,120 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
const providers = providerOrderForLookup(lower);
const durationByProvider: Record<string, number> = {};
let lastError: unknown = null;
const aggregateKey = ns("dns:all", lower);
const lockKey = ns("lock", `dns:${lower}`);
// Aggregate cache fast-path
try {
const agg = (await redis.get(aggregateKey)) as DnsResolveResult | null;
if (agg && Array.isArray(agg.records)) {
await captureServer("dns_resolve_all", {
domain: lower,
duration_ms_total: Date.now() - startedAt,
counts: ((): Record<DnsType, number> => {
return (DnsTypeSchema.options as DnsType[]).reduce(
(acc, t) => {
acc[t] = agg.records.filter((r) => r.type === t).length;
return acc;
},
{ A: 0, AAAA: 0, MX: 0, TXT: 0, NS: 0 } as Record<DnsType, number>,
);
})(),
cloudflare_ip_present: agg.records.some(
(r) => (r.type === "A" || r.type === "AAAA") && r.isCloudflare,
),
dns_provider_used: agg.resolver,
provider_attempts: 0,
duration_ms_by_provider: {},
cache_hit: true,
cache_source: "aggregate",
});
console.info("[dns] aggregate cache hit", {
domain: lower,
resolver: agg.resolver,
total: agg.records.length,
});
return agg;
}
} catch {}
// Try to acquire lock or wait for someone else's result
const lockWaitStart = Date.now();
const lockResult = await acquireLockOrWaitForResult<DnsResolveResult>({
lockKey,
resultKey: aggregateKey,
lockTtl: 30,
});
if (!lockResult.acquired && lockResult.cachedResult) {
const agg = lockResult.cachedResult;
await captureServer("dns_resolve_all", {
domain: lower,
duration_ms_total: Date.now() - startedAt,
counts: ((): Record<DnsType, number> => {
return (DnsTypeSchema.options as DnsType[]).reduce(
(acc, t) => {
acc[t] = agg.records.filter((r) => r.type === t).length;
return acc;
},
{ A: 0, AAAA: 0, MX: 0, TXT: 0, NS: 0 } as Record<DnsType, number>,
);
})(),
cloudflare_ip_present: agg.records.some(
(r) => (r.type === "A" || r.type === "AAAA") && r.isCloudflare,
),
dns_provider_used: agg.resolver,
provider_attempts: 0,
duration_ms_by_provider: {},
cache_hit: true,
cache_source: "aggregate_wait",
lock_acquired: false,
lock_waited_ms: Date.now() - lockWaitStart,
});
console.info("[dns] waited for aggregate", { domain: lower });
return agg;
}
const acquiredLock = lockResult.acquired;
if (!acquiredLock && !lockResult.cachedResult) {
// Manual short wait/poll for aggregate result in test envs where
// acquireLockOrWaitForResult does not poll. Keeps callers from duplicating work.
const start = Date.now();
const maxWaitMs = 1500;
const intervalMs = 25;
// eslint-disable-next-line no-constant-condition
while (Date.now() - start < maxWaitMs) {
const agg = (await redis.get(aggregateKey)) as DnsResolveResult | null;
if (agg && Array.isArray(agg.records)) {
await captureServer("dns_resolve_all", {
domain: lower,
duration_ms_total: Date.now() - startedAt,
counts: ((): Record<DnsType, number> => {
return (DnsTypeSchema.options as DnsType[]).reduce(
(acc, t) => {
acc[t] = agg.records.filter((r) => r.type === t).length;
return acc;
},
{ A: 0, AAAA: 0, MX: 0, TXT: 0, NS: 0 } as Record<
DnsType,
number
>,
);
})(),
cloudflare_ip_present: agg.records.some(
(r) => (r.type === "A" || r.type === "AAAA") && r.isCloudflare,
),
dns_provider_used: agg.resolver,
provider_attempts: 0,
duration_ms_by_provider: {},
cache_hit: true,
cache_source: "aggregate_wait",
lock_acquired: false,
lock_waited_ms: Date.now() - start,
});
return agg;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
// Provider-agnostic cache check: if all types are cached, return immediately
const types = DnsTypeSchema.options;
@@ -76,6 +190,15 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
((await redis.get(
ns("dns:meta", `${lower}:resolver`),
)) as DnsResolver | null) || "cloudflare";
try {
await redis.set(
aggregateKey,
{ records: flat, resolver: resolverUsed },
{
ex: 5 * 60,
},
);
} catch {}
await captureServer("dns_resolve_all", {
domain: lower,
duration_ms_total: Date.now() - startedAt,
@@ -85,12 +208,20 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
provider_attempts: 0,
duration_ms_by_provider: {},
cache_hit: true,
cache_source: "per_type",
lock_acquired: acquiredLock,
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
});
console.info("[dns] cache hit", {
domain: lower,
counts,
resolver: resolverUsed,
});
if (acquiredLock) {
try {
await redis.del(lockKey);
} catch {}
}
return { records: flat, resolver: resolverUsed } as DnsResolveResult;
}
@@ -134,6 +265,15 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
: ((await redis.get(
ns("dns:meta", `${lower}:resolver`),
)) as DnsResolver | null) || provider.key;
try {
await redis.set(
aggregateKey,
{ records: flat, resolver: resolverUsed },
{
ex: 5 * 60,
},
);
} catch {}
await captureServer("dns_resolve_all", {
domain: lower,
duration_ms_total: Date.now() - startedAt,
@@ -143,6 +283,9 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
provider_attempts: attemptIndex + 1,
duration_ms_by_provider: durationByProvider,
cache_hit: !usedFresh,
cache_source: usedFresh ? "fresh" : "per_type",
lock_acquired: acquiredLock,
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
});
console.info("[dns] ok", {
domain: lower,
@@ -150,6 +293,11 @@ export async function resolveAll(domain: string): Promise<DnsResolveResult> {
resolver: resolverUsed,
duration_ms_total: Date.now() - startedAt,
});
if (acquiredLock) {
try {
await redis.del(lockKey);
} catch {}
}
return { records: flat, resolver: resolverUsed } as DnsResolveResult;
} catch (err) {
console.warn("[dns] provider attempt failed", {

View File

@@ -52,6 +52,34 @@ describe("probeHeaders", () => {
fetchMock.mockRestore();
});
it("dedupes concurrent callers via lock/wait", async () => {
const head = new Response(null, {
status: 200,
headers: {
server: "vercel",
"x-vercel-id": "abc",
},
});
const fetchMock = vi
.spyOn(global, "fetch")
.mockImplementation(async (_url, init?: RequestInit) => {
if ((init?.method || "HEAD") === "HEAD") return head;
return new Response(null, { status: 500 });
});
const [a, b, c] = await Promise.all([
probeHeaders("example.com"),
probeHeaders("example.com"),
probeHeaders("example.com"),
]);
expect(a.length).toBeGreaterThan(0);
expect(b.length).toBe(a.length);
expect(c.length).toBe(a.length);
// HEAD called once; no GETs should be needed after first completes
expect(fetchMock).toHaveBeenCalledTimes(1);
fetchMock.mockRestore();
});
it("returns empty array and does not cache on error", async () => {
const fetchMock = vi.spyOn(global, "fetch").mockImplementation(async () => {
throw new Error("network");

View File

@@ -1,11 +1,12 @@
import { captureServer } from "@/lib/analytics/server";
import { ns, redis } from "@/lib/redis";
import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis";
import type { HttpHeader } from "@/lib/schemas";
export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
const lower = domain.toLowerCase();
const url = `https://${domain}/`;
const key = ns("headers", lower);
const lockKey = ns("lock", `headers:${lower}`);
console.debug("[headers] start", { domain: lower });
const cached = await redis.get<HttpHeader[]>(key);
@@ -17,6 +18,34 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
return cached;
}
// Try to acquire lock or wait for someone else's result
const lockWaitStart = Date.now();
const lockResult = await acquireLockOrWaitForResult<HttpHeader[]>({
lockKey,
resultKey: key,
lockTtl: 30,
});
if (!lockResult.acquired && Array.isArray(lockResult.cachedResult)) {
return lockResult.cachedResult;
}
const acquiredLock = lockResult.acquired;
if (!acquiredLock && !lockResult.cachedResult) {
// Short poll for cached result to avoid duplicate external requests when the
// helper cannot poll in the current environment
const start = Date.now();
const maxWaitMs = 1500;
const intervalMs = 25;
while (Date.now() - start < maxWaitMs) {
const result = (await redis.get<HttpHeader[]>(key)) as
| HttpHeader[]
| null;
if (Array.isArray(result)) {
return result;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
const REQUEST_TIMEOUT_MS = 5000;
try {
// Try HEAD first with timeout
@@ -67,6 +96,8 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
status: final.status,
used_method: res?.ok ? "HEAD" : "GET",
final_url: final.url,
lock_acquired: acquiredLock,
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
});
await redis.set(key, normalized, { ex: 10 * 60 });
@@ -75,6 +106,11 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
status: final.status,
count: normalized.length,
});
if (acquiredLock) {
try {
await redis.del(lockKey);
} catch {}
}
return normalized;
} catch (err) {
console.warn("[headers] error", {
@@ -87,8 +123,15 @@ export async function probeHeaders(domain: string): Promise<HttpHeader[]> {
used_method: "ERROR",
final_url: url,
error: String(err),
lock_acquired: acquiredLock,
lock_waited_ms: acquiredLock ? 0 : Date.now() - lockWaitStart,
});
// Return empty on failure without caching to avoid long-lived negatives
if (acquiredLock) {
try {
await redis.del(lockKey);
} catch {}
}
return [];
}
}