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:
Matthias
2026-03-27 11:35:18 +01:00
parent 99a359f330
commit 5da0204163
28 changed files with 1180 additions and 35 deletions

View File

@@ -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;