Skip to content

Commit 2a30dc7

Browse files
committed
fix(sync): retry a transient spec fetch instead of failing the run
Every spec request now retries with exponential backoff, up to five attempts, on a network error or a status that describes a temporary origin or edge condition. A brief outage delays a run rather than ending it. The domain probe uses the same path, so a blip while probing can no longer be read as "not a product domain" and silently drop a live domain from the catalog. Statuses outside the transient set still return on the first attempt, so the 401 and 404 that genuinely mean "not a domain" cost one request each.
1 parent 30b90b6 commit 2a30dc7

1 file changed

Lines changed: 33 additions & 5 deletions

File tree

scripts/sync.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
* 4. Publish: resolve the existing collection UID (cache, then workspace name match), then PUT it; create + capture the UID when it does not exist yet.
1212
* 5. Persist: write `specs/{slug}.json` (next run's diff baseline), `collections/{slug}.json` (browsable artifact), and the auto-written `collections.json` UID cache. The workflow commits these back.
1313
*
14+
* Every spec request retries a transient origin or edge failure with exponential backoff, so a brief outage delays a run rather than failing it or dropping a live domain from the catalog.
15+
*
1416
* Flags: `--dry-run` (discover, diff and build but make no Postman writes; no API key required, used by CI), `--force` (rebuild and publish every domain regardless of diff), `--prune` (delete collections for domains that no longer exist; off by default so a transient outage cannot wipe the workspace).
1517
*/
1618

@@ -98,8 +100,37 @@ function fingerprint(spec: OpenAPISpec): string {
98100
/** Bypass edge cache so the diff baseline always compares against the freshest origin spec, not a stale CDN node. */
99101
const SPEC_FETCH: RequestInit = { headers: { 'Cache-Control': 'no-cache' } };
100102

103+
/**
104+
* Statuses that describe a temporary origin or edge condition rather than an answer about the
105+
* resource. Everything outside this set is returned to the caller on the first attempt, so the 401
106+
* and 404 that legitimately mean "not a product domain" still cost exactly one request.
107+
*/
108+
const RETRYABLE_STATUS = new Set([
109+
408, 425, 429, 500, 502, 503, 504, 521, 522, 523, 524, 525, 526,
110+
]);
111+
112+
const MAX_ATTEMPTS = 5;
113+
114+
/** Fetch a spec, retrying a transient failure with exponential backoff before giving up. */
115+
async function fetchSpec(url: string): Promise<Response> {
116+
let last: Error = new Error(`GET ${url} -> no attempt made`);
117+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
118+
if (attempt > 1) await Bun.sleep(1000 * 2 ** (attempt - 2));
119+
try {
120+
const res = await fetch(url, SPEC_FETCH);
121+
if (!RETRYABLE_STATUS.has(res.status)) return res;
122+
last = new Error(`GET ${url} -> ${res.status}`);
123+
} catch (err) {
124+
last = err instanceof Error ? err : new Error(String(err));
125+
}
126+
if (attempt < MAX_ATTEMPTS)
127+
console.warn(`retrying (${attempt}/${MAX_ATTEMPTS}): ${last.message}`);
128+
}
129+
throw last;
130+
}
131+
101132
async function fetchJson<T>(url: string): Promise<T> {
102-
const res = await fetch(url, SPEC_FETCH);
133+
const res = await fetchSpec(url);
103134
if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
104135
return (await res.json()) as T;
105136
}
@@ -141,10 +172,7 @@ async function discoverDomains(): Promise<
141172
}
142173
const probed = await Promise.all(
143174
[...candidates].sort().map(async (slug) => {
144-
const res = await fetch(
145-
`${API_ORIGIN}/api/v2/${slug}/openapi.json`,
146-
SPEC_FETCH,
147-
);
175+
const res = await fetchSpec(`${API_ORIGIN}/api/v2/${slug}/openapi.json`);
148176
if (!res.ok) return null;
149177
return { slug, spec: (await res.json()) as OpenAPISpec };
150178
}),

0 commit comments

Comments
 (0)