Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/lib/formatters.avatars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,57 @@ describe('bot avatar resolution', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('does not re-request a bot the API had no avatar for', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false } as Response);
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('gone[bot]')).resolves.toBeNull();
await expect(resolveBotAvatar('gone[bot]')).resolves.toBeNull();
await expect(resolveBotAvatar('gone[bot]')).resolves.toBeNull();

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('does not re-request a bot whose lookup threw', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('offline'));
vi.stubGlobal('fetch', fetchMock);
const { resolveBotAvatar } = await freshFormatters();

await expect(resolveBotAvatar('flaky[bot]')).resolves.toBeNull();
await expect(resolveBotAvatar('flaky[bot]')).resolves.toBeNull();

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('skips known-unresolvable bots on later preload passes', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false } as Response);
vi.stubGlobal('fetch', fetchMock);
const { preloadBotAvatars } = await freshFormatters();

await preloadBotAvatars(['a[bot]', 'b[bot]']);
expect(fetchMock).toHaveBeenCalledTimes(2);

await preloadBotAvatars(['a[bot]', 'b[bot]']);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('keeps failures out of storage so a reload retries them', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false } as Response));
const first = await freshFormatters();
await first.resolveBotAvatar('later[bot]');
expect(storage.getItem(AVATAR_STORAGE_KEY)).toBeNull();

const fetchMock = vi.fn().mockResolvedValue(okResponse('https://example.test/late.png'));
vi.stubGlobal('fetch', fetchMock);
const second = await freshFormatters();

await expect(second.resolveBotAvatar('later[bot]')).resolves.toBe(
'https://example.test/late.png',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('queues requests beyond the concurrency cap but still resolves them all', async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse('https://example.test/c.png'));
vi.stubGlobal('fetch', fetchMock);
Expand Down
18 changes: 16 additions & 2 deletions src/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,12 @@ const HARDCODED_BOTS = new Set(botAvatarCache.keys());
/** In-flight fetch promises to avoid duplicate API calls */
const pendingFetches = new Map<string, Promise<string | null>>();

// Usernames the API had no avatar for. Kept in memory only, never persisted, so
// a reload retries a bot that has since been created or renamed. Without this
// every failed lookup repeats on each dataset change and exhausts the 60/hr
// unauthenticated rate limit, after which no avatars resolve at all.
const unresolvedAvatars = new Set<string>();

/** Simple throttle: max concurrent GitHub API requests */
const MAX_CONCURRENT_FETCHES = 3;
let activeFetchCount = 0;
Expand Down Expand Up @@ -407,6 +413,7 @@ function throttledFetch(url: string): Promise<Response> {
*/
export async function resolveBotAvatar(username: string): Promise<string | null> {
if (botAvatarCache.has(username)) return botAvatarCache.get(username)!;
if (unresolvedAvatars.has(username)) return null;
if (!isBot(username)) return null;

// Deduplicate in-flight requests
Expand All @@ -423,10 +430,15 @@ export async function resolveBotAvatar(username: string): Promise<string | null>
if (url) {
botAvatarCache.set(username, url);
persistAvatarCache();
} else {
unresolvedAvatars.add(username);
}
return url;
})
.catch(() => null)
.catch(() => {
unresolvedAvatars.add(username);
return null;
})
.finally(() => pendingFetches.delete(username));

pendingFetches.set(username, promise);
Expand All @@ -439,7 +451,9 @@ export async function resolveBotAvatar(username: string): Promise<string | null>
* Caps at 10 API lookups per batch to avoid rate limits.
*/
export async function preloadBotAvatars(usernames: string[]): Promise<boolean> {
const bots = usernames.filter((u) => isBot(u) && !botAvatarCache.has(u));
const bots = usernames.filter(
(u) => isBot(u) && !botAvatarCache.has(u) && !unresolvedAvatars.has(u),
);
if (bots.length === 0) return false;
// Cap lookups to avoid hammering the API with many unknown bots
const batch = bots.slice(0, 10);
Expand Down