feat(api): implement retry logic for Mistral API calls in receipt import and AI services

This commit is contained in:
Nils-Johan Gynther
2026-04-19 11:31:05 +02:00
parent 15c24df1a7
commit 045f160655
3 changed files with 107 additions and 74 deletions
+20 -2
View File
@@ -47,7 +47,9 @@ Regler:
const userPrompt = `Produkt: "${productName}"`; const userPrompt = `Produkt: "${productName}"`;
let raw: string; let raw = '';
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try { try {
const response = await fetch(MISTRAL_API_URL, { const response = await fetch(MISTRAL_API_URL, {
method: 'POST', method: 'POST',
@@ -67,6 +69,16 @@ Regler:
}), }),
}); });
if (response.status === 503 || response.status === 429) {
const err = await response.text();
this.logger.warn(`Mistral API ${response.status} (försök ${attempt}/${MAX_RETRIES}): ${err}`);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, attempt * 1500));
continue;
}
throw new ServiceUnavailableException('AI-tjänsten är tillfälligt otillgänglig, försök igen');
}
if (!response.ok) { if (!response.ok) {
const err = await response.text(); const err = await response.text();
this.logger.error(`Mistral API-fel: ${response.status} ${err}`); this.logger.error(`Mistral API-fel: ${response.status} ${err}`);
@@ -75,11 +87,17 @@ Regler:
const data = await response.json() as { choices: { message: { content: string } }[] }; const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? ''; raw = data.choices?.[0]?.message?.content ?? '';
break;
} catch (err) { } catch (err) {
if (err instanceof ServiceUnavailableException) throw err; if (err instanceof ServiceUnavailableException) throw err;
this.logger.error(`Mistral fetch-fel: ${String(err)}`); this.logger.error(`Mistral fetch-fel (försök ${attempt}/${MAX_RETRIES}): ${String(err)}`);
if (attempt < MAX_RETRIES) {
await new Promise((r) => setTimeout(r, attempt * 1500));
continue;
}
throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten'); throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten');
} }
}
// Parsa och validera AI-svaret // Parsa och validera AI-svaret
let parsed: { categoryId: number; confidence: string }; let parsed: { categoryId: number; confidence: string };
@@ -38,6 +38,33 @@ ${text}`;
@Injectable() @Injectable()
export class ReceiptImportService { export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name); private readonly logger = new Logger(ReceiptImportService.name);
private readonly MAX_RETRIES = 3;
private async callMistralWithRetry(body: object, apiKey: string, source: string): Promise<Response> {
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (response.status === 503 || response.status === 429) {
const err = await response.text();
this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${this.MAX_RETRIES}): ${err}`);
if (attempt < this.MAX_RETRIES) {
await new Promise((r) => setTimeout(r, attempt * 2000));
continue;
}
throw new ServiceUnavailableException('Mistral API returnerade ett fel: Tjänsten tillfälligt otillgänglig (503)');
}
return response;
}
throw new ServiceUnavailableException('Kunde inte nå Mistral API efter flera försök');
}
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -72,13 +99,7 @@ export class ReceiptImportService {
apiKey: string, apiKey: string,
): Promise<ParsedReceiptItem[]> { ): Promise<ParsedReceiptItem[]> {
const base64 = buffer.toString('base64'); const base64 = buffer.toString('base64');
const response = await fetch(MISTRAL_API_URL, { const response = await this.callMistralWithRetry({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: RECEIPT_IMPORT_MODEL, model: RECEIPT_IMPORT_MODEL,
messages: [ messages: [
{ {
@@ -94,8 +115,7 @@ export class ReceiptImportService {
], ],
max_tokens: 2000, max_tokens: 2000,
temperature: 0.1, temperature: 0.1,
}), }, apiKey, 'bild');
});
return this.extractItemsFromMistralResponse(response, 'bild'); return this.extractItemsFromMistralResponse(response, 'bild');
} }
@@ -120,19 +140,12 @@ export class ReceiptImportService {
this.logger.log(`PDF-text extraherad (${pdfText.length} tecken)`); this.logger.log(`PDF-text extraherad (${pdfText.length} tecken)`);
const response = await fetch(MISTRAL_API_URL, { const response = await this.callMistralWithRetry({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: RECEIPT_IMPORT_MODEL, model: RECEIPT_IMPORT_MODEL,
messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }], messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
max_tokens: 2000, max_tokens: 2000,
temperature: 0.1, temperature: 0.1,
}), }, apiKey, 'PDF');
});
return this.extractItemsFromMistralResponse(response, 'PDF'); return this.extractItemsFromMistralResponse(response, 'PDF');
} }
+9 -7
View File
@@ -1,19 +1,21 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { auth } from '../../../auth'; import { getAuthHeaders } from '../../../lib/auth-headers';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = auth(async function GET(req) { export async function GET(req: NextRequest) {
const token = (req.auth as any)?.accessToken as string | undefined; const authHeaders = await getAuthHeaders();
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!authHeaders.Authorization) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url); const url = new URL(req.url);
const query = url.searchParams.toString(); const query = url.searchParams.toString();
const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, { const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders,
cache: 'no-store', cache: 'no-store',
}); });
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
}); }