재시도와 멱등성
무엇이 재시도되고, 무엇이 의도적으로 재시도되지 않으며, 재시도된 발송이 왜 중복될 수 없는지.
발송
클라이언트는 모든 발송(emails.send, emails.send_batch, templates.send, broadcasts.send)에 Idempotency-Key를 붙이며, 이 키는 **호출**마다 한 번 oe-와 무작위 UUID로 생성되어 그 호출의 재시도에서 재사용됩니다. API는 무엇이든 발송하기 전에 그 키를 선점하므로, 재시도는 두 번째 메시지를 보내는 대신 원래 메시지를 그대로 재생합니다. 반면 의도적으로 send를 두 번 호출하면 두 번 발송됩니다. 둘은 서로 다른 의도이며, 앞으로도 다르게 남습니다.
직접 만든 idempotency_key:를 전달하면 그 보장을 프로세스 경계 너머까지 확장할 수 있습니다. 그러면 중간에 죽었다가 다시 실행된 작업이 발송을 반복하지 않고 재생합니다. 재생은 replayed가 true로 설정된, 현재 상태의 저장된 메시지로 응답합니다.
invoice_id = "inv_2026_09_4192" sent = client.emails.send( from: "[email protected]", to: "[email protected]", subject: "Your September invoice", text: "Invoice attached.", idempotency_key: "invoice:#{invoice_id}") puts sent[:id], sent[:replayed]키는 발송이 필요해진 원인에서 만드세요. 절대 시계에서 만들지 마세요. 같은 키를 다른 본문과 함께 재사용하면 조용히 재생되지 않고 422 idempotency_key_reuse로 거부됩니다. 키는 영문자, 숫자, _, ., :, -로 이루어진 1~255자이며, 그 밖의 값은 400 invalid_idempotency_key가 됩니다.
그 밖의 모든 것
모든 GET은 재시도됩니다. 쓰기는 두 번째 동일 요청이 첫 번째와 다른 의미를 가질 수 없는 경우에만 재시도되며, 발송은 멱등성 키가 반복을 재생으로 바꾸기 때문에 여기에 해당합니다.
| 호출 | 재시도 여부 | 이유 |
|---|---|---|
| 모든 GET | 예 | 바뀌는 것이 없습니다. |
| emails.send, emails.send_batch, templates.send, broadcasts.send | 예 | 멱등성 키가 반복을 재생으로 바꿉니다. |
| emails.cancel, emails.reschedule, broadcasts.cancel, forms.pause, forms.resume, threads.restore | 예 | 명시된 상태를 그대로 설정하는 순수한 작업입니다. |
| threads.update, threads.trash | 예 | 레이블 설정입니다. 두 번 적용해도 한 번 적용한 것과 같습니다. |
| threads.snooze, threads.unsnooze | 예 | 깨어날 시각이 본문에 담겨 있으며, 도착 시각에서 계산되지 않습니다. |
| emails.update, labels.update, webhooks.update, settings.update, roles.update, members.update, domains.update, domains.update_address, domains.update_address_forward, contacts.update, audiences.update, keys.update, forms.update, branding.update, threads.update_note, chats.rename, account.set_email_notification, account.set_push_muted, app_host.set, workspaces.set_active | 예 | 명시된 필드를 그대로 설정하는 순수한 작업입니다. |
| members.grant_address, members.grant_domain, rules.reorder, threads.reorder_notes | 예 | 권한 부여는 upsert이며, 순서는 전체가 명시됩니다. |
| templates.publish, forms.publish, imports.start | 예 | 이미 게시된 것을 다시 게시하거나 이미 시작된 가져오기를 다시 시작하면 바뀌지 않고 그대로 반환됩니다. |
| templates.preview, templates.render, broadcasts.preview, rules.test | 예 | 렌더링하거나 개수를 세거나 평가할 뿐, 아무것도 기록하지 않습니다. |
| domains.verify, app_host.verify, senders.research | 예 | 반복해서 확인해도 바뀌는 것은 확인한 시각뿐입니다. |
| contacts.save, contacts.set_audiences, contacts.remove_photo, contacts.block, contacts.unblock, contacts.delete_many, keys.revoke, files.revoke_link, files.revoke_all_links, app_host.delete, account.remove_photo, branding.remove_image, domains.remove_logo, domains.remove_logo_certificate, domains.remove_address_photo, account.accept_invitation, account.decline_invitation, forms.approve_submission, subscriptions.move | 예 | 각각 최종 결과를 지정하므로, 두 번째 호출은 첫 번째 호출이 남긴 상태를 그대로 남깁니다. |
| audiences.add_contact, audiences.add_contacts, audiences.remove_contacts, audiences.import_contacts, suppressions.add, domains.create_address | 예 | 반복 호출은 첫 호출의 작업이 이미 끝난 것을 확인하고, 두 번 하지 않고 그 사실을 보고합니다. |
| contacts.set_photo, account.set_photo, branding.upload_image, domains.set_logo, domains.set_logo_certificate, domains.set_address_photo, imports.upload_chunk | 예 | 다시 보낸 바이트가 첫 시도에서 저장된 것을 대체합니다. |
| drafts.create, labels.create, webhooks.create, templates.create, rules.create, roles.create, temp_mail.create, files.upload | 아니요 | 재시도하면 객체가 두 개 남습니다. |
| drafts.update | 아니요 | 보낸 id를 재사용하지 말고, 매 쓰기의 결과에서 id를 읽으십시오. |
| drafts.delete, labels.delete, webhooks.delete, templates.delete, rules.delete, roles.delete, members.remove, members.revoke_address, temp_mail.delete, temp_mail.delete_message | 아니요 | 응답을 잃어버린 뒤 재시도하면 이미 성공한 작업을 실패로 보고하게 됩니다. |
| webhooks.rotate_secret | 아니요 | 두 번째 회전은 첫 번째 시도가 돌려준 시크릿을 무효화합니다. |
| webhooks.test | 아니요 | 두 번째 테스트 전달이 발송됩니다. |
| webhooks.replay_delivery | 아니요 | 이벤트를 수신 측에 한 번 더 보내게 됩니다. |
| emails.translate, emails.compose, emails.rewrite, emails.suggest_subject | 아니요 | 각각 모델 호출 비용이 들므로, 응답 없는 요청 뒤에 재시도하면 같은 답을 두 번 사는 셈이 됩니다. |
| security.begin_step_up, security.verify_step_up | 아니요 | 재시도하면 두 번째 이메일이 가거나 코드 시도 횟수를 한 번 더 쓸 수 있습니다. |
| GET이 아닌 그 밖의 모든 호출 | 아니요 | 한 번만 전송되며, 실패는 반복되지 않고 보고됩니다. |
백오프
- 클라이언트의
max_retries:로 제한되며, 기본값은 두 번의 추가 시도입니다.max_retries: 0이면 재시도가 꺼집니다. - 네트워크 실패나
408,500,502,503,504에서만 재시도합니다.429는Retry-After가 함께 올 때만 재시도하는데 이 API는 그 헤더를 보내지 않으므로, 요청 제한은 곧바로 예외를 발생시킵니다. 그 밖의 상태 코드도 즉시 예외를 발생시킵니다. - 0.5초에서 8초까지 지수적으로 늘어나며 지터가 적용됩니다: 각 대기는 그 상한의 절반과 상한 사이의 무작위 값이므로, 여러 클라이언트가 복구 시점에 다시 동기화되지 않습니다.
Retry-After가 delay-seconds든 HTTP-date든 그 값에 맞춰 대기합니다. 서버가 대기 시간을 지정하면 클라이언트는 백오프 대신 정확히 그만큼 기다립니다.- 서버가 1분보다 긴 대기를 요구하면 잠시 쉬라는 뜻이 아니라 멈추라는 뜻으로 해석하여,
retry_after_seconds를 담은 오류를 발생시킵니다. 요청받은 것보다 일찍 돌아오는 것은 그 지시를 따르는 것이 아니기 때문입니다. - 타임아웃도 다른 네트워크 장애와 같으므로, 반복해도 안전한 호출은 타임아웃 뒤에 다시 시도되며,
timeout:은 시도마다 새로 적용됩니다. - 대기는 호출 내부의
sleep이므로 호출한 스레드도 함께 기다리며, 호출은 마지막 시도가 끝난 뒤에야 반환하거나 예외를 발생시킵니다.