Files
buzz/desktop/src/shared/lib/useResolvedLinkPreviews.ts
Taylor Ho 201aee4a11 feat(link-preview): retry a transient blip inline before the self-heal wait
Give a blanked link one immediate second chance so a momentary failure
recovers right away instead of waiting out the full self-heal window.

- A transient fetch failure (timeout/5xx/network) earns one inline retry after
  a short backoff before falling into the 30s self-heal wait. If that retry
  succeeds the card shows immediately.
- A rate limit (429) skips the inline retry (an immediate retry would just be
  throttled again) and waits out its Retry-After instead, so we do not re-trip
  the limit. The whole sequence stays under one coalesced promise inside the
  2-concurrent scheduler.
- Move the rate-limit / transient-status classification into the rate_limit
  submodule where retry_after_duration already lives, keeping link_preview.rs
  within the file-size ratchet.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-10 15:01:58 -07:00

486 lines
14 KiB
TypeScript

import * as React from "react";
import { invokeTauri } from "@/shared/api/tauri";
import { relayClient } from "@/shared/api/relayClient";
import {
KIND_GIT_ISSUE,
KIND_GIT_PULL_REQUEST,
} from "@/shared/constants/kinds";
import { parseEntityLink } from "./entityLink";
import {
buzzEntityFallbackTitle,
type SupportedLinkPreview,
} from "./linkPreview";
type LinkPreviewImageFetchState =
| "none"
| "image"
| "transient_failure"
| "rejected";
export type LinkPreviewMetadata = {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: LinkPreviewImageFetchState;
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
};
type MetadataCacheEntry = {
expiresAt: number | null;
metadata: LinkPreviewMetadata | null;
/** A transient fetch failure (timeout/429/5xx/network) rather than a genuine
* "no metadata" result; retried soon instead of cached for the full miss TTL. */
transient?: boolean;
};
type MetadataLoadResult = MetadataCacheEntry & {
key: string;
};
const DEFAULT_TRANSIENT_RETRY_MS = 30_000;
const NULL_METADATA_RETRY_MS = 5 * 60_000;
const MAX_CONCURRENT_METADATA_FETCHES = 2;
/** Backoff before the single inline retry that a transient blip earns before
* it falls into the longer {@link DEFAULT_TRANSIENT_RETRY_MS} self-heal wait. */
const INLINE_RETRY_BACKOFF_MS = 750;
/** Prefix the native fetcher uses to mark a 429. A rate limit must NOT get the
* fast inline retry (we would just be throttled again); it waits instead. Kept
* in sync with RATE_LIMITED_ERROR_PREFIX in link_preview.rs. */
const RATE_LIMITED_ERROR_PREFIX = "link preview rate limited";
type TransientFailure = {
/** A 429 rate limit: skip the inline retry and wait it out. */
rateLimited: boolean;
/** Server-supplied Retry-After, in ms, when present on a rate limit. */
retryAfterMs?: number;
};
/** Classify a rejected native fetch. Any rejection is transient (a genuine
* "no metadata" result resolves, it does not reject); we only distinguish a
* rate limit — which must not be retried immediately — from an ordinary blip. */
function classifyTransientFailure(reason: unknown): TransientFailure {
const message = reason instanceof Error ? reason.message : String(reason);
if (!message.startsWith(RATE_LIMITED_ERROR_PREFIX)) {
return { rateLimited: false };
}
const match = /retry-after (\d+)/.exec(message);
const retryAfterSeconds = match ? Number.parseInt(match[1], 10) : NaN;
return {
rateLimited: true,
retryAfterMs: Number.isFinite(retryAfterSeconds)
? retryAfterSeconds * 1_000
: undefined,
};
}
/**
* React may flush an interaction-triggered effect before the browser paints.
* Start uncached preview I/O after a frame plus a task boundary so the pasted
* text and loading card are visible before native IPC work begins.
*/
function scheduleAfterPaint(task: () => void): () => void {
let frameId: number | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const run = () => {
timeoutId = setTimeout(task, 0);
};
if (typeof requestAnimationFrame === "function") {
frameId = requestAnimationFrame(run);
} else {
run();
}
return () => {
if (frameId !== null) cancelAnimationFrame(frameId);
if (timeoutId !== null) clearTimeout(timeoutId);
};
}
function metadataCacheKey(href: string): string {
try {
const url = new URL(href);
url.hash = "";
return url.href;
} catch {
return href.split("#", 1)[0] ?? href;
}
}
function metadataExpiry(
metadata: LinkPreviewMetadata | null,
now: number,
transient = false,
transientRetryMs = DEFAULT_TRANSIENT_RETRY_MS,
): number | null {
// A transient failure (timeout/429/5xx/network) must not stick for the full
// miss TTL: one unlucky fetch would otherwise blank the card for 5 minutes
// even though the link is perfectly valid. Retry it soon instead — after the
// server's Retry-After when it gave us one, otherwise the default window.
if (transient) return now + transientRetryMs;
if (metadata === null) return now + NULL_METADATA_RETRY_MS;
if (metadata.imageFetchState !== "transient_failure") return null;
const retryAfterMs =
typeof metadata.imageRetryAfterMs === "number" &&
Number.isFinite(metadata.imageRetryAfterMs)
? Math.max(1_000, metadata.imageRetryAfterMs)
: DEFAULT_TRANSIENT_RETRY_MS;
return now + retryAfterMs;
}
function createTaskScheduler(concurrency: number) {
const pending: Array<() => void> = [];
let active = 0;
const drain = () => {
while (active < concurrency) {
const run = pending.shift();
if (!run) return;
active += 1;
run();
}
};
return <T>(task: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
pending.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
active -= 1;
drain();
});
});
drain();
});
}
function createMetadataLoader({
concurrency = MAX_CONCURRENT_METADATA_FETCHES,
delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms)),
fetcher,
now = Date.now,
}: {
concurrency?: number;
delay?: (ms: number) => Promise<void>;
fetcher: (href: string) => Promise<LinkPreviewMetadata | null>;
now?: () => number;
}) {
const cache = new Map<
string,
MetadataCacheEntry | Promise<MetadataLoadResult>
>();
const schedule = createTaskScheduler(Math.max(1, concurrency));
let generation = 0;
const peek = (href: string): MetadataLoadResult | undefined => {
const key = metadataCacheKey(href);
const cached = cache.get(key);
if (!cached || cached instanceof Promise) return undefined;
if (cached.expiresAt !== null && cached.expiresAt <= now()) {
cache.delete(key);
return undefined;
}
return { key, ...cached };
};
const load = (href: string): Promise<MetadataLoadResult> => {
const key = metadataCacheKey(href);
const cached = cache.get(key);
if (cached instanceof Promise) return cached;
if (cached) {
if (cached.expiresAt === null || cached.expiresAt > now()) {
return Promise.resolve({ key, ...cached });
}
cache.delete(key);
}
const requestGeneration = generation;
// One coalesced attempt sequence. A transient blip earns a single inline
// retry after a short backoff before we give up and fall into the longer
// self-heal wait; a rate limit (429) skips that retry — an immediate retry
// would just be throttled again — and waits out its Retry-After instead.
const attempt = (): Promise<{
metadata: LinkPreviewMetadata | null;
transient: boolean;
transientRetryMs: number;
}> =>
fetcher(href).then(
(metadata) => ({
metadata,
transient: false,
transientRetryMs: DEFAULT_TRANSIENT_RETRY_MS,
}),
(reason) => {
const failure = classifyTransientFailure(reason);
if (failure.rateLimited) {
return {
metadata: null,
transient: true,
transientRetryMs:
failure.retryAfterMs ?? DEFAULT_TRANSIENT_RETRY_MS,
};
}
return delay(INLINE_RETRY_BACKOFF_MS)
.then(() => fetcher(href))
.then(
(metadata) => ({
metadata,
transient: false,
transientRetryMs: DEFAULT_TRANSIENT_RETRY_MS,
}),
() => ({
metadata: null,
transient: true,
transientRetryMs: DEFAULT_TRANSIENT_RETRY_MS,
}),
);
},
);
const promise = schedule(attempt).then(
({ metadata, transient, transientRetryMs }) => {
const entry = {
expiresAt: metadataExpiry(
metadata,
now(),
transient,
transientRetryMs,
),
metadata,
transient,
};
if (requestGeneration === generation) {
cache.set(key, entry);
}
return { key, ...entry };
},
);
cache.set(key, promise);
return promise;
};
return {
deleteKey(key: string) {
cache.delete(key);
},
load,
peek,
reset() {
generation += 1;
cache.clear();
},
};
}
function fetchLinkPreviewMetadata(
href: string,
): Promise<LinkPreviewMetadata | null> {
return invokeTauri<LinkPreviewMetadata | null>(
"fetch_link_preview_metadata",
{
href,
},
);
}
const metadataLoader = createMetadataLoader({
fetcher: fetchLinkPreviewMetadata,
});
const entityTitleLoader = createMetadataLoader({
fetcher: async (href) => {
const parsed = parseEntityLink(href);
if (!parsed.ok || parsed.value.type === "repo") return null;
const { id, owner, dtag } = parsed.value;
const expectedCoordinate = `30617:${owner}:${dtag}`;
const events = await relayClient.fetchEvents({
kinds: [
parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE,
],
ids: [id],
limit: 1,
});
const event = events[0];
if (
!event?.tags.some(
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
)
) {
return null;
}
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
const title = subject || event.content.split("\n")[0] || null;
return title
? {
title,
siteName: "Buzz",
description: null,
imageDataUrl: null,
imageDomain: null,
}
: null;
},
});
/** Clear ephemeral metadata when the active relay/community changes. */
export function resetLinkPreviewMetadataCache(): void {
metadataLoader.reset();
entityTitleLoader.reset();
}
export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none";
export type ResolvedLinkPreview = SupportedLinkPreview & {
description?: string | null;
faviconDataUrl?: string | null;
imageState: LinkPreviewImageState;
/** Metadata extraction completed successfully; safe to snapshot after media uploads. */
snapshotReady?: boolean;
};
type ResolvedMetadataByHref = Record<
string,
LinkPreviewMetadata | null | undefined
>;
/** Only auto-generated titles may be replaced; explicit markdown labels win. */
export function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
if (preview.kind !== "buzz-pull-request" && preview.kind !== "buzz-issue") {
return true;
}
const parsed = parseEntityLink(preview.href);
return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value);
}
export function resolveLinkPreview(
preview: SupportedLinkPreview,
metadata: LinkPreviewMetadata | null | undefined,
): ResolvedLinkPreview {
if (metadata === undefined) {
return { ...preview, imageState: "pending" };
}
if (metadata === null) {
return { ...preview, imageState: "none" };
}
const hasImage = Boolean(metadata.imageDataUrl && metadata.imageDomain);
const imageState: LinkPreviewImageState = hasImage
? "image"
: metadata.imageFetchState === "image" ||
metadata.imageFetchState === "transient_failure" ||
metadata.imageFetchState === "rejected"
? "fallback"
: "none";
return {
...preview,
snapshotReady: !preview.href.startsWith("buzz://"),
title: shouldResolveTitle(preview) ? metadata.title : preview.title,
description: metadata.description,
faviconDataUrl: metadata.faviconDataUrl,
provider:
preview.kind === "generic-link" && metadata.siteName
? metadata.siteName
: preview.provider,
imageDataUrl: hasImage ? metadata.imageDataUrl : null,
imageDomain: hasImage ? metadata.imageDomain : null,
imageState,
};
}
export function useResolvedLinkPreviews(
previews: SupportedLinkPreview[],
): ResolvedLinkPreview[] {
const [resolvedMetadata, setResolvedMetadata] =
React.useState<ResolvedMetadataByHref>({});
const [retryGeneration, setRetryGeneration] = React.useState(0);
React.useEffect(() => {
let cancelled = false;
let retryAt = Number.POSITIVE_INFINITY;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRetry = (
{ expiresAt, key }: Pick<MetadataLoadResult, "expiresAt" | "key">,
loader: typeof metadataLoader,
) => {
if (expiresAt === null || expiresAt >= retryAt) return;
retryAt = expiresAt;
if (retryTimer !== null) clearTimeout(retryTimer);
retryTimer = setTimeout(
() => {
loader.deleteKey(key);
setResolvedMetadata((current) => {
if (!(key in current)) return current;
const next = { ...current };
delete next[key];
return next;
});
setRetryGeneration(retryGeneration + 1);
},
Math.max(0, expiresAt - Date.now()),
);
};
const cancelScheduledLoads: Array<() => void> = [];
for (const preview of previews) {
const loader = preview.href.startsWith("buzz://")
? entityTitleLoader
: metadataLoader;
const cached = loader.peek(preview.href);
if (cached !== undefined) {
setResolvedMetadata((current) =>
current[cached.key] === cached.metadata
? current
: { ...current, [cached.key]: cached.metadata },
);
scheduleRetry(cached, loader);
continue;
}
cancelScheduledLoads.push(
scheduleAfterPaint(() => {
void loader.load(preview.href).then((result) => {
if (cancelled) return;
setResolvedMetadata((current) =>
current[result.key] === result.metadata
? current
: { ...current, [result.key]: result.metadata },
);
scheduleRetry(result, loader);
});
}),
);
}
return () => {
cancelled = true;
for (const cancel of cancelScheduledLoads) cancel();
if (retryTimer !== null) clearTimeout(retryTimer);
};
}, [previews, retryGeneration]);
return React.useMemo(
() =>
previews.flatMap((preview) => {
const metadata = resolvedMetadata[metadataCacheKey(preview.href)];
return metadata === null ? [] : [resolveLinkPreview(preview, metadata)];
}),
[previews, resolvedMetadata],
);
}
export const __linkPreviewMetadataTest = {
createMetadataLoader,
createTaskScheduler,
metadataCacheKey,
metadataExpiry,
};