managarten/packages/shared-llm/src/__tests__/retry.spec.ts
Till JS e2f144962c feat: add unified @manacore/shared-llm package and migrate all backends
Create a shared LLM client package that provides a unified interface
to the mana-llm service, replacing 9 individual fetch-based integrations
with consistent error handling, retry logic, and JSON extraction.

Package (@manacore/shared-llm):
- LlmModule with forRoot/forRootAsync (NestJS dynamic module)
- LlmClientService: chat, json, vision, visionJson, embed, stream
- LlmClient standalone class for non-NestJS consumers
- extractJson utility (consolidates 3 markdown-stripping implementations)
- retryFetch with exponential backoff (429, 5xx, network errors)
- 44 unit tests (json-extractor, retry, llm-client)

Migrated backends:
- mana-core-auth: raw fetch → llm.json()
- planta: raw fetch + vision → llm.visionJson()
- nutriphi: raw fetch + regex → llm.visionJson() + llm.json()
- chat: custom OllamaService (175 LOC) → llm.chatMessages()
- context: raw fetch → llm.chat() (keeps token tracking)
- traces: 2x raw fetch → llm.chat()
- manadeck: @google/genai SDK → llm.json() + llm.visionJson()
- bot-services: raw Ollama API → LlmClient standalone
- matrix-ollama-bot: raw fetch → llm.chatMessages() + llm.vision()

New credit operations:
- AI_PLANT_ANALYSIS (2 credits, planta)
- AI_GUIDE_GENERATION (5 credits, traces)
- AI_CONTEXT_GENERATION (2 credits, context)
- AI_BOT_CHAT (0.1 credits, matrix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:06:30 +01:00

118 lines
3.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { retryFetch } from '../utils/retry';
// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
function mockResponse(status: number, body = ''): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: `Status ${status}`,
text: () => Promise.resolve(body),
json: () => Promise.resolve(JSON.parse(body || '{}')),
headers: new Headers(),
} as unknown as Response;
}
describe('retryFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns on first successful attempt', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(200, '{"ok": true}'));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('retries on 503 and succeeds', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(503))
.mockResolvedValueOnce(mockResponse(200, '{}'));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('retries on 429 rate limit', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(429))
.mockResolvedValueOnce(mockResponse(200, '{}'));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('retries on network error and succeeds', async () => {
mockFetch
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockResolvedValueOnce(mockResponse(200, '{}'));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('does NOT retry on 400 client error', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(400, 'Bad Request'));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.status).toBe(400);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does NOT retry on 401 unauthorized', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(401));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does NOT retry on 404 not found', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(404));
const response = await retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 });
expect(response.status).toBe(404);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('throws after exhausting all retries', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse(503))
.mockResolvedValueOnce(mockResponse(503))
.mockResolvedValueOnce(mockResponse(503));
await expect(retryFetch('http://test', {}, { maxRetries: 2, baseDelay: 10 })).rejects.toThrow(
'HTTP 503'
);
expect(mockFetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it('throws after exhausting retries on network errors', async () => {
mockFetch
.mockRejectedValueOnce(new Error('ECONNREFUSED'))
.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await expect(retryFetch('http://test', {}, { maxRetries: 1, baseDelay: 10 })).rejects.toThrow(
'ECONNREFUSED'
);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('works with maxRetries: 0 (no retries)', async () => {
mockFetch.mockResolvedValueOnce(mockResponse(503));
await expect(retryFetch('http://test', {}, { maxRetries: 0, baseDelay: 10 })).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});