Skip to content

Commit a577697

Browse files
superlowburnclaudesteipete
authored
fix: add retry logic for OpenAI embedding API failures (#272)
* fix: add retry logic for OpenAI embedding API failures Fixes #149 When importing or uploading skills, the OpenAI embedding API call could fail with transient errors (rate limits, timeouts, network issues), causing the entire import to fail with a generic "Server Error". This adds retry logic with exponential backoff (1s, 2s, 4s delays): - Retries on 429 (rate limit) and 5xx server errors - Retries on network/fetch errors - Logs warnings for debugging - Max 3 retries before failing with clear error message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct retry count and broaden network error catch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address retry loop off-by-one, broaden error catch, preserve original error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: harden embeddings retry semantics * style: format embeddings retry changes --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 6a2c131 commit a577697

File tree

2 files changed

+222
-33
lines changed

2 files changed

+222
-33
lines changed

convex/lib/embeddings.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/* @vitest-environment node */
2+
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { EMBEDDING_DIMENSIONS, generateEmbedding } from './embeddings'
5+
6+
const fetchMock = vi.fn<typeof fetch>()
7+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
8+
9+
const originalFetch = globalThis.fetch
10+
const originalApiKey = process.env.OPENAI_API_KEY
11+
12+
function jsonResponse(payload: unknown, init?: ResponseInit) {
13+
return new Response(JSON.stringify(payload), {
14+
status: 200,
15+
headers: {
16+
'content-type': 'application/json',
17+
},
18+
...init,
19+
})
20+
}
21+
22+
beforeEach(() => {
23+
fetchMock.mockReset()
24+
globalThis.fetch = fetchMock as typeof fetch
25+
process.env.OPENAI_API_KEY = 'test-key'
26+
consoleWarnSpy.mockClear()
27+
})
28+
29+
afterEach(() => {
30+
globalThis.fetch = originalFetch
31+
32+
if (originalApiKey === undefined) {
33+
delete process.env.OPENAI_API_KEY
34+
} else {
35+
process.env.OPENAI_API_KEY = originalApiKey
36+
}
37+
38+
vi.useRealTimers()
39+
})
40+
41+
describe('generateEmbedding', () => {
42+
it('returns zero embedding when OPENAI_API_KEY is missing', async () => {
43+
delete process.env.OPENAI_API_KEY
44+
const result = await generateEmbedding('hello world')
45+
46+
expect(result).toHaveLength(EMBEDDING_DIMENSIONS)
47+
expect(result.every((value) => value === 0)).toBe(true)
48+
expect(fetchMock).not.toHaveBeenCalled()
49+
})
50+
51+
it('retries on 429 responses and then succeeds', async () => {
52+
vi.useFakeTimers()
53+
fetchMock.mockResolvedValueOnce(new Response('rate limited', { status: 429 }))
54+
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [{ embedding: [0.25, 0.75] }] }))
55+
56+
const promise = generateEmbedding('retry me')
57+
await vi.runAllTimersAsync()
58+
59+
await expect(promise).resolves.toEqual([0.25, 0.75])
60+
expect(fetchMock).toHaveBeenCalledTimes(2)
61+
})
62+
63+
it('does not retry non-retryable 4xx responses', async () => {
64+
fetchMock.mockResolvedValueOnce(new Response('bad request', { status: 400 }))
65+
66+
await expect(generateEmbedding('bad')).rejects.toThrow('Embedding failed: bad request')
67+
expect(fetchMock).toHaveBeenCalledTimes(1)
68+
})
69+
70+
it('retries on network failures and then succeeds', async () => {
71+
vi.useFakeTimers()
72+
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'))
73+
fetchMock.mockResolvedValueOnce(jsonResponse({ data: [{ embedding: [1, 2, 3] }] }))
74+
75+
const promise = generateEmbedding('network retry')
76+
await vi.runAllTimersAsync()
77+
78+
await expect(promise).resolves.toEqual([1, 2, 3])
79+
expect(fetchMock).toHaveBeenCalledTimes(2)
80+
})
81+
82+
it('retries timeouts up to max attempts and preserves timeout error', async () => {
83+
vi.useFakeTimers()
84+
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
85+
86+
const promise = generateEmbedding('always timeout')
87+
const rejection = expect(promise).rejects.toThrow(
88+
'OpenAI API request timed out after 10 seconds',
89+
)
90+
await vi.runAllTimersAsync()
91+
92+
await rejection
93+
expect(fetchMock).toHaveBeenCalledTimes(3)
94+
})
95+
})

convex/lib/embeddings.ts

Lines changed: 127 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,145 @@
11
export const EMBEDDING_MODEL = 'text-embedding-3-small'
22
export const EMBEDDING_DIMENSIONS = 1536
33

4+
const EMBEDDING_ENDPOINT = 'https://api.openai.com/v1/embeddings'
5+
const REQUEST_TIMEOUT_MS = 10_000
6+
const MAX_ATTEMPTS = 3
7+
const BASE_RETRY_DELAY_MS = 1_000
8+
9+
class RetryableEmbeddingError extends Error {
10+
constructor(message: string, options?: { cause?: unknown }) {
11+
super(message, options)
12+
this.name = 'RetryableEmbeddingError'
13+
}
14+
}
15+
416
function emptyEmbedding() {
517
return Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0)
618
}
719

20+
function parseRetryAfterMs(retryAfterHeader: string | null) {
21+
if (!retryAfterHeader) return null
22+
23+
const seconds = Number(retryAfterHeader)
24+
if (Number.isFinite(seconds) && seconds >= 0) {
25+
return Math.round(seconds * 1000)
26+
}
27+
28+
const dateMs = Date.parse(retryAfterHeader)
29+
if (Number.isFinite(dateMs)) {
30+
return Math.max(0, dateMs - Date.now())
31+
}
32+
33+
return null
34+
}
35+
36+
function getRetryDelayMs(attempt: number, retryAfterMs: number | null) {
37+
const exponentialDelayMs = BASE_RETRY_DELAY_MS * 2 ** attempt
38+
if (retryAfterMs == null) return exponentialDelayMs
39+
return Math.max(exponentialDelayMs, retryAfterMs)
40+
}
41+
42+
function normalizeRetryableNetworkError(error: unknown) {
43+
if (!(error instanceof Error)) return null
44+
45+
if (error.name === 'AbortError') {
46+
return new RetryableEmbeddingError(
47+
`OpenAI API request timed out after ${Math.floor(REQUEST_TIMEOUT_MS / 1000)} seconds`,
48+
{ cause: error },
49+
)
50+
}
51+
52+
if (error instanceof TypeError) {
53+
return new RetryableEmbeddingError(`Embedding request failed: ${error.message}`, { cause: error })
54+
}
55+
56+
return null
57+
}
58+
59+
function sleep(ms: number) {
60+
return new Promise<void>((resolve) => {
61+
setTimeout(resolve, ms)
62+
})
63+
}
64+
865
export async function generateEmbedding(text: string) {
966
const apiKey = process.env.OPENAI_API_KEY
1067
if (!apiKey) {
1168
console.warn('OPENAI_API_KEY is not configured; using zero embeddings')
1269
return emptyEmbedding()
1370
}
1471

15-
const controller = new AbortController()
16-
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
17-
18-
try {
19-
const response = await fetch('https://api.openai.com/v1/embeddings', {
20-
method: 'POST',
21-
headers: {
22-
'Content-Type': 'application/json',
23-
Authorization: `Bearer ${apiKey}`,
24-
},
25-
body: JSON.stringify({
26-
model: EMBEDDING_MODEL,
27-
input: text,
28-
}),
29-
signal: controller.signal,
30-
})
31-
32-
if (!response.ok) {
33-
const message = await response.text()
34-
throw new Error(`Embedding failed: ${message}`)
35-
}
72+
let lastRetryableError: RetryableEmbeddingError | null = null
3673

37-
const payload = (await response.json()) as {
38-
data?: Array<{ embedding: number[] }>
39-
}
40-
const embedding = payload.data?.[0]?.embedding
41-
if (!embedding) throw new Error('Embedding missing from response')
42-
return embedding
43-
} catch (error) {
44-
if (error instanceof Error && error.name === 'AbortError') {
45-
throw new Error('OpenAI API request timed out after 10 seconds', { cause: error })
74+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
75+
const controller = new AbortController()
76+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
77+
78+
try {
79+
const response = await fetch(EMBEDDING_ENDPOINT, {
80+
method: 'POST',
81+
headers: {
82+
'Content-Type': 'application/json',
83+
Authorization: `Bearer ${apiKey}`,
84+
},
85+
body: JSON.stringify({
86+
model: EMBEDDING_MODEL,
87+
input: text,
88+
}),
89+
signal: controller.signal,
90+
})
91+
92+
if (!response.ok) {
93+
const message = await response.text()
94+
const isRetryableStatus = response.status === 429 || response.status >= 500
95+
if (isRetryableStatus) {
96+
const retryableError = new RetryableEmbeddingError(
97+
`Embedding failed (${response.status}): ${message}`,
98+
)
99+
lastRetryableError = retryableError
100+
101+
if (attempt < MAX_ATTEMPTS - 1) {
102+
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'))
103+
const delayMs = getRetryDelayMs(attempt, retryAfterMs)
104+
console.warn(
105+
`OpenAI embeddings retry in ${delayMs}ms (attempt ${attempt + 1}/${MAX_ATTEMPTS})`,
106+
)
107+
await sleep(delayMs)
108+
continue
109+
}
110+
111+
throw retryableError
112+
}
113+
114+
throw new Error(`Embedding failed: ${message}`)
115+
}
116+
117+
const payload = (await response.json()) as {
118+
data?: Array<{ embedding: number[] }>
119+
}
120+
const embedding = payload.data?.[0]?.embedding
121+
if (!embedding) throw new Error('Embedding missing from response')
122+
return embedding
123+
} catch (error) {
124+
const retryableNetworkError = normalizeRetryableNetworkError(error)
125+
if (retryableNetworkError) {
126+
lastRetryableError = retryableNetworkError
127+
if (attempt < MAX_ATTEMPTS - 1) {
128+
const delayMs = getRetryDelayMs(attempt, null)
129+
console.warn(
130+
`OpenAI embeddings network retry in ${delayMs}ms (attempt ${attempt + 1}/${MAX_ATTEMPTS})`,
131+
)
132+
await sleep(delayMs)
133+
continue
134+
}
135+
throw retryableNetworkError
136+
}
137+
138+
throw error
139+
} finally {
140+
clearTimeout(timeoutId)
46141
}
47-
throw error
48-
} finally {
49-
clearTimeout(timeoutId)
50142
}
143+
144+
throw lastRetryableError ?? new Error('Embedding failed after retries')
51145
}

0 commit comments

Comments
 (0)