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;
|
||||
|
||||
@@ -160,6 +160,7 @@ export const create = mutation({
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
status: "idle",
|
||||
retryCount: 0,
|
||||
data: args.data,
|
||||
parentId: args.parentId,
|
||||
zIndex: args.zIndex,
|
||||
@@ -276,14 +277,19 @@ export const updateStatus = mutation({
|
||||
v.literal("error")
|
||||
),
|
||||
statusMessage: v.optional(v.string()),
|
||||
retryCount: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { nodeId, status, statusMessage }) => {
|
||||
handler: async (ctx, { nodeId, status, statusMessage, retryCount }) => {
|
||||
const user = await requireAuth(ctx);
|
||||
const node = await ctx.db.get(nodeId);
|
||||
if (!node) throw new Error("Node not found");
|
||||
|
||||
await getCanvasOrThrow(ctx, node.canvasId, user.userId);
|
||||
const patch: { status: typeof status; statusMessage?: string } = {
|
||||
const patch: {
|
||||
status: typeof status;
|
||||
statusMessage?: string;
|
||||
retryCount?: number;
|
||||
} = {
|
||||
status,
|
||||
};
|
||||
if (statusMessage !== undefined) {
|
||||
@@ -291,6 +297,9 @@ export const updateStatus = mutation({
|
||||
} else if (status === "done" || status === "executing" || status === "idle") {
|
||||
patch.statusMessage = undefined;
|
||||
}
|
||||
if (retryCount !== undefined) {
|
||||
patch.retryCount = retryCount;
|
||||
}
|
||||
await ctx.db.patch(nodeId, patch);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -183,6 +183,7 @@ export default defineSchema({
|
||||
// Node-Status (UX-Strategie: Status direkt am Node sichtbar)
|
||||
status: nodeStatus,
|
||||
statusMessage: v.optional(v.string()), // z.B. "Timeout — Credits nicht abgebucht"
|
||||
retryCount: v.optional(v.number()), // Anzahl bereits durchgeführter Retries
|
||||
// Typ-spezifische Daten
|
||||
// Convex empfiehlt v.any() für polymorphe data-Felder
|
||||
// Type Safety wird über den `type`-Discriminator + Zod im Frontend sichergestellt
|
||||
|
||||
Reference in New Issue
Block a user