fix(catalog-sync): add retry logic and pagination for Scryfall API

Scryfall was returning 429/503 transiently, causing catalog sync to
fail immediately with no recovery. Adds exponential-backoff retry
(3 attempts) for rate-limit and service-unavailable responses in both
the set discovery and card import paths. Also adds proper pagination
support for sets with 175+ cards and URL-encodes set codes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-13 10:49:50 -05:00
parent cf9fea0726
commit 59e2ca4ce4
2 changed files with 83 additions and 7 deletions

View file

@ -75,12 +75,27 @@ export async function getKnownPokemonSetCodes() {
return new Set(rows.map((row) => row.set_code)); return new Set(rows.map((row) => row.set_code));
} }
const SCRYFALL_RETRY_DELAY_MS = 2000;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function discoverMissingMtgSets() { export async function discoverMissingMtgSets() {
const response = await fetch('https://api.scryfall.com/sets'); let response;
if (!response.ok) { for (let attempt = 1; attempt <= 3; attempt += 1) {
response = await fetch('https://api.scryfall.com/sets', {
headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
});
if (response.ok) break;
if (response.status === 429 || response.status === 503) {
await delay(SCRYFALL_RETRY_DELAY_MS * attempt);
continue;
}
throw new Error(`Scryfall sets API error: ${response.status}`); throw new Error(`Scryfall sets API error: ${response.status}`);
} }
if (!response.ok) {
throw new Error(`Scryfall sets API error after retries: ${response.status}`);
}
const data = await response.json(); const data = await response.json();
const knownCodes = await getKnownMtgSetCodes(); const knownCodes = await getKnownMtgSetCodes();
return filterMissingMtgSets(data.data || [], knownCodes); return filterMissingMtgSets(data.data || [], knownCodes);

View file

@ -1,17 +1,78 @@
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
const SCRYFALL_DELAY_MS = 100;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function fetchScryfallWithRetry(url, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
const response = await fetch(url, {
headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
});
if (response.ok) return response;
if (response.status === 429 || response.status === 503) {
const retryAfter = parseInt(response.headers.get('retry-after') || '2', 10);
const waitMs = Math.max(retryAfter * 1000, 1000 * attempt);
console.warn(`[fetchScryfallWithRetry] ${response.status} on attempt ${attempt}, waiting ${waitMs}ms`);
await delay(waitMs);
continue;
}
if (response.status === 404) {
return response;
}
throw new Error(`Scryfall API error: ${response.status} for ${url}`);
}
throw new Error(`Scryfall API: exhausted ${maxRetries} retries for ${url}`);
}
async function fetchAllScryfallPages(initialUrl) {
const allCards = [];
let url = initialUrl;
while (url) {
const response = await fetchScryfallWithRetry(url);
if (!response.ok) break;
const data = await response.json();
allCards.push(...(data.data || []));
if (data.has_more && data.next_page) {
url = data.next_page;
await delay(SCRYFALL_DELAY_MS);
} else {
url = null;
}
}
return allCards;
}
/** /**
* Import all cards for a Scryfall set code. Skips rows already present by scryfall_id. * Import all cards for a Scryfall set code. Skips rows already present by scryfall_id.
*/ */
export async function importMtgSet(setCode) { export async function importMtgSet(setCode) {
const response = await fetch(`https://api.scryfall.com/cards/search?q=set:${setCode}`); const initialUrl = `https://api.scryfall.com/cards/search?q=set:${encodeURIComponent(setCode)}`;
const firstResponse = await fetchScryfallWithRetry(initialUrl);
if (!response.ok) { if (firstResponse.status === 404) {
throw new Error(`Scryfall API error: ${response.status}`); return { setCode, imported: 0, skipped: 0, total: 0 };
} }
const data = await response.json(); if (!firstResponse.ok) {
const cards = data.data || []; throw new Error(`Scryfall API error: ${firstResponse.status} for set ${setCode}`);
}
const firstPage = await firstResponse.json();
let cards = firstPage.data || [];
if (firstPage.has_more && firstPage.next_page) {
await delay(SCRYFALL_DELAY_MS);
const remaining = await fetchAllScryfallPages(firstPage.next_page);
cards = cards.concat(remaining);
}
let imported = 0; let imported = 0;
let skipped = 0; let skipped = 0;