feat(api): retry configuration for api block (#3329)

* fix(api): add configurable request retries

The API block docs described automatic retries, but the block didn't expose any retry controls and requests were executed only once.

This adds tool-level retry support with exponential backoff (including Retry-After support) for timeouts, 429s, and 5xx responses, exposes retry settings in the API block and http_request tool, and updates the docs to match.

Fixes #3225

* remove unnecessary helpers, cleanup

* update desc

* ack comments

* ack comment

* ack

* handle timeouts

---------

Co-authored-by: Jay Prajapati <79649559+jayy-77@users.noreply.github.com>
This commit is contained in:
Waleed
2026-02-25 00:13:47 -08:00
committed by GitHub
parent ff01825b20
commit 43c0f5b199
7 changed files with 496 additions and 52 deletions

View File

@@ -95,11 +95,17 @@ const apiUrl = `https://api.example.com/users/${userId}/profile`;
### Request Retries
The API block automatically handles:
- Network timeouts with exponential backoff
- Rate limit responses (429 status codes)
- Server errors (5xx status codes) with retry logic
- Connection failures with reconnection attempts
The API block supports **configurable retries** (see the blocks **Advanced** settings):
- **Retries**: Number of retry attempts (additional tries after the first request)
- **Retry delay (ms)**: Initial delay before retrying (uses exponential backoff)
- **Max retry delay (ms)**: Maximum delay between retries
- **Retry non-idempotent methods**: Allow retries for **POST/PATCH** (may create duplicate requests)
Retries are attempted for:
- Network/connection failures and timeouts (with exponential backoff)
- Rate limits (**429**) and server errors (**5xx**)
### Response Validation

View File

@@ -89,6 +89,38 @@ Example:
'Request timeout in milliseconds (default: 300000 = 5 minutes, max: 600000 = 10 minutes)',
mode: 'advanced',
},
{
id: 'retries',
title: 'Retries',
type: 'short-input',
placeholder: '0',
description:
'Number of retry attempts for timeouts, 429 responses, and 5xx errors (default: 0, no retries)',
mode: 'advanced',
},
{
id: 'retryDelayMs',
title: 'Retry delay (ms)',
type: 'short-input',
placeholder: '500',
description: 'Initial retry delay in milliseconds (exponential backoff)',
mode: 'advanced',
},
{
id: 'retryMaxDelayMs',
title: 'Max retry delay (ms)',
type: 'short-input',
placeholder: '30000',
description: 'Maximum delay between retries in milliseconds',
mode: 'advanced',
},
{
id: 'retryNonIdempotent',
title: 'Retry non-idempotent methods',
type: 'switch',
description: 'Allow retries for POST/PATCH requests (may create duplicate requests)',
mode: 'advanced',
},
],
tools: {
access: ['http_request'],
@@ -100,6 +132,16 @@ Example:
body: { type: 'json', description: 'Request body data' },
params: { type: 'json', description: 'URL query parameters' },
timeout: { type: 'number', description: 'Request timeout in milliseconds' },
retries: { type: 'number', description: 'Number of retry attempts for retryable failures' },
retryDelayMs: { type: 'number', description: 'Initial retry delay in milliseconds' },
retryMaxDelayMs: {
type: 'number',
description: 'Maximum delay between retries in milliseconds',
},
retryNonIdempotent: {
type: 'boolean',
description: 'Allow retries for non-idempotent methods like POST/PATCH',
},
},
outputs: {
data: { type: 'json', description: 'API response data (JSON, text, or other formats)' },

View File

@@ -53,6 +53,28 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
visibility: 'user-only',
description: 'Request timeout in milliseconds (default: 300000 = 5 minutes)',
},
retries: {
type: 'number',
visibility: 'hidden',
description:
'Number of retry attempts for retryable failures (timeouts, 429, 5xx). Default: 0 (no retries).',
},
retryDelayMs: {
type: 'number',
visibility: 'hidden',
description: 'Initial retry delay in milliseconds (default: 500)',
},
retryMaxDelayMs: {
type: 'number',
visibility: 'hidden',
description: 'Maximum delay between retries in milliseconds (default: 30000)',
},
retryNonIdempotent: {
type: 'boolean',
visibility: 'hidden',
description:
'Allow retries for non-idempotent methods like POST/PATCH (may create duplicate requests).',
},
},
request: {
@@ -119,6 +141,14 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
return undefined
}) as (params: RequestParams) => Record<string, any> | string | FormData | undefined,
retry: {
enabled: true,
maxRetries: 0,
initialDelayMs: 500,
maxDelayMs: 30000,
retryIdempotentOnly: true,
},
},
transformResponse: async (response: Response) => {

View File

@@ -9,6 +9,10 @@ export interface RequestParams {
pathParams?: Record<string, string>
formData?: Record<string, string | Blob>
timeout?: number
retries?: number
retryDelayMs?: number
retryMaxDelayMs?: number
retryNonIdempotent?: boolean
}
export interface RequestResponse extends ToolResponse {

View File

@@ -958,4 +958,231 @@ describe('MCP Tool Execution', () => {
expect(result.error).toContain('Network error')
expect(result.timing).toBeDefined()
})
describe('Tool request retries', () => {
function makeJsonResponse(
status: number,
body: unknown,
extraHeaders?: Record<string, string>
): any {
const headers = new Headers({ 'content-type': 'application/json', ...(extraHeaders ?? {}) })
return {
ok: status >= 200 && status < 300,
status,
statusText: status >= 200 && status < 300 ? 'OK' : 'Error',
headers,
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
blob: () => Promise.resolve(new Blob()),
}
}
it('retries on 5xx responses for http_request', async () => {
global.fetch = Object.assign(
vi
.fn()
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 2,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(result.success).toBe(true)
expect((result.output as any).status).toBe(200)
})
it('does not retry when retries is not specified (default: 0)', async () => {
global.fetch = Object.assign(
vi.fn().mockResolvedValue(makeJsonResponse(500, { error: 'server error' })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result.success).toBe(false)
})
it('stops retrying after max attempts for http_request', async () => {
global.fetch = Object.assign(
vi.fn().mockResolvedValue(makeJsonResponse(502, { error: 'bad gateway' })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 2,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(3)
expect(result.success).toBe(false)
})
it('does not retry on 4xx responses for http_request', async () => {
global.fetch = Object.assign(
vi.fn().mockResolvedValue(makeJsonResponse(400, { error: 'bad request' })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 5,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result.success).toBe(false)
})
it('does not retry POST by default (non-idempotent)', async () => {
global.fetch = Object.assign(
vi
.fn()
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'POST',
retries: 2,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result.success).toBe(false)
})
it('retries POST when retryNonIdempotent is enabled', async () => {
global.fetch = Object.assign(
vi
.fn()
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'POST',
retries: 1,
retryNonIdempotent: true,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(result.success).toBe(true)
expect((result.output as any).status).toBe(200)
})
it('retries on timeout errors for http_request', async () => {
const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' })
global.fetch = Object.assign(
vi
.fn()
.mockRejectedValueOnce(abortError)
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 1,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(result.success).toBe(true)
})
it('skips retry when Retry-After header exceeds maxDelayMs', async () => {
global.fetch = Object.assign(
vi
.fn()
.mockResolvedValueOnce(
makeJsonResponse(429, { error: 'rate limited' }, { 'retry-after': '60' })
)
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 3,
retryMaxDelayMs: 5000,
})
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(result.success).toBe(false)
})
it('retries when Retry-After header is within maxDelayMs', async () => {
global.fetch = Object.assign(
vi
.fn()
.mockResolvedValueOnce(
makeJsonResponse(429, { error: 'rate limited' }, { 'retry-after': '1' })
)
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 2,
retryMaxDelayMs: 5000,
})
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(result.success).toBe(true)
})
it('retries on ETIMEDOUT errors for http_request', async () => {
const etimedoutError = Object.assign(new Error('connect ETIMEDOUT 10.0.0.1:443'), {
code: 'ETIMEDOUT',
})
global.fetch = Object.assign(
vi
.fn()
.mockRejectedValueOnce(etimedoutError)
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
{ preconnect: vi.fn() }
) as typeof fetch
const result = await executeTool('http_request', {
url: '/api/test',
method: 'GET',
retries: 1,
retryDelayMs: 0,
retryMaxDelayMs: 0,
})
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(result.success).toBe(true)
})
})
})

View File

@@ -14,7 +14,7 @@ import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
import type { ExecutionContext } from '@/executor/types'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
import type { OAuthTokenPayload, ToolConfig, ToolResponse } from '@/tools/types'
import type { OAuthTokenPayload, ToolConfig, ToolResponse, ToolRetryConfig } from '@/tools/types'
import {
formatRequestParams,
getTool,
@@ -610,6 +610,68 @@ async function addInternalAuthIfNeeded(
}
}
interface ResolvedRetryConfig {
maxRetries: number
initialDelayMs: number
maxDelayMs: number
}
function getRetryConfig(
retry: ToolRetryConfig | undefined,
params: Record<string, any>,
method: string
): ResolvedRetryConfig | null {
if (!retry?.enabled) return null
const isIdempotent = ['GET', 'HEAD', 'PUT', 'DELETE'].includes(method.toUpperCase())
if (retry.retryIdempotentOnly && !isIdempotent && !params.retryNonIdempotent) {
return null
}
const maxRetries = Math.min(10, Math.max(0, Number(params.retries) || retry.maxRetries || 0))
if (maxRetries === 0) return null
return {
maxRetries,
initialDelayMs: Number(params.retryDelayMs) || retry.initialDelayMs || 500,
maxDelayMs: Number(params.retryMaxDelayMs) || retry.maxDelayMs || 30000,
}
}
function isRetryableFailure(error: unknown, status?: number): boolean {
if (status === 429 || (status && status >= 500 && status <= 599)) return true
if (error instanceof Error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
return true
}
const msg = error.message.toLowerCase()
if (isBodySizeLimitError(msg)) return false
return msg.includes('timeout') || msg.includes('timed out')
}
return false
}
function calculateBackoff(attempt: number, initialDelayMs: number, maxDelayMs: number): number {
const base = Math.min(initialDelayMs * 2 ** attempt, maxDelayMs)
return Math.round(base / 2 + Math.random() * (base / 2))
}
function parseRetryAfterHeader(header: string | null): number {
if (!header) return 0
const trimmed = header.trim()
if (/^\d+$/.test(trimmed)) {
const seconds = Number.parseInt(trimmed, 10)
return seconds > 0 ? seconds * 1000 : 0
}
const date = new Date(trimmed)
if (!Number.isNaN(date.getTime())) {
const deltaMs = date.getTime() - Date.now()
return deltaMs > 0 ? deltaMs : 0
}
return 0
}
/**
* Execute a tool request directly
* Internal routes (/api/...) use regular fetch
@@ -691,59 +753,123 @@ async function executeToolRequest(
headersRecord[key] = value
})
let response: Response
const retryConfig = getRetryConfig(tool.request.retry, params, requestParams.method)
const maxAttempts = retryConfig ? 1 + retryConfig.maxRetries : 1
if (isInternalRoute) {
const controller = new AbortController()
const timeout = requestParams.timeout || DEFAULT_EXECUTION_TIMEOUT_MS
const timeoutId = setTimeout(() => controller.abort(), timeout)
let response: Response | undefined
let lastError: unknown
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const isLastAttempt = attempt === maxAttempts - 1
try {
response = await fetch(fullUrl, {
method: requestParams.method,
headers: headers,
body: requestParams.body,
signal: controller.signal,
})
} catch (error) {
// Convert AbortError to a timeout error message
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeout}ms`)
if (isInternalRoute) {
const controller = new AbortController()
const timeout = requestParams.timeout || DEFAULT_EXECUTION_TIMEOUT_MS
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
response = await fetch(fullUrl, {
method: requestParams.method,
headers: headers,
body: requestParams.body,
signal: controller.signal,
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timed out after ${timeout}ms`)
}
throw error
} finally {
clearTimeout(timeoutId)
}
} else {
const urlValidation = await validateUrlWithDNS(fullUrl, 'toolUrl')
if (!urlValidation.isValid) {
throw new Error(`Invalid tool URL: ${urlValidation.error}`)
}
const secureResponse = await secureFetchWithPinnedIP(fullUrl, urlValidation.resolvedIP!, {
method: requestParams.method,
headers: headersRecord,
body: requestParams.body ?? undefined,
timeout: requestParams.timeout,
})
const responseHeaders = new Headers(secureResponse.headers.toRecord())
const nullBodyStatuses = new Set([101, 204, 205, 304])
if (nullBodyStatuses.has(secureResponse.status)) {
response = new Response(null, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
} else {
const bodyBuffer = await secureResponse.arrayBuffer()
response = new Response(bodyBuffer, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
}
}
throw error
} finally {
clearTimeout(timeoutId)
}
} else {
const urlValidation = await validateUrlWithDNS(fullUrl, 'toolUrl')
if (!urlValidation.isValid) {
throw new Error(`Invalid tool URL: ${urlValidation.error}`)
} catch (error) {
lastError = error
if (!retryConfig || isLastAttempt || !isRetryableFailure(error)) {
throw error
}
const delayMs = calculateBackoff(
attempt,
retryConfig.initialDelayMs,
retryConfig.maxDelayMs
)
logger.warn(
`[${requestId}] Retrying ${toolId} after error (attempt ${attempt + 1}/${maxAttempts})`,
{ delayMs }
)
await new Promise((r) => setTimeout(r, delayMs))
continue
}
const secureResponse = await secureFetchWithPinnedIP(fullUrl, urlValidation.resolvedIP!, {
method: requestParams.method,
headers: headersRecord,
body: requestParams.body ?? undefined,
timeout: requestParams.timeout,
})
const responseHeaders = new Headers(secureResponse.headers.toRecord())
const nullBodyStatuses = new Set([101, 204, 205, 304])
if (nullBodyStatuses.has(secureResponse.status)) {
response = new Response(null, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
} else {
const bodyBuffer = await secureResponse.arrayBuffer()
response = new Response(bodyBuffer, {
status: secureResponse.status,
statusText: secureResponse.statusText,
headers: responseHeaders,
})
if (
retryConfig &&
!isLastAttempt &&
response &&
!response.ok &&
isRetryableFailure(null, response.status)
) {
const retryAfterMs = parseRetryAfterHeader(response.headers.get('retry-after'))
if (retryAfterMs > retryConfig.maxDelayMs) {
logger.warn(
`[${requestId}] Retry-After (${retryAfterMs}ms) exceeds maxDelayMs (${retryConfig.maxDelayMs}ms), skipping retry`
)
break
}
try {
await response.arrayBuffer()
} catch {
// Ignore errors when consuming body
}
const backoffMs = calculateBackoff(
attempt,
retryConfig.initialDelayMs,
retryConfig.maxDelayMs
)
const delayMs = Math.max(backoffMs, retryAfterMs)
logger.warn(
`[${requestId}] Retrying ${toolId} after HTTP ${response.status} (attempt ${attempt + 1}/${maxAttempts})`,
{ delayMs }
)
await new Promise((r) => setTimeout(r, delayMs))
continue
}
break
}
if (!response) {
throw lastError ?? new Error(`Request failed for ${toolId}`)
}
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text

View File

@@ -58,6 +58,14 @@ export interface OAuthConfig {
requiredScopes?: string[] // Specific scopes this tool needs (for granular scope validation)
}
export interface ToolRetryConfig {
enabled: boolean
maxRetries?: number
initialDelayMs?: number
maxDelayMs?: number
retryIdempotentOnly?: boolean
}
export interface ToolConfig<P = any, R = any> {
// Basic tool identification
id: string
@@ -115,6 +123,7 @@ export interface ToolConfig<P = any, R = any> {
method: HttpMethod | ((params: P) => HttpMethod)
headers: (params: P) => Record<string, string>
body?: (params: P) => Record<string, any> | string | FormData | undefined
retry?: ToolRetryConfig
}
// Post-processing (optional) - allows additional processing after the initial request