feat: implement error classification and handling for AI generation limits

- Added error classification for daily generation cap and concurrency limits in the PromptNode component, improving user feedback during AI image generation failures.
- Enhanced toast notifications to provide specific messages for daily limit and concurrent job errors.
- Introduced internal mutations in the credits module to check abuse limits and track usage, ensuring better resource management and user experience.
- Updated AI error handling logic to categorize and respond to different error types effectively.
This commit is contained in:
Matthias
2026-03-28 11:31:11 +01:00
parent 4b4d784768
commit e5f27d7d29
6 changed files with 193 additions and 12 deletions

View File

@@ -38,6 +38,7 @@ import { Sparkles, Loader2, Coins } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages"; import { msg } from "@/lib/toast-messages";
import { classifyError } from "@/lib/ai-errors";
type PromptNodeData = { type PromptNodeData = {
prompt?: string; prompt?: string;
@@ -257,7 +258,21 @@ export default function PromptNode({
}, },
); );
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : msg.ai.generationFailed.title); const classified = classifyError(err);
if (classified.category === "daily_cap") {
toast.error(
msg.billing.dailyLimitReached(0).title,
"Morgen stehen wieder Generierungen zur Verfügung.",
);
} else if (classified.category === "concurrency") {
toast.warning(
msg.ai.concurrentLimitReached.title,
msg.ai.concurrentLimitReached.desc,
);
} else {
setError(classified.message || msg.ai.generationFailed.title);
}
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }

View File

@@ -103,12 +103,14 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
/> />
) : ( ) : (
<div <div
onDoubleClick={() => setIsEditing(true)} onClick={() => {
className="min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm" if (selected) setIsEditing(true);
}}
className="nodrag nowheel min-h-[2rem] cursor-text whitespace-pre-wrap break-words text-sm"
> >
{content || ( {content || (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Doppelklick zum Bearbeiten Auswählen, dann hier klicken
</span> </span>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { action } from "./_generated/server"; import { action } from "./_generated/server";
import { api } from "./_generated/api"; import { api, internal } from "./_generated/api";
import { import {
generateImageViaOpenRouter, generateImageViaOpenRouter,
DEFAULT_IMAGE_MODEL, DEFAULT_IMAGE_MODEL,
@@ -182,6 +182,9 @@ export const generateImage = action({
throw new Error(`Unknown model: ${modelId}`); throw new Error(`Unknown model: ${modelId}`);
} }
// Abuse-Check vor allem anderen — immer, unabhängig von Credits
await ctx.runMutation(internal.credits.checkAbuseLimits, {});
const reservationId = internalCreditsEnabled const reservationId = internalCreditsEnabled
? await ctx.runMutation(api.credits.reserve, { ? await ctx.runMutation(api.credits.reserve, {
estimatedCost: modelConfig.creditCost, estimatedCost: modelConfig.creditCost,
@@ -192,15 +195,21 @@ export const generateImage = action({
}) })
: null; : null;
// Usage-Tracking wenn Credits deaktiviert (reserve übernimmt das bei aktivierten Credits)
if (!internalCreditsEnabled) {
await ctx.runMutation(internal.credits.incrementUsage, {});
}
let retryCount = 0;
try {
// Status auf "executing" setzen — im try-Block damit Fehler den catch erreichen
await ctx.runMutation(api.nodes.updateStatus, { await ctx.runMutation(api.nodes.updateStatus, {
nodeId: args.nodeId, nodeId: args.nodeId,
status: "executing", status: "executing",
retryCount: 0, retryCount: 0,
}); });
let retryCount = 0;
try {
let referenceImageUrl = args.referenceImageUrl?.trim() || undefined; let referenceImageUrl = args.referenceImageUrl?.trim() || undefined;
if (args.referenceStorageId) { if (args.referenceStorageId) {
referenceImageUrl = referenceImageUrl =
@@ -290,6 +299,12 @@ export const generateImage = action({
}); });
throw error; throw error;
} finally {
// Concurrency freigeben wenn Credits deaktiviert
// (commit/release übernehmen das bei aktivierten Credits)
if (!internalCreditsEnabled) {
await ctx.runMutation(internal.credits.decrementConcurrency, {});
}
} }
}, },
}); });

View File

@@ -1,6 +1,7 @@
import { query, mutation, internalMutation } from "./_generated/server"; import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { requireAuth } from "./helpers"; import { requireAuth } from "./helpers";
import { internal } from "./_generated/api";
// ============================================================================ // ============================================================================
// Tier-Konfiguration // Tier-Konfiguration
@@ -344,14 +345,14 @@ export const reserve = mutation({
if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) { if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) {
throw new Error( throw new Error(
`Daily generation limit reached (${config.dailyGenerationCap}/${tier})` `daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
); );
} }
// Concurrency Limit prüfen // Concurrency Limit prüfen
if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) { if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) {
throw new Error( throw new Error(
`Concurrent job limit reached (${config.concurrencyLimit}/${tier})` `concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
); );
} }
@@ -652,3 +653,109 @@ export const topUp = mutation({
}); });
}, },
}); });
// ============================================================================
// Internal Mutations — Abuse Prevention (für ai.ts Action)
// ============================================================================
/**
* Prüft Daily Cap und Concurrency — wirft Fehler bei Verstoß.
* Wird von generateImage aufgerufen BEVOR Credits reserviert werden.
* Wird benötigt, wenn INTERNAL_CREDITS_ENABLED !== "true".
*/
export const checkAbuseLimits = internalMutation({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const subscription = await ctx.db
.query("subscriptions")
.withIndex("by_user", (q) => q.eq("userId", user.userId))
.order("desc")
.first();
const tier = (subscription?.tier ?? "free") as Tier;
const config = TIER_CONFIG[tier];
const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
const dailyCount = usage?.generationCount ?? 0;
if (dailyCount >= config.dailyGenerationCap) {
throw new Error(
`daily_cap:Tageslimit erreicht (${config.dailyGenerationCap} Generierungen/Tag im ${tier}-Tier)`
);
}
const currentConcurrency = usage?.concurrentJobs ?? 0;
if (currentConcurrency >= config.concurrencyLimit) {
throw new Error(
`concurrency:Bereits ${config.concurrencyLimit} Generierung(en) aktiv — bitte warten`
);
}
},
});
/**
* Erhöht generationCount und concurrentJobs atomar.
* Nur aufrufen wenn INTERNAL_CREDITS_ENABLED !== "true"
* (reserve übernimmt das bei aktivierten Credits).
*/
export const incrementUsage = internalMutation({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
if (usage) {
await ctx.db.patch(usage._id, {
generationCount: usage.generationCount + 1,
concurrentJobs: usage.concurrentJobs + 1,
});
} else {
await ctx.db.insert("dailyUsage", {
userId: user.userId,
date: today,
generationCount: 1,
concurrentJobs: 1,
});
}
},
});
/**
* Verringert concurrentJobs um 1 (Minimum 0).
* Nur aufrufen wenn INTERNAL_CREDITS_ENABLED !== "true"
* (commit/release übernehmen das bei aktivierten Credits).
*/
export const decrementConcurrency = internalMutation({
args: {},
handler: async (ctx) => {
const user = await requireAuth(ctx);
const today = new Date().toISOString().split("T")[0];
const usage = await ctx.db
.query("dailyUsage")
.withIndex("by_user_date", (q) =>
q.eq("userId", user.userId).eq("date", today)
)
.unique();
if (usage && usage.concurrentJobs > 0) {
await ctx.db.patch(usage._id, {
concurrentJobs: usage.concurrentJobs - 1,
});
}
},
});

View File

@@ -6,6 +6,8 @@ export type AiErrorCategory =
| "network" | "network"
| "server" | "server"
| "invalid_request" | "invalid_request"
| "daily_cap"
| "concurrency"
| "unknown"; | "unknown";
export interface AiError { export interface AiError {
@@ -52,6 +54,12 @@ const CATEGORY_ALIASES: Record<string, AiErrorCategory> = {
invalidrequest: "invalid_request", invalidrequest: "invalid_request",
bad_request: "invalid_request", bad_request: "invalid_request",
badrequest: "invalid_request", badrequest: "invalid_request",
daily_cap: "daily_cap",
dailycap: "daily_cap",
daily_limit: "daily_cap",
dailylimit: "daily_cap",
concurrency: "concurrency",
concurrent: "concurrency",
}; };
function normalizeCategory(value: string | undefined): AiErrorCategory | undefined { function normalizeCategory(value: string | undefined): AiErrorCategory | undefined {
@@ -153,6 +161,22 @@ function inferCategoryFromText(text: string): AiErrorCategory {
return "rate_limited"; return "rate_limited";
} }
if (
lower.includes("daily_cap") ||
lower.includes("tageslimit erreicht") ||
lower.includes("daily generation limit")
) {
return "daily_cap";
}
if (
lower.includes("concurrency") ||
lower.includes("generierung(en) aktiv") ||
lower.includes("concurrent job limit")
) {
return "concurrency";
}
if ( if (
lower.includes("timeout") || lower.includes("timeout") ||
lower.includes("timed out") || lower.includes("timed out") ||
@@ -246,6 +270,20 @@ function defaultsForCategory(category: AiErrorCategory): Omit<AiError, "category
creditsNotCharged: true, creditsNotCharged: true,
showTopUp: false, showTopUp: false,
}; };
case "daily_cap":
return {
message: "Tageslimit erreicht",
retryable: false,
creditsNotCharged: true,
showTopUp: false,
};
case "concurrency":
return {
message: "Generierung bereits aktiv",
retryable: true,
creditsNotCharged: true,
showTopUp: false,
};
case "unknown": case "unknown":
default: default:
return { return {

View File

@@ -45,6 +45,10 @@ export const msg = {
title: "OpenRouter möglicherweise gestört", title: "OpenRouter möglicherweise gestört",
desc: "Mehrere Generierungen fehlgeschlagen.", desc: "Mehrere Generierungen fehlgeschlagen.",
}, },
concurrentLimitReached: {
title: "Generierung bereits aktiv",
desc: "Bitte warte, bis die laufende Generierung abgeschlossen ist.",
},
}, },
export: { export: {