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
+48 -30
View File
@@ -47,38 +47,56 @@ Regler:
const userPrompt = `Produkt: "${productName}"`; const userPrompt = `Produkt: "${productName}"`;
let raw: string; let raw = '';
try { const MAX_RETRIES = 3;
const response = await fetch(MISTRAL_API_URL, { for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
method: 'POST', try {
headers: { const response = await fetch(MISTRAL_API_URL, {
'Content-Type': 'application/json', method: 'POST',
Authorization: `Bearer ${apiKey}`, headers: {
}, 'Content-Type': 'application/json',
body: JSON.stringify({ Authorization: `Bearer ${apiKey}`,
model: MODEL, },
messages: [ body: JSON.stringify({
{ role: 'system', content: systemPrompt }, model: MODEL,
{ role: 'user', content: userPrompt }, messages: [
], { role: 'system', content: systemPrompt },
max_tokens: 100, { role: 'user', content: userPrompt },
temperature: 0.1, ],
response_format: { type: 'json_object' }, max_tokens: 100,
}), temperature: 0.1,
}); response_format: { type: 'json_object' },
}),
});
if (!response.ok) { if (response.status === 503 || response.status === 429) {
const err = await response.text(); const err = await response.text();
this.logger.error(`Mistral API-fel: ${response.status} ${err}`); this.logger.warn(`Mistral API ${response.status} (försök ${attempt}/${MAX_RETRIES}): ${err}`);
throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt'); 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) {
const err = await response.text();
this.logger.error(`Mistral API-fel: ${response.status} ${err}`);
throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt');
}
const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? '';
break;
} catch (err) {
if (err instanceof ServiceUnavailableException) throw 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');
} }
const data = await response.json() as { choices: { message: { content: string } }[] };
raw = data.choices?.[0]?.message?.content ?? '';
} catch (err) {
if (err instanceof ServiceUnavailableException) throw err;
this.logger.error(`Mistral fetch-fel: ${String(err)}`);
throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten');
} }
// Parsa och validera AI-svaret // Parsa och validera AI-svaret
@@ -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,30 +99,23 @@ 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', model: RECEIPT_IMPORT_MODEL,
headers: { messages: [
'Content-Type': 'application/json', {
Authorization: `Bearer ${apiKey}`, role: 'user',
}, content: [
body: JSON.stringify({ {
model: RECEIPT_IMPORT_MODEL, type: 'image_url',
messages: [ image_url: { url: `data:${mimeType};base64,${base64}` },
{ },
role: 'user', { type: 'text', text: IMAGE_PROMPT },
content: [ ],
{ },
type: 'image_url', ],
image_url: { url: `data:${mimeType};base64,${base64}` }, max_tokens: 2000,
}, temperature: 0.1,
{ type: 'text', text: IMAGE_PROMPT }, }, apiKey, 'bild');
],
},
],
max_tokens: 2000,
temperature: 0.1,
}),
});
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', model: RECEIPT_IMPORT_MODEL,
headers: { messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
'Content-Type': 'application/json', max_tokens: 2000,
Authorization: `Bearer ${apiKey}`, temperature: 0.1,
}, }, apiKey, 'PDF');
body: JSON.stringify({
model: RECEIPT_IMPORT_MODEL,
messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
max_tokens: 2000,
temperature: 0.1,
}),
});
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 });
}); }