Skip to content

Commit a882f09

Browse files
Mlaz-codeclaude
andauthored
feat: retry 502/503/504 with jittered backoff (#1)
HttpClient now retries up to 3 times on transient upstream failures (502/503/504 or fetch errors) with full-jitter exponential backoff (500ms base → 4s cap). Shields SDK users from the ~3s cold-start gap when the SharpAPI server restarts during deploys. Also DRYs up get/post onto a single request() path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1bf03b3 commit a882f09

1 file changed

Lines changed: 55 additions & 58 deletions

File tree

src/index.ts

Lines changed: 55 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,16 @@ export interface StreamParams {
285285
// HTTP Client
286286
// =============================================================================
287287

288+
const RETRY_STATUSES = new Set([502, 503, 504])
289+
const RETRY_MAX_ATTEMPTS = 3
290+
const RETRY_BASE_DELAY_MS = 500
291+
const RETRY_MAX_DELAY_MS = 4000
292+
293+
function retryDelay(attempt: number): number {
294+
const ceiling = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
295+
return Math.random() * ceiling
296+
}
297+
288298
class HttpClient {
289299
private apiKey: string
290300
private baseUrl: string
@@ -314,62 +324,47 @@ class HttpClient {
314324
return url.toString()
315325
}
316326

317-
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
327+
private async request<T>(method: 'GET' | 'POST', path: string, body?: unknown, params?: Record<string, unknown>): Promise<T> {
318328
const url = this.buildUrl(path, params)
329+
const init: RequestInit = {
330+
method,
331+
headers: {
332+
'X-API-Key': this.apiKey,
333+
'Content-Type': 'application/json',
334+
},
335+
}
336+
if (body !== undefined) init.body = JSON.stringify(body)
319337

320-
const controller = new AbortController()
321-
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
322-
323-
try {
324-
const response = await fetch(url, {
325-
method: 'GET',
326-
headers: {
327-
'X-API-Key': this.apiKey,
328-
'Content-Type': 'application/json',
329-
},
330-
signal: controller.signal,
331-
})
332-
333-
clearTimeout(timeoutId)
338+
let lastNetworkError: Error | undefined
339+
for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
340+
const controller = new AbortController()
341+
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
342+
let response: Response | undefined
343+
let networkError: Error | undefined
334344

335-
if (!response.ok) {
336-
const errorData = await response.json().catch(() => ({})) as APIError
337-
const error: Error & { code?: string; status?: number } = new Error(errorData.error?.message || `HTTP ${response.status}`)
338-
error.code = errorData.error?.code || 'unknown_error'
339-
error.status = response.status
340-
throw error
345+
try {
346+
response = await fetch(url, { ...init, signal: controller.signal })
347+
} catch (err) {
348+
networkError = err as Error
349+
if (networkError.name === 'AbortError') {
350+
clearTimeout(timeoutId)
351+
const error: Error & { code?: string } = new Error('Request timeout')
352+
error.code = 'timeout'
353+
throw error
354+
}
355+
} finally {
356+
clearTimeout(timeoutId)
341357
}
342358

343-
return response.json()
344-
} catch (err) {
345-
clearTimeout(timeoutId)
346-
if ((err as Error).name === 'AbortError') {
347-
const error: Error & { code?: string } = new Error('Request timeout')
348-
error.code = 'timeout'
349-
throw error
359+
const transient = networkError !== undefined || (response !== undefined && RETRY_STATUSES.has(response.status))
360+
if (attempt < RETRY_MAX_ATTEMPTS && transient) {
361+
lastNetworkError = networkError
362+
await new Promise(resolve => setTimeout(resolve, retryDelay(attempt)))
363+
continue
350364
}
351-
throw err
352-
}
353-
}
354-
355-
async post<T>(path: string, body?: unknown, params?: Record<string, unknown>): Promise<T> {
356-
const url = this.buildUrl(path, params)
357-
358-
const controller = new AbortController()
359-
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
360-
361-
try {
362-
const response = await fetch(url, {
363-
method: 'POST',
364-
headers: {
365-
'X-API-Key': this.apiKey,
366-
'Content-Type': 'application/json',
367-
},
368-
body: body ? JSON.stringify(body) : undefined,
369-
signal: controller.signal,
370-
})
371365

372-
clearTimeout(timeoutId)
366+
if (networkError) throw networkError
367+
if (!response) throw lastNetworkError ?? new Error('No response')
373368

374369
if (!response.ok) {
375370
const errorData = await response.json().catch(() => ({})) as APIError
@@ -379,16 +374,18 @@ class HttpClient {
379374
throw error
380375
}
381376

382-
return response.json()
383-
} catch (err) {
384-
clearTimeout(timeoutId)
385-
if ((err as Error).name === 'AbortError') {
386-
const error: Error & { code?: string } = new Error('Request timeout')
387-
error.code = 'timeout'
388-
throw error
389-
}
390-
throw err
377+
return response.json() as Promise<T>
391378
}
379+
380+
throw lastNetworkError ?? new Error('Max retries exceeded')
381+
}
382+
383+
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
384+
return this.request<T>('GET', path, undefined, params)
385+
}
386+
387+
async post<T>(path: string, body?: unknown, params?: Record<string, unknown>): Promise<T> {
388+
return this.request<T>('POST', path, body, params)
392389
}
393390

394391
getStreamUrl(path: string, params?: Record<string, unknown>): string {

0 commit comments

Comments
 (0)