feat: enhance canvas and node components with error handling and retry logic
- Integrated retry logic for AI image generation to handle transient errors and improve user experience. - Updated error categorization to provide more informative feedback based on different failure scenarios. - Enhanced node components to display retry attempts and error messages, improving visibility during image generation failures. - Refactored canvas and node components to include retry count in status updates, ensuring accurate tracking of generation attempts.
This commit is contained in:
185
convex/ai.ts
185
convex/ai.ts
@@ -7,6 +7,155 @@ import {
|
||||
IMAGE_MODELS,
|
||||
} from "./openrouter";
|
||||
|
||||
const MAX_IMAGE_RETRIES = 2;
|
||||
|
||||
type ErrorCategory =
|
||||
| "credits"
|
||||
| "policy"
|
||||
| "timeout"
|
||||
| "transient"
|
||||
| "provider"
|
||||
| "unknown";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error ?? "Generation failed");
|
||||
}
|
||||
|
||||
function parseOpenRouterStatus(message: string): number | null {
|
||||
const match = message.match(/OpenRouter API error\s+(\d+)/i);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[1]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function categorizeError(error: unknown): {
|
||||
category: ErrorCategory;
|
||||
retryable: boolean;
|
||||
} {
|
||||
const message = errorMessage(error);
|
||||
const lower = message.toLowerCase();
|
||||
const status = parseOpenRouterStatus(message);
|
||||
|
||||
if (
|
||||
lower.includes("insufficient credits") ||
|
||||
lower.includes("daily generation limit") ||
|
||||
lower.includes("concurrent job limit")
|
||||
) {
|
||||
return { category: "credits", retryable: false };
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("modell lehnt ab") ||
|
||||
lower.includes("content policy") ||
|
||||
lower.includes("policy") ||
|
||||
lower.includes("moderation") ||
|
||||
lower.includes("safety") ||
|
||||
lower.includes("refusal") ||
|
||||
lower.includes("policy_violation")
|
||||
) {
|
||||
return { category: "policy", retryable: false };
|
||||
}
|
||||
|
||||
if (status !== null) {
|
||||
if (status >= 500 || status === 408 || status === 429 || status === 499) {
|
||||
return { category: "provider", retryable: true };
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
return { category: "provider", retryable: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("timeout") ||
|
||||
lower.includes("timed out") ||
|
||||
lower.includes("deadline") ||
|
||||
lower.includes("abort") ||
|
||||
lower.includes("etimedout")
|
||||
) {
|
||||
return { category: "timeout", retryable: true };
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("fetch failed") ||
|
||||
lower.includes("network") ||
|
||||
lower.includes("connection") ||
|
||||
lower.includes("econnreset") ||
|
||||
lower.includes("temporarily unavailable") ||
|
||||
lower.includes("service unavailable") ||
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("overloaded")
|
||||
) {
|
||||
return { category: "transient", retryable: true };
|
||||
}
|
||||
|
||||
return { category: "unknown", retryable: false };
|
||||
}
|
||||
|
||||
function formatTerminalStatusMessage(error: unknown): string {
|
||||
const message = errorMessage(error).trim() || "Generation failed";
|
||||
const { category } = categorizeError(error);
|
||||
|
||||
const prefixByCategory: Record<Exclude<ErrorCategory, "unknown">, string> = {
|
||||
credits: "Credits",
|
||||
policy: "Policy",
|
||||
timeout: "Timeout",
|
||||
transient: "Netzwerk",
|
||||
provider: "Provider",
|
||||
};
|
||||
|
||||
if (category === "unknown") {
|
||||
return message;
|
||||
}
|
||||
|
||||
const prefix = prefixByCategory[category];
|
||||
if (message.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return `${prefix}: ${message}`;
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function generateImageWithAutoRetry(
|
||||
operation: () => Promise<Awaited<ReturnType<typeof generateImageViaOpenRouter>>>,
|
||||
onRetry: (
|
||||
retryCount: number,
|
||||
maxRetries: number,
|
||||
failure: { message: string; category: ErrorCategory }
|
||||
) => Promise<void>
|
||||
) {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_IMAGE_RETRIES; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const { retryable, category } = categorizeError(error);
|
||||
const retryCount = attempt + 1;
|
||||
const hasRemainingRetry = retryCount <= MAX_IMAGE_RETRIES;
|
||||
|
||||
if (!retryable || !hasRemainingRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await onRetry(retryCount, MAX_IMAGE_RETRIES, {
|
||||
message: errorMessage(error),
|
||||
category,
|
||||
});
|
||||
await wait(Math.min(1500, 400 * retryCount));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Generation failed");
|
||||
}
|
||||
|
||||
export const generateImage = action({
|
||||
args: {
|
||||
canvasId: v.id("canvases"),
|
||||
@@ -45,8 +194,11 @@ export const generateImage = action({
|
||||
await ctx.runMutation(api.nodes.updateStatus, {
|
||||
nodeId: args.nodeId,
|
||||
status: "executing",
|
||||
retryCount: 0,
|
||||
});
|
||||
|
||||
let retryCount = 0;
|
||||
|
||||
try {
|
||||
let referenceImageUrl: string | undefined;
|
||||
if (args.referenceStorageId) {
|
||||
@@ -54,12 +206,28 @@ export const generateImage = action({
|
||||
(await ctx.storage.getUrl(args.referenceStorageId)) ?? undefined;
|
||||
}
|
||||
|
||||
const result = await generateImageViaOpenRouter(apiKey, {
|
||||
prompt: args.prompt,
|
||||
referenceImageUrl,
|
||||
model: modelId,
|
||||
aspectRatio: args.aspectRatio,
|
||||
});
|
||||
const result = await generateImageWithAutoRetry(
|
||||
() =>
|
||||
generateImageViaOpenRouter(apiKey, {
|
||||
prompt: args.prompt,
|
||||
referenceImageUrl,
|
||||
model: modelId,
|
||||
aspectRatio: args.aspectRatio,
|
||||
}),
|
||||
async (nextRetryCount, maxRetries, failure) => {
|
||||
retryCount = nextRetryCount;
|
||||
const reason =
|
||||
typeof failure.message === "string"
|
||||
? failure.message
|
||||
: "temporärer Fehler";
|
||||
await ctx.runMutation(api.nodes.updateStatus, {
|
||||
nodeId: args.nodeId,
|
||||
status: "executing",
|
||||
retryCount: nextRetryCount,
|
||||
statusMessage: `Retry ${nextRetryCount}/${maxRetries} — ${reason}`,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const binaryString = atob(result.imageBase64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
@@ -97,6 +265,7 @@ export const generateImage = action({
|
||||
await ctx.runMutation(api.nodes.updateStatus, {
|
||||
nodeId: args.nodeId,
|
||||
status: "done",
|
||||
retryCount,
|
||||
});
|
||||
|
||||
if (reservationId) {
|
||||
@@ -115,8 +284,8 @@ export const generateImage = action({
|
||||
await ctx.runMutation(api.nodes.updateStatus, {
|
||||
nodeId: args.nodeId,
|
||||
status: "error",
|
||||
statusMessage:
|
||||
error instanceof Error ? error.message : "Generation failed",
|
||||
retryCount,
|
||||
statusMessage: formatTerminalStatusMessage(error),
|
||||
});
|
||||
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user