|
11 | 11 | * 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. |
12 | 12 | * 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. |
13 | 13 | * |
| 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 | + * |
14 | 16 | * 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). |
15 | 17 | */ |
16 | 18 |
|
@@ -98,8 +100,37 @@ function fingerprint(spec: OpenAPISpec): string { |
98 | 100 | /** Bypass edge cache so the diff baseline always compares against the freshest origin spec, not a stale CDN node. */ |
99 | 101 | const SPEC_FETCH: RequestInit = { headers: { 'Cache-Control': 'no-cache' } }; |
100 | 102 |
|
| 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 | + |
101 | 132 | async function fetchJson<T>(url: string): Promise<T> { |
102 | | - const res = await fetch(url, SPEC_FETCH); |
| 133 | + const res = await fetchSpec(url); |
103 | 134 | if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); |
104 | 135 | return (await res.json()) as T; |
105 | 136 | } |
@@ -141,10 +172,7 @@ async function discoverDomains(): Promise< |
141 | 172 | } |
142 | 173 | const probed = await Promise.all( |
143 | 174 | [...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`); |
148 | 176 | if (!res.ok) return null; |
149 | 177 | return { slug, spec: (await res.json()) as OpenAPISpec }; |
150 | 178 | }), |
|
0 commit comments