feat(api): implement retry logic for Mistral API calls in receipt import and AI services
This commit is contained in:
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user