Skip to content

Commit 43c0f5b

Browse files
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>
1 parent ff01825 commit 43c0f5b

File tree

7 files changed

+494
-50
lines changed

7 files changed

+494
-50
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: '0',
97+
description:
98+
'Number of retry attempts for timeouts, 429 responses, and 5xx errors (default: 0, no retries)',
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: 30 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: 'hidden',
59+
description:
60+
'Number of retry attempts for retryable failures (timeouts, 429, 5xx). Default: 0 (no retries).',
61+
},
62+
retryDelayMs: {
63+
type: 'number',
64+
visibility: 'hidden',
65+
description: 'Initial retry delay in milliseconds (default: 500)',
66+
},
67+
retryMaxDelayMs: {
68+
type: 'number',
69+
visibility: 'hidden',
70+
description: 'Maximum delay between retries in milliseconds (default: 30000)',
71+
},
72+
retryNonIdempotent: {
73+
type: 'boolean',
74+
visibility: 'hidden',
75+
description:
76+
'Allow retries for non-idempotent methods like POST/PATCH (may create duplicate requests).',
77+
},
5678
},
5779

5880
request: {
@@ -119,6 +141,14 @@ 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: 0,
148+
initialDelayMs: 500,
149+
maxDelayMs: 30000,
150+
retryIdempotentOnly: true,
151+
},
122152
},
123153

124154
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: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,4 +958,231 @@ 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('does not retry when retries is not specified (default: 0)', async () => {
1004+
global.fetch = Object.assign(
1005+
vi.fn().mockResolvedValue(makeJsonResponse(500, { error: 'server error' })),
1006+
{ preconnect: vi.fn() }
1007+
) as typeof fetch
1008+
1009+
const result = await executeTool('http_request', {
1010+
url: '/api/test',
1011+
method: 'GET',
1012+
})
1013+
1014+
expect(global.fetch).toHaveBeenCalledTimes(1)
1015+
expect(result.success).toBe(false)
1016+
})
1017+
1018+
it('stops retrying after max attempts for http_request', async () => {
1019+
global.fetch = Object.assign(
1020+
vi.fn().mockResolvedValue(makeJsonResponse(502, { error: 'bad gateway' })),
1021+
{ preconnect: vi.fn() }
1022+
) as typeof fetch
1023+
1024+
const result = await executeTool('http_request', {
1025+
url: '/api/test',
1026+
method: 'GET',
1027+
retries: 2,
1028+
retryDelayMs: 0,
1029+
retryMaxDelayMs: 0,
1030+
})
1031+
1032+
expect(global.fetch).toHaveBeenCalledTimes(3)
1033+
expect(result.success).toBe(false)
1034+
})
1035+
1036+
it('does not retry on 4xx responses for http_request', async () => {
1037+
global.fetch = Object.assign(
1038+
vi.fn().mockResolvedValue(makeJsonResponse(400, { error: 'bad request' })),
1039+
{ preconnect: vi.fn() }
1040+
) as typeof fetch
1041+
1042+
const result = await executeTool('http_request', {
1043+
url: '/api/test',
1044+
method: 'GET',
1045+
retries: 5,
1046+
retryDelayMs: 0,
1047+
retryMaxDelayMs: 0,
1048+
})
1049+
1050+
expect(global.fetch).toHaveBeenCalledTimes(1)
1051+
expect(result.success).toBe(false)
1052+
})
1053+
1054+
it('does not retry POST by default (non-idempotent)', async () => {
1055+
global.fetch = Object.assign(
1056+
vi
1057+
.fn()
1058+
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
1059+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1060+
{ preconnect: vi.fn() }
1061+
) as typeof fetch
1062+
1063+
const result = await executeTool('http_request', {
1064+
url: '/api/test',
1065+
method: 'POST',
1066+
retries: 2,
1067+
retryDelayMs: 0,
1068+
retryMaxDelayMs: 0,
1069+
})
1070+
1071+
expect(global.fetch).toHaveBeenCalledTimes(1)
1072+
expect(result.success).toBe(false)
1073+
})
1074+
1075+
it('retries POST when retryNonIdempotent is enabled', async () => {
1076+
global.fetch = Object.assign(
1077+
vi
1078+
.fn()
1079+
.mockResolvedValueOnce(makeJsonResponse(500, { error: 'nope' }))
1080+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1081+
{ preconnect: vi.fn() }
1082+
) as typeof fetch
1083+
1084+
const result = await executeTool('http_request', {
1085+
url: '/api/test',
1086+
method: 'POST',
1087+
retries: 1,
1088+
retryNonIdempotent: true,
1089+
retryDelayMs: 0,
1090+
retryMaxDelayMs: 0,
1091+
})
1092+
1093+
expect(global.fetch).toHaveBeenCalledTimes(2)
1094+
expect(result.success).toBe(true)
1095+
expect((result.output as any).status).toBe(200)
1096+
})
1097+
1098+
it('retries on timeout errors for http_request', async () => {
1099+
const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' })
1100+
global.fetch = Object.assign(
1101+
vi
1102+
.fn()
1103+
.mockRejectedValueOnce(abortError)
1104+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1105+
{ preconnect: vi.fn() }
1106+
) as typeof fetch
1107+
1108+
const result = await executeTool('http_request', {
1109+
url: '/api/test',
1110+
method: 'GET',
1111+
retries: 1,
1112+
retryDelayMs: 0,
1113+
retryMaxDelayMs: 0,
1114+
})
1115+
1116+
expect(global.fetch).toHaveBeenCalledTimes(2)
1117+
expect(result.success).toBe(true)
1118+
})
1119+
1120+
it('skips retry when Retry-After header exceeds maxDelayMs', async () => {
1121+
global.fetch = Object.assign(
1122+
vi
1123+
.fn()
1124+
.mockResolvedValueOnce(
1125+
makeJsonResponse(429, { error: 'rate limited' }, { 'retry-after': '60' })
1126+
)
1127+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1128+
{ preconnect: vi.fn() }
1129+
) as typeof fetch
1130+
1131+
const result = await executeTool('http_request', {
1132+
url: '/api/test',
1133+
method: 'GET',
1134+
retries: 3,
1135+
retryMaxDelayMs: 5000,
1136+
})
1137+
1138+
expect(global.fetch).toHaveBeenCalledTimes(1)
1139+
expect(result.success).toBe(false)
1140+
})
1141+
1142+
it('retries when Retry-After header is within maxDelayMs', async () => {
1143+
global.fetch = Object.assign(
1144+
vi
1145+
.fn()
1146+
.mockResolvedValueOnce(
1147+
makeJsonResponse(429, { error: 'rate limited' }, { 'retry-after': '1' })
1148+
)
1149+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1150+
{ preconnect: vi.fn() }
1151+
) as typeof fetch
1152+
1153+
const result = await executeTool('http_request', {
1154+
url: '/api/test',
1155+
method: 'GET',
1156+
retries: 2,
1157+
retryMaxDelayMs: 5000,
1158+
})
1159+
1160+
expect(global.fetch).toHaveBeenCalledTimes(2)
1161+
expect(result.success).toBe(true)
1162+
})
1163+
1164+
it('retries on ETIMEDOUT errors for http_request', async () => {
1165+
const etimedoutError = Object.assign(new Error('connect ETIMEDOUT 10.0.0.1:443'), {
1166+
code: 'ETIMEDOUT',
1167+
})
1168+
global.fetch = Object.assign(
1169+
vi
1170+
.fn()
1171+
.mockRejectedValueOnce(etimedoutError)
1172+
.mockResolvedValueOnce(makeJsonResponse(200, { ok: true })),
1173+
{ preconnect: vi.fn() }
1174+
) as typeof fetch
1175+
1176+
const result = await executeTool('http_request', {
1177+
url: '/api/test',
1178+
method: 'GET',
1179+
retries: 1,
1180+
retryDelayMs: 0,
1181+
retryMaxDelayMs: 0,
1182+
})
1183+
1184+
expect(global.fetch).toHaveBeenCalledTimes(2)
1185+
expect(result.success).toBe(true)
1186+
})
1187+
})
9611188
})

0 commit comments

Comments
 (0)