(tag, fn, log)
| 1175 | // so rate-limit headers (retry-after / x-ratelimit-*) are honored uniformly. |
| 1176 | // Throws on final failure so the caller can decide how to degrade. |
| 1177 | async function withRetry(tag, fn, log) { |
| 1178 | const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); |
| 1179 | let lastErr; |
| 1180 | for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { |
| 1181 | try { |
| 1182 | return await fn(); |
| 1183 | } catch (e) { |
| 1184 | lastErr = e; |
| 1185 | const retryInfo = computeRetryDelayMs(e, attempt); |
| 1186 | const willRetry = retryInfo != null && attempt < MAX_RETRIES; |
| 1187 | if (willRetry) { |
| 1188 | const secs = (retryInfo.delayMs / 1000).toFixed(1); |
| 1189 | log( |
| 1190 | `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + |
| 1191 | `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` |
| 1192 | ); |
| 1193 | await sleep(retryInfo.delayMs); |
| 1194 | } else { |
| 1195 | log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); |
| 1196 | throw e; |
| 1197 | } |
| 1198 | } |
| 1199 | } |
| 1200 | throw lastErr != null |
| 1201 | ? lastErr |
| 1202 | : new Error(`withRetry(${tag}): exhausted retries with no error captured`); |
| 1203 | } |
| 1204 | |
| 1205 | // Read API wrapper with retry + proactive pacing. Read requests are cheaper |
| 1206 | // than writes but still consume the primary rate limit and can trigger the |
no test coverage detected