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}"`;
let raw: string;
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 100,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
let raw = '';
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: 100,
temperature: 0.1,
response_format: { type: 'json_object' },
}),
});
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');
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) {
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
@@ -38,6 +38,33 @@ ${text}`;
@Injectable()
export class ReceiptImportService {
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(
private readonly prisma: PrismaService,
@@ -72,30 +99,23 @@ export class ReceiptImportService {
apiKey: string,
): Promise<ParsedReceiptItem[]> {
const base64 = buffer.toString('base64');
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: RECEIPT_IMPORT_MODEL,
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${base64}` },
},
{ type: 'text', text: IMAGE_PROMPT },
],
},
],
max_tokens: 2000,
temperature: 0.1,
}),
});
const response = await this.callMistralWithRetry({
model: RECEIPT_IMPORT_MODEL,
messages: [
{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${base64}` },
},
{ type: 'text', text: IMAGE_PROMPT },
],
},
],
max_tokens: 2000,
temperature: 0.1,
}, apiKey, 'bild');
return this.extractItemsFromMistralResponse(response, 'bild');
}
@@ -120,19 +140,12 @@ export class ReceiptImportService {
this.logger.log(`PDF-text extraherad (${pdfText.length} tecken)`);
const response = await fetch(MISTRAL_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: RECEIPT_IMPORT_MODEL,
messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
max_tokens: 2000,
temperature: 0.1,
}),
});
const response = await this.callMistralWithRetry({
model: RECEIPT_IMPORT_MODEL,
messages: [{ role: 'user', content: TEXT_PROMPT(pdfText) }],
max_tokens: 2000,
temperature: 0.1,
}, apiKey, 'PDF');
return this.extractItemsFromMistralResponse(response, 'PDF');
}
+9 -7
View File
@@ -1,19 +1,21 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../auth';
import { NextRequest, NextResponse } from 'next/server';
import { getAuthHeaders } from '../../../lib/auth-headers';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = auth(async function GET(req) {
const token = (req.auth as any)?.accessToken as string | undefined;
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
export async function GET(req: NextRequest) {
const authHeaders = await getAuthHeaders();
if (!authHeaders.Authorization) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const query = url.searchParams.toString();
const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, {
headers: { Authorization: `Bearer ${token}` },
headers: authHeaders,
cache: 'no-store',
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
});
}