Skip to content

Commit 2564def

Browse files
committed
fix(outlook): make retried event creates duplicate-safe via transactionId
The retry config opts POSTs in via retryIdempotentOnly:false, but the executor's isRetryableFailure covers 429 AND 500-599 — not just the throttle the comment justified. A 5xx returned after Graph had already committed a create would be retried and produce a duplicate calendar event. create_event now sends a transactionId, which Graph documents for exactly this: it discards a repeat POST carrying an id it has already seen. The request body is built once per execution (formatRequestParams runs before the attempt loop), so the id is stable across retries of a call and unique between calls. The retry comment now describes what actually retries and why each non-idempotent method is safe: PATCH replays the same partial body as a no-op, and respond is state-idempotent though a post-commit retry can send the organizer a second notification — accepted deliberately, since Graph exposes no transactionId for accept/decline and failing outright under throttling is worse.
1 parent 6b6b655 commit 2564def

3 files changed

Lines changed: 44 additions & 5 deletions

File tree

apps/sim/tools/outlook/calendar-tools.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,25 @@ describe('outlook calendar tools', () => {
303303
expect(withComment).toEqual({ sendResponse: true, comment: 'See you there' })
304304
})
305305

306+
it('sends a stable transactionId so a retried create cannot duplicate the event', () => {
307+
const body = outlookCalendarCreateEventTool.request.body as (
308+
p: unknown
309+
) => Record<string, unknown>
310+
const params = {
311+
accessToken: 't',
312+
subject: 'Sync',
313+
startDateTime: '2025-06-03T10:00:00Z',
314+
endDateTime: '2025-06-03T11:00:00Z',
315+
}
316+
const first = body(params)
317+
expect(typeof first.transactionId).toBe('string')
318+
expect(first.transactionId).toBeTruthy()
319+
320+
// Distinct executions must not share an id, or Graph would discard a genuine
321+
// second event as a duplicate. (Retries reuse one body, built once per execution.)
322+
expect(body(params).transactionId).not.toBe(first.transactionId)
323+
})
324+
306325
it('enables retry with backoff on every calendar tool (429/mailbox concurrency)', () => {
307326
for (const tool of tools) {
308327
expect(tool.request.retry?.enabled).toBe(true)

apps/sim/tools/outlook/calendar-utils.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,20 @@ import type { ToolRetryConfig } from '@/tools/types'
1212
*
1313
* Microsoft Graph throttles a single mailbox at roughly four concurrent requests and
1414
* returns 429 with a `Retry-After` header when calendar operations fan out (e.g. many
15-
* parallel event creates in a workflow). The tool executor retries 429/5xx with backoff
16-
* and honors `Retry-After`. `retryIdempotentOnly` is `false` so create/update/respond
17-
* (POST/PATCH) also retry — a 429 is a pre-processing throttle, so retrying does not
18-
* duplicate the event.
15+
* parallel event creates in a workflow). `retryIdempotentOnly` is `false` so
16+
* create/update/respond (POST/PATCH) retry too, rather than failing the whole workflow
17+
* on a throttle.
18+
*
19+
* The executor retries **429 and 5xx** (`isRetryableFailure`), not 429 alone, so each
20+
* non-idempotent method has to be safe against a retry that follows an already-committed
21+
* write:
22+
* - `create_event` (POST) sends a per-execution `transactionId`; Graph uses it to discard
23+
* the duplicate POST, so a 5xx-after-commit cannot create a second event.
24+
* - `update_event` (PATCH) resends the same partial body, so replaying it is a no-op.
25+
* - `respond` (POST accept/decline) is state-idempotent — the resulting `responseStatus`
26+
* is identical — but a retry after a committed write can send the organizer a second
27+
* notification email. Accepted: a duplicate notification is less harmful than failing
28+
* the response outright under throttling, and Graph exposes no transactionId for it.
1929
*/
2030
export const CALENDAR_RETRY: ToolRetryConfig = {
2131
enabled: true,

apps/sim/tools/outlook/calendar_create_event.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { generateId } from '@sim/utils/id'
12
import { ErrorExtractorId } from '@/tools/error-extractors'
23
import {
34
buildAllDayRange,
@@ -135,7 +136,16 @@ export const outlookCalendarCreateEventTool: ToolConfig<
135136
// date-only input for all-day events.
136137
const bothBoundsDateOnly = isDateOnly(params.startDateTime) && isDateOnly(params.endDateTime)
137138
const isAllDay = toBool(params.isAllDay) || bothBoundsDateOnly
138-
const event: Record<string, unknown> = { subject: params.subject }
139+
const event: Record<string, unknown> = {
140+
subject: params.subject,
141+
// Graph de-duplicates create-event POSTs that repeat a transactionId, which is
142+
// exactly the retry case: the executor retries 5xx as well as 429, so a failure
143+
// returned after Graph already committed the event would otherwise create a
144+
// duplicate. The request body is built once per execution and reused across
145+
// attempts, so this id is stable for all retries of this call and unique across
146+
// calls. https://learn.microsoft.com/en-us/graph/api/resources/event
147+
transactionId: generateId(),
148+
}
139149

140150
if (isAllDay) {
141151
// All-day events need midnight bounds with an exclusive end day.

0 commit comments

Comments
 (0)