mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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>
486 lines
14 KiB
TypeScript
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,
|
|
};
|