( apiKey: string, prompt: string, outputPath: string, size: string, quality: string, fetchFn: typeof globalThis.fetch = globalThis.fetch, )
| 36 | * production code uses the global fetch by default. |
| 37 | */ |
| 38 | export async function generateVariant( |
| 39 | apiKey: string, |
| 40 | prompt: string, |
| 41 | outputPath: string, |
| 42 | size: string, |
| 43 | quality: string, |
| 44 | fetchFn: typeof globalThis.fetch = globalThis.fetch, |
| 45 | ): Promise<{ path: string; success: boolean; error?: string }> { |
| 46 | const maxRetries = 3; |
| 47 | const MAX_RETRY_AFTER_MS = 60_000; // cap honored Retry-After to bound stalls |
| 48 | let lastError = ""; |
| 49 | let skipLeadingDelay = false; |
| 50 | |
| 51 | for (let attempt = 0; attempt <= maxRetries; attempt++) { |
| 52 | if (attempt > 0 && !skipLeadingDelay) { |
| 53 | // Exponential backoff: 2s, 4s, 8s |
| 54 | const delay = Math.pow(2, attempt) * 1000; |
| 55 | console.error(` Rate limited, retrying in ${delay / 1000}s...`); |
| 56 | await new Promise(r => setTimeout(r, delay)); |
| 57 | } |
| 58 | skipLeadingDelay = false; |
| 59 | |
| 60 | const controller = new AbortController(); |
| 61 | const timeout = setTimeout(() => controller.abort(), 240_000); |
| 62 | |
| 63 | try { |
| 64 | const response = await fetchFn("https://api.openai.com/v1/responses", { |
| 65 | method: "POST", |
| 66 | headers: { |
| 67 | "Authorization": `Bearer ${apiKey}`, |
| 68 | "Content-Type": "application/json", |
| 69 | }, |
| 70 | body: JSON.stringify({ |
| 71 | model: "gpt-4o", |
| 72 | input: prompt, |
| 73 | tools: [{ type: "image_generation", model: "gpt-image-2", size, quality }], |
| 74 | }), |
| 75 | signal: controller.signal, |
| 76 | }); |
| 77 | |
| 78 | clearTimeout(timeout); |
| 79 | |
| 80 | if (response.status === 429) { |
| 81 | lastError = "Rate limited (429)"; |
| 82 | const retryAfter = response.headers.get("retry-after"); |
| 83 | if (retryAfter) { |
| 84 | const trimmed = retryAfter.trim(); |
| 85 | let waitMs: number | null = null; |
| 86 | if (/^\d+$/.test(trimmed)) { |
| 87 | // delta-seconds (RFC 7231) |
| 88 | waitMs = Math.min(Number.parseInt(trimmed, 10) * 1000, MAX_RETRY_AFTER_MS); |
| 89 | } else { |
| 90 | // HTTP-date (RFC 7231) |
| 91 | const dateMs = Date.parse(trimmed); |
| 92 | if (!Number.isNaN(dateMs)) { |
| 93 | waitMs = Math.min(Math.max(0, dateMs - Date.now()), MAX_RETRY_AFTER_MS); |
| 94 | } |
| 95 | } |
no test coverage detected