1+ import { GoogleGenAI } from '@google/genai' ;
2+ import { GeminiEmbedding } from './gemini-embedding' ;
3+ import type { EmbeddingVector } from './base-embedding' ;
4+
5+ // Mock the GoogleGenAI module
6+ const mockEmbedContent = jest . fn ( ) ;
7+ jest . mock ( '@google/genai' , ( ) => {
8+ return {
9+ GoogleGenAI : jest . fn ( ) . mockImplementation ( ( ) => ( {
10+ models : {
11+ embedContent : mockEmbedContent ,
12+ } ,
13+ } ) ) ,
14+ } ;
15+ } ) ;
16+
17+ const MockGoogleGenAI = GoogleGenAI as unknown as jest . Mock ;
18+
19+ describe ( 'GeminiEmbedding Retry Mechanism' , ( ) => {
20+ let consoleLogSpy : jest . SpyInstance ;
21+
22+ beforeEach ( ( ) => {
23+ jest . resetModules ( ) ;
24+ mockEmbedContent . mockClear ( ) ;
25+ MockGoogleGenAI . mockClear ( ) ;
26+ consoleLogSpy = jest . spyOn ( console , 'log' ) . mockImplementation ( ( ) => { } ) ;
27+ } ) ;
28+
29+ afterEach ( ( ) => {
30+ consoleLogSpy . mockRestore ( ) ;
31+ } ) ;
32+
33+ describe ( 'Single Embedding with Retry' , ( ) => {
34+ it ( 'should succeed on first attempt' , async ( ) => {
35+ const embedding = new GeminiEmbedding ( {
36+ model : 'gemini-embedding-001' ,
37+ apiKey : 'test-key' ,
38+ maxRetries : 3 ,
39+ baseDelay : 100
40+ } ) ;
41+
42+ const mockResponse = {
43+ embeddings : [ {
44+ values : [ 0.1 , 0.2 , 0.3 ]
45+ } ]
46+ } ;
47+ mockEmbedContent . mockResolvedValue ( mockResponse ) ;
48+
49+ const result = await embedding . embed ( 'test text' ) ;
50+
51+ expect ( result ) . toEqual ( {
52+ vector : [ 0.1 , 0.2 , 0.3 ] ,
53+ dimension : 3
54+ } ) ;
55+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 1 ) ;
56+ expect ( consoleLogSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'retrying' ) ) ;
57+ } ) ;
58+
59+ it ( 'should retry on 503 error and eventually succeed' , async ( ) => {
60+ const embedding = new GeminiEmbedding ( {
61+ model : 'gemini-embedding-001' ,
62+ apiKey : 'test-key' ,
63+ maxRetries : 3 ,
64+ baseDelay : 10 // Fast for testing
65+ } ) ;
66+
67+ // First two attempts fail with 503, third succeeds
68+ const mockError = new Error ( '503 Service Unavailable: The model is overloaded. Please try again later.' ) ;
69+ const mockResponse = {
70+ embeddings : [ {
71+ values : [ 0.1 , 0.2 , 0.3 ]
72+ } ]
73+ } ;
74+
75+ mockEmbedContent
76+ . mockRejectedValueOnce ( mockError )
77+ . mockRejectedValueOnce ( mockError )
78+ . mockResolvedValueOnce ( mockResponse ) ;
79+
80+ const result = await embedding . embed ( 'test text' ) ;
81+
82+ expect ( result ) . toEqual ( {
83+ vector : [ 0.1 , 0.2 , 0.3 ] ,
84+ dimension : 3
85+ } ) ;
86+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 3 ) ;
87+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Single embed attempt 1 failed, retrying' ) ) ;
88+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Single embed attempt 2 failed, retrying' ) ) ;
89+ } ) ;
90+
91+ it ( 'should throw after exhausting all retries' , async ( ) => {
92+ const embedding = new GeminiEmbedding ( {
93+ model : 'gemini-embedding-001' ,
94+ apiKey : 'test-key' ,
95+ maxRetries : 2 ,
96+ baseDelay : 10
97+ } ) ;
98+
99+ const mockError = new Error ( '503 Service Unavailable: The model is overloaded. Please try again later.' ) ;
100+ mockEmbedContent . mockRejectedValue ( mockError ) ;
101+
102+ await expect ( embedding . embed ( 'test text' ) ) . rejects . toThrow ( '503 Service Unavailable' ) ;
103+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 2 ) ;
104+ } ) ;
105+
106+ it ( 'should not retry on non-retryable errors' , async ( ) => {
107+ const embedding = new GeminiEmbedding ( {
108+ model : 'gemini-embedding-001' ,
109+ apiKey : 'test-key' ,
110+ maxRetries : 3 ,
111+ baseDelay : 10
112+ } ) ;
113+
114+ const mockError = new Error ( '401 Unauthorized: Invalid API key' ) ;
115+ mockEmbedContent . mockRejectedValue ( mockError ) ;
116+
117+ await expect ( embedding . embed ( 'test text' ) ) . rejects . toThrow ( '401 Unauthorized' ) ;
118+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 1 ) ; // No retries
119+ expect ( consoleLogSpy ) . not . toHaveBeenCalledWith ( expect . stringContaining ( 'retrying' ) ) ;
120+ } ) ;
121+ } ) ;
122+
123+ describe ( 'Batch Embedding with Retry and Fallback' , ( ) => {
124+ it ( 'should succeed batch processing on first attempt' , async ( ) => {
125+ const embedding = new GeminiEmbedding ( {
126+ model : 'gemini-embedding-001' ,
127+ apiKey : 'test-key' ,
128+ maxRetries : 3 ,
129+ baseDelay : 100
130+ } ) ;
131+
132+ const mockResponse = {
133+ embeddings : [
134+ { values : [ 0.1 , 0.2 , 0.3 ] } ,
135+ { values : [ 0.4 , 0.5 , 0.6 ] }
136+ ]
137+ } ;
138+ mockEmbedContent . mockResolvedValue ( mockResponse ) ;
139+
140+ const result = await embedding . embedBatch ( [ 'text1' , 'text2' ] ) ;
141+
142+ expect ( result ) . toEqual ( [
143+ { vector : [ 0.1 , 0.2 , 0.3 ] , dimension : 3 } ,
144+ { vector : [ 0.4 , 0.5 , 0.6 ] , dimension : 3 }
145+ ] ) ;
146+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 1 ) ;
147+ } ) ;
148+
149+ it ( 'should retry batch processing and eventually succeed' , async ( ) => {
150+ const embedding = new GeminiEmbedding ( {
151+ model : 'gemini-embedding-001' ,
152+ apiKey : 'test-key' ,
153+ maxRetries : 3 ,
154+ baseDelay : 10
155+ } ) ;
156+
157+ const mockError = new Error ( '503 Service Unavailable: The model is overloaded. Please try again later.' ) ;
158+ const mockResponse = {
159+ embeddings : [
160+ { values : [ 0.1 , 0.2 , 0.3 ] } ,
161+ { values : [ 0.4 , 0.5 , 0.6 ] }
162+ ]
163+ } ;
164+
165+ mockEmbedContent
166+ . mockRejectedValueOnce ( mockError )
167+ . mockResolvedValueOnce ( mockResponse ) ;
168+
169+ const result = await embedding . embedBatch ( [ 'text1' , 'text2' ] ) ;
170+
171+ expect ( result ) . toEqual ( [
172+ { vector : [ 0.1 , 0.2 , 0.3 ] , dimension : 3 } ,
173+ { vector : [ 0.4 , 0.5 , 0.6 ] , dimension : 3 }
174+ ] ) ;
175+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 2 ) ;
176+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Attempt 1 failed with retryable error, retrying' ) ) ;
177+ } ) ;
178+
179+ it ( 'should fallback to individual requests when batch consistently fails' , async ( ) => {
180+ const embedding = new GeminiEmbedding ( {
181+ model : 'gemini-embedding-001' ,
182+ apiKey : 'test-key' ,
183+ maxRetries : 2 ,
184+ baseDelay : 10
185+ } ) ;
186+
187+ const mockBatchError = new Error ( '503 Service Unavailable: The model is overloaded. Please try again later.' ) ;
188+ const mockSingleResponse1 = { embeddings : [ { values : [ 0.1 , 0.2 , 0.3 ] } ] } ;
189+ const mockSingleResponse2 = { embeddings : [ { values : [ 0.4 , 0.5 , 0.6 ] } ] } ;
190+
191+ mockEmbedContent
192+ // Batch attempts (2 failures)
193+ . mockRejectedValueOnce ( mockBatchError )
194+ . mockRejectedValueOnce ( mockBatchError )
195+ // Individual requests (2 successes)
196+ . mockResolvedValueOnce ( mockSingleResponse1 )
197+ . mockResolvedValueOnce ( mockSingleResponse2 ) ;
198+
199+ const result = await embedding . embedBatch ( [ 'text1' , 'text2' ] ) ;
200+
201+ expect ( result ) . toEqual ( [
202+ { vector : [ 0.1 , 0.2 , 0.3 ] , dimension : 3 } ,
203+ { vector : [ 0.4 , 0.5 , 0.6 ] , dimension : 3 }
204+ ] ) ;
205+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 4 ) ; // 2 batch + 2 individual
206+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'falling back to individual requests' ) ) ;
207+ expect ( consoleLogSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Using fallback: processing 2 texts individually' ) ) ;
208+ } ) ;
209+
210+ it ( 'should validate response array length matches input length' , async ( ) => {
211+ const embedding = new GeminiEmbedding ( {
212+ model : 'gemini-embedding-001' ,
213+ apiKey : 'test-key'
214+ } ) ;
215+
216+ // Return only 1 embedding for 2 input texts
217+ const mockResponse = {
218+ embeddings : [
219+ { values : [ 0.1 , 0.2 , 0.3 ] }
220+ // Missing second embedding
221+ ]
222+ } ;
223+ mockEmbedContent . mockResolvedValue ( mockResponse ) ;
224+
225+ await expect ( embedding . embedBatch ( [ 'text1' , 'text2' ] ) ) . rejects . toThrow (
226+ 'Gemini API returned 1 embeddings but expected 2'
227+ ) ;
228+ } ) ;
229+ } ) ;
230+
231+ describe ( 'Error Classification' , ( ) => {
232+ it ( 'should correctly identify retryable errors' , async ( ) => {
233+ const embedding = new GeminiEmbedding ( {
234+ model : 'gemini-embedding-001' ,
235+ apiKey : 'test-key'
236+ } ) ;
237+
238+ const retryableErrors = [
239+ 'Error: 503 Service Unavailable' ,
240+ 'Error: 429 Too Many Requests' ,
241+ 'The model is overloaded. Please try again later.' ,
242+ 'Status: UNAVAILABLE' ,
243+ 'Server is busy, please try again' ,
244+ 'Rate limit exceeded' ,
245+ 'Temporarily unavailable' ,
246+ 'Network timeout error'
247+ ] ;
248+
249+ for ( const errorMsg of retryableErrors ) {
250+ expect ( embedding [ 'isRetryableError' ] ( errorMsg ) ) . toBe ( true ) ;
251+ }
252+ } ) ;
253+
254+ it ( 'should correctly identify non-retryable errors' , async ( ) => {
255+ const embedding = new GeminiEmbedding ( {
256+ model : 'gemini-embedding-001' ,
257+ apiKey : 'test-key'
258+ } ) ;
259+
260+ const nonRetryableErrors = [
261+ 'Error: 401 Unauthorized' ,
262+ 'Error: 403 Forbidden' ,
263+ 'Invalid API key provided' ,
264+ 'Model not found' ,
265+ 'Invalid request format' ,
266+ 'Quota exceeded'
267+ ] ;
268+
269+ for ( const errorMsg of nonRetryableErrors ) {
270+ expect ( embedding [ 'isRetryableError' ] ( errorMsg ) ) . toBe ( false ) ;
271+ }
272+ } ) ;
273+ } ) ;
274+
275+ describe ( 'Configuration Options' , ( ) => {
276+ it ( 'should use custom retry configuration' , async ( ) => {
277+ const embedding = new GeminiEmbedding ( {
278+ model : 'gemini-embedding-001' ,
279+ apiKey : 'test-key' ,
280+ maxRetries : 5 ,
281+ baseDelay : 50
282+ } ) ;
283+
284+ const mockError = new Error ( '503 Service Unavailable' ) ;
285+ mockEmbedContent . mockRejectedValue ( mockError ) ;
286+
287+ await expect ( embedding . embed ( 'test' ) ) . rejects . toThrow ( ) ;
288+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 5 ) ; // Custom maxRetries
289+ } ) ;
290+
291+ it ( 'should use default retry configuration when not specified' , async ( ) => {
292+ const embedding = new GeminiEmbedding ( {
293+ model : 'gemini-embedding-001' ,
294+ apiKey : 'test-key'
295+ // No retry config specified
296+ } ) ;
297+
298+ const mockError = new Error ( '503 Service Unavailable' ) ;
299+ mockEmbedContent . mockRejectedValue ( mockError ) ;
300+
301+ await expect ( embedding . embed ( 'test' ) ) . rejects . toThrow ( ) ;
302+ expect ( mockEmbedContent ) . toHaveBeenCalledTimes ( 3 ) ; // Default maxRetries = 3
303+ } ) ;
304+ } ) ;
305+ } ) ;
0 commit comments