Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apps/docs/content/docs/en/blocks/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 block’s **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

Expand Down
42 changes: 42 additions & 0 deletions apps/sim/blocks/blocks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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)' },
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/tools/http/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/tools/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
227 changes: 227 additions & 0 deletions apps/sim/tools/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
Loading