Files
ai-florist/src/lib/server/aiError.js
Chaewon Lee d8f93f4c17 feat: implement AI bouquet generation flow with Gemini/OpenAI
* feat: scaffold message, generating, and map pages and align header steps

* feat: implement AI bouquet generation flow with Gemini/OpenAI

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:07:38 +09:00

170 lines
5.0 KiB
JavaScript

/**
* Classify an AI provider error (Gemini / OpenAI) into a structured shape the
* API layer and client can act on, instead of regex-matching message strings.
*
* The goal: never again show "rate-limited, retrying forever" for an error that
* is actually permanent (billing/quota exhausted, bad auth) or unrelated.
*
* @typedef {Object} AiErrorInfo
* @property {number} status HTTP status to return to the client
* @property {string} message human-readable message
* @property {string} code short machine code (e.g. 'rate_limited')
* @property {boolean} retryable safe to retry after a delay?
* @property {boolean} permanent will never succeed without user action?
* @property {number} retryAfterMs suggested wait before retrying
*/
const DEFAULT_RETRY_MS = 15_000;
const MIN_RETRY_MS = 5_000;
const MAX_RETRY_MS = 60_000;
/** @param {number} seconds */
function clampRetryMs(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_RETRY_MS;
return Math.max(MIN_RETRY_MS, Math.min(seconds * 1000, MAX_RETRY_MS));
}
/**
* Pull a "retry after N seconds" hint out of whatever the provider gave us:
* an OpenAI `retry-after` header, a Gemini RetryInfo detail, or the raw message.
* @param {any} error
* @param {string} message
*/
function extractRetryMs(error, message) {
// OpenAI: retry-after header (object map or Headers instance)
const headers = error?.headers;
if (headers) {
const raw =
typeof headers.get === 'function' ? headers.get('retry-after') : headers['retry-after'];
if (raw) return clampRetryMs(Number(raw));
}
// Gemini legacy SDK: errorDetails[].retryDelay = "56s"
const details = error?.errorDetails;
if (Array.isArray(details)) {
for (const detail of details) {
const delay = detail?.retryDelay;
if (typeof delay === 'string') {
const seconds = Number(delay.replace(/s$/i, ''));
if (Number.isFinite(seconds)) return clampRetryMs(seconds);
}
}
}
// Fallback: scrape the message ("retry in 56s" / "retryDelay": "56s")
const match =
message.match(/retry(?:\s+in|delay)["'\s:]*([\d.]+)\s*s/i) ??
message.match(/"retryDelay"\s*:\s*"?([\d.]+)s/i);
if (match) return clampRetryMs(Number(match[1]));
return DEFAULT_RETRY_MS;
}
/**
* @param {unknown} error
* @returns {AiErrorInfo}
*/
export function describeAiError(error) {
const message = error instanceof Error ? error.message : String(error);
/** @type {any} */
const anyErr = error;
// Numeric status from either SDK (OpenAI APIError.status, Gemini fetch error .status)
let status = typeof anyErr?.status === 'number' ? anyErr.status : 0;
if (!status) {
const m = message.match(/\[(\d{3})\b/) ?? message.match(/\b(4\d{2}|5\d{2})\b/);
if (m) status = Number(m[1]);
}
const code = typeof anyErr?.code === 'string' ? anyErr.code : '';
const lower = `${code} ${message}`.toLowerCase();
// Permanent billing/quota: waiting will NEVER help — the user must act.
const billingExhausted =
code === 'insufficient_quota' ||
/insufficient_quota|exceeded your current quota|billing|payment|plan and billing/.test(lower);
if (billingExhausted) {
return {
status: 402,
code: 'quota_exhausted',
message:
'Image generation is blocked: the provider account is out of quota/credits. Check billing and usage limits on the provider dashboard.',
retryable: false,
permanent: true,
retryAfterMs: 0
};
}
// Auth / verification problems — also permanent until the user fixes config.
if (
status === 401 ||
status === 403 ||
/api key|unauthorized|permission|verify your org/.test(lower)
) {
return {
status: status === 403 ? 403 : 401,
code: 'auth',
message: `Provider rejected the request (auth/permission): ${message}`,
retryable: false,
permanent: true,
retryAfterMs: 0
};
}
// Transient rate limit.
if (
status === 429 ||
/rate limit|too many requests|resource has been exhausted|quota/.test(lower)
) {
return {
status: 429,
code: 'rate_limited',
message: 'AI provider is rate-limiting requests right now.',
retryable: true,
permanent: false,
retryAfterMs: extractRetryMs(anyErr, message)
};
}
// Transient server-side outage / overload.
if (
status === 500 ||
status === 502 ||
status === 503 ||
status === 504 ||
/overloaded|unavailable|high demand|try again later/.test(lower)
) {
return {
status: 503,
code: 'unavailable',
message: 'AI provider is temporarily unavailable or overloaded.',
retryable: true,
permanent: false,
retryAfterMs: extractRetryMs(anyErr, message)
};
}
// Bad request (e.g. unsupported model, bad prompt) — retrying won't help.
if (status === 400 || status === 404 || status === 422) {
return {
status,
code: 'bad_request',
message: `Provider rejected the request: ${message}`,
retryable: false,
permanent: true,
retryAfterMs: 0
};
}
// Unknown: treat as a non-retryable server error so it surfaces instead of looping.
return {
status: 500,
code: 'unknown',
message: message || 'Unexpected error during generation.',
retryable: false,
permanent: false,
retryAfterMs: 0
};
}