Skip to content

Commit 543ddfb

Browse files
committed
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
1 parent 0d86ea0 commit 543ddfb

File tree

7 files changed

+620
-49
lines changed

7 files changed

+620
-49
lines changed

apps/docs/content/docs/en/blocks/api.mdx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,17 @@ const apiUrl = `https://api.example.com/users/${userId}/profile`;
9595

9696
### Request Retries
9797

98-
The API block automatically handles:
99-
- Network timeouts with exponential backoff
100-
- Rate limit responses (429 status codes)
101-
- Server errors (5xx status codes) with retry logic
102-
- Connection failures with reconnection attempts
98+
The API block supports **configurable retries** (see the block’s **Advanced** settings):
99+
100+
- **Retries**: Number of retry attempts (additional tries after the first request)
101+
- **Retry delay (ms)**: Initial delay before retrying (uses exponential backoff)
102+
- **Max retry delay (ms)**: Maximum delay between retries
103+
- **Retry non-idempotent methods**: Allow retries for **POST/PATCH** (may create duplicate requests)
104+
105+
Retries are attempted for:
106+
107+
- Network/connection failures and timeouts (with exponential backoff)
108+
- Rate limits (**429**) and server errors (**5xx**)
103109

104110
### Response Validation
105111

apps/sim/blocks/blocks/api.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,38 @@ Example:
8989
'Request timeout in milliseconds (default: 300000 = 5 minutes, max: 600000 = 10 minutes)',
9090
mode: 'advanced',
9191
},
92+
{
93+
id: 'retries',
94+
title: 'Retries',
95+
type: 'short-input',
96+
placeholder: '2',
97+
description:
98+
'Number of retry attempts for timeouts, 429 responses, and 5xx errors (default: 2 for GET/PUT/DELETE/HEAD)',
99+
mode: 'advanced',
100+
},
101+
{
102+
id: 'retryDelayMs',
103+
title: 'Retry delay (ms)',
104+
type: 'short-input',
105+
placeholder: '500',
106+
description: 'Initial retry delay in milliseconds (exponential backoff)',
107+
mode: 'advanced',
108+
},
109+
{
110+
id: 'retryMaxDelayMs',
111+
title: 'Max retry delay (ms)',
112+
type: 'short-input',
113+
placeholder: '30000',
114+
description: 'Maximum delay between retries in milliseconds',
115+
mode: 'advanced',
116+
},
117+
{
118+
id: 'retryNonIdempotent',
119+
title: 'Retry non-idempotent methods',
120+
type: 'switch',
121+
description: 'Allow retries for POST/PATCH requests (may create duplicate requests)',
122+
mode: 'advanced',
123+
},
92124
],
93125
tools: {
94126
access: ['http_request'],
@@ -100,6 +132,16 @@ Example:
100132
body: { type: 'json', description: 'Request body data' },
101133
params: { type: 'json', description: 'URL query parameters' },
102134
timeout: { type: 'number', description: 'Request timeout in milliseconds' },
135+
retries: { type: 'number', description: 'Number of retry attempts for retryable failures' },
136+
retryDelayMs: { type: 'number', description: 'Initial retry delay in milliseconds' },
137+
retryMaxDelayMs: {
138+
type: 'number',
139+
description: 'Maximum delay between retries in milliseconds',
140+
},
141+
retryNonIdempotent: {
142+
type: 'boolean',
143+
description: 'Allow retries for non-idempotent methods like POST/PATCH',
144+
},
103145
},
104146
outputs: {
105147
data: { type: 'json', description: 'API response data (JSON, text, or other formats)' },

apps/sim/tools/http/request.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,28 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
5353
visibility: 'user-only',
5454
description: 'Request timeout in milliseconds (default: 300000 = 5 minutes)',
5555
},
56+
retries: {
57+
type: 'number',
58+
visibility: 'user-only',
59+
description:
60+
'Number of retry attempts for retryable failures (timeouts, 429, 5xx). Defaults to 2 for idempotent methods (GET/PUT/DELETE/HEAD) and 0 otherwise.',
61+
},
62+
retryDelayMs: {
63+
type: 'number',
64+
visibility: 'user-only',
65+
description: 'Initial retry delay in milliseconds (default: 500)',
66+
},
67+
retryMaxDelayMs: {
68+
type: 'number',
69+
visibility: 'user-only',
70+
description: 'Maximum delay between retries in milliseconds (default: 30000)',
71+
},
72+
retryNonIdempotent: {
73+
type: 'boolean',
74+
visibility: 'user-only',
75+
description:
76+
'Allow retries for non-idempotent methods like POST/PATCH (may create duplicate requests).',
77+
},
5678
},
5779

5880
request: {
@@ -119,6 +141,26 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
119141

120142
return undefined
121143
}) as (params: RequestParams) => Record<string, any> | string | FormData | undefined,
144+
145+
retry: {
146+
enabled: true,
147+
maxRetries: 2,
148+
maxRetriesLimit: 10,
149+
initialDelayMs: 500,
150+
maxDelayMs: 30000,
151+
retryOnStatusCodes: [429],
152+
retryOnStatusRanges: [{ min: 500, max: 599 }],
153+
retryOnTimeout: true,
154+
retryOnNetworkError: true,
155+
respectRetryAfter: true,
156+
retryableMethods: ['GET', 'HEAD', 'PUT', 'DELETE'],
157+
paramOverrides: {
158+
retries: 'retries',
159+
initialDelayMs: 'retryDelayMs',
160+
maxDelayMs: 'retryMaxDelayMs',
161+
nonIdempotent: 'retryNonIdempotent',
162+
},
163+
},
122164
},
123165

124166
transformResponse: async (response: Response) => {

apps/sim/tools/http/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export interface RequestParams {
99
pathParams?: Record<string, string>
1010
formData?: Record<string, string | Blob>
1111
timeout?: number
12+
retries?: number
13+
retryDelayMs?: number
14+
retryMaxDelayMs?: number
15+
retryNonIdempotent?: boolean
1216
}
1317

1418
export interface RequestResponse extends ToolResponse {

apps/sim/tools/index.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,4 +958,148 @@ describe('MCP Tool Execution', () => {
958958
expect(result.error).toContain('Network error')
959959
expect(result.timing).toBeDefined()
960960
})
961+
962+
describe('Tool request retries', () => {
963+
function makeJsonResponse(
964+
status: number,
965+
body: unknown,
966+
extraHeaders?: Record<string, string>
967+
): any {
968+
const headers = new Headers({ 'content-type': 'application/json', ...(extraHeaders ?? {}) })
969+
return {
970+
ok: status >= 200 && status < 300,
971+
status,
972+
statusText: status >= 200 && status < 300 ? 'OK' : 'Error',
973+
headers,
974+
json: () => Promise.resolve(body),
975+
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
976+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
977+
blob: () => Promise.resolve(new Blob()),
978+
}
979+
}
980+
981+
it('retries on 5xx responses for http_request', async () => {
982+
global.fetch = Object.assign(
983+
vi
984+
.fn()
985+
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
986+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
987+
{ preconnect: vi.fn() }
988+
) as typeof fetch
989+
990+
const result = await executeTool('http_request', {
991+
url: '/api/test',
992+
method: 'GET',
993+
retries: 2,
994+
retryDelayMs: 0,
995+
retryMaxDelayMs: 0,
996+
})
997+
998+
expect(global.fetch).toHaveBeenCalledTimes(2)
999+
expect(result.success).toBe(true)
1000+
expect((result.output as any).status).toBe(200)
1001+
})
1002+
1003+
it('stops retrying after max attempts for http_request', async () => {
1004+
global.fetch = Object.assign(
1005+
vi.fn().mockResolvedValue(makeJsonResponse(502, { error: 'bad gateway' })),
1006+
{ preconnect: vi.fn() }
1007+
) as typeof fetch
1008+
1009+
const result = await executeTool('http_request', {
1010+
url: '/api/test',
1011+
method: 'GET',
1012+
retries: 2,
1013+
retryDelayMs: 0,
1014+
retryMaxDelayMs: 0,
1015+
})
1016+
1017+
expect(global.fetch).toHaveBeenCalledTimes(3)
1018+
expect(result.success).toBe(false)
1019+
})
1020+
1021+
it('does not retry on 4xx responses for http_request', async () => {
1022+
global.fetch = Object.assign(
1023+
vi.fn().mockResolvedValue(makeJsonResponse(400, { error: 'bad request' })),
1024+
{ preconnect: vi.fn() }
1025+
) as typeof fetch
1026+
1027+
const result = await executeTool('http_request', {
1028+
url: '/api/test',
1029+
method: 'GET',
1030+
retries: 5,
1031+
retryDelayMs: 0,
1032+
retryMaxDelayMs: 0,
1033+
})
1034+
1035+
expect(global.fetch).toHaveBeenCalledTimes(1)
1036+
expect(result.success).toBe(false)
1037+
})
1038+
1039+
it('does not retry POST by default (non-idempotent)', async () => {
1040+
global.fetch = Object.assign(
1041+
vi
1042+
.fn()
1043+
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
1044+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1045+
{ preconnect: vi.fn() }
1046+
) as typeof fetch
1047+
1048+
const result = await executeTool('http_request', {
1049+
url: '/api/test',
1050+
method: 'POST',
1051+
retries: 2,
1052+
retryDelayMs: 0,
1053+
retryMaxDelayMs: 0,
1054+
})
1055+
1056+
expect(global.fetch).toHaveBeenCalledTimes(1)
1057+
expect(result.success).toBe(false)
1058+
})
1059+
1060+
it('retries POST when retryNonIdempotent is enabled', async () => {
1061+
global.fetch = Object.assign(
1062+
vi
1063+
.fn()
1064+
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
1065+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1066+
{ preconnect: vi.fn() }
1067+
) as typeof fetch
1068+
1069+
const result = await executeTool('http_request', {
1070+
url: '/api/test',
1071+
method: 'POST',
1072+
retries: 1,
1073+
retryNonIdempotent: true,
1074+
retryDelayMs: 0,
1075+
retryMaxDelayMs: 0,
1076+
})
1077+
1078+
expect(global.fetch).toHaveBeenCalledTimes(2)
1079+
expect(result.success).toBe(true)
1080+
expect((result.output as any).status).toBe(200)
1081+
})
1082+
1083+
it('retries on timeout errors for http_request', async () => {
1084+
const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' })
1085+
global.fetch = Object.assign(
1086+
vi
1087+
.fn()
1088+
.mockRejectedValueOnce(abortError)
1089+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1090+
{ preconnect: vi.fn() }
1091+
) as typeof fetch
1092+
1093+
const result = await executeTool('http_request', {
1094+
url: '/api/test',
1095+
method: 'GET',
1096+
retries: 1,
1097+
retryDelayMs: 0,
1098+
retryMaxDelayMs: 0,
1099+
})
1100+
1101+
expect(global.fetch).toHaveBeenCalledTimes(2)
1102+
expect(result.success).toBe(true)
1103+
})
1104+
})
9611105
})

0 commit comments

Comments
 (0)