diff --git a/README.md b/README.md index d44284fe..4dea714a 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,10 @@ organization = WorkOS.client.organizations.get_organization( ) ``` -`Idempotency-Key` is only sent on `POST`, `PUT`, and `PATCH` requests — either when you provide `request_options[:idempotency_key]`, or auto-generated when the SDK retries one of those requests after a transient failure. It is never attached to `GET` or `DELETE` requests. +`Idempotency-Key` is only sent on `POST`, `PUT`, and `PATCH` requests, and never on `GET` or `DELETE`. Every `POST` carries one: the key you provide in `request_options[:idempotency_key]`, or one the SDK generates before the first attempt and reuses on every retry. `PUT` and `PATCH` requests carry one when you provide it, or when the SDK retries them after a transient failure. > [!NOTE] -> The WorkOS API currently honors `Idempotency-Key` only on the [Create Audit Log Event](https://workos.com/docs/reference/audit-logs/event) endpoint (`audit_logs.create_event`). Other endpoints accept the header but do not deduplicate requests, so a retried mutation elsewhere can still create a duplicate. +> The WorkOS API currently honors `Idempotency-Key` only on the [Create Audit Log Event](https://workos.com/docs/reference/audit-logs/event) endpoint (`audit_logs.create_event`) and on Pipes explicit adds (`pipes.create_data_integration_api_key`, `pipes.create_data_integration_client_credential`, and `pipes.create_user_connected_account` / `pipes.create_organization_connected_account` with `connection_intent: "add"`). Other endpoints accept the header but do not deduplicate requests, so a retried mutation elsewhere can still create a duplicate. ## Usage Examples diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index 4b4af432..394d1ffd 100644 --- a/lib/workos/base_client.rb +++ b/lib/workos/base_client.rb @@ -73,6 +73,9 @@ def post_request(path:, auth: false, body: {}, params: {}, request_options: nil) req.body = body.nil? ? "" : body.to_json req["Content-Type"] = "application/json" inject_idempotency_key(req, request_options) + # Keyed before the first attempt, so a retry after a lost response + # carries the key the server already saw. + req["Idempotency-Key"] ||= SecureRandom.uuid req end diff --git a/test/workos/test_base_client.rb b/test/workos/test_base_client.rb index d8937c04..425b51b5 100644 --- a/test/workos/test_base_client.rb +++ b/test/workos/test_base_client.rb @@ -164,15 +164,16 @@ def test_delete_request_serializes_explicit_nil_as_json_null assert_equal({"external_id" => nil}, JSON.parse(request.body)) end - def test_retry_path_generates_idempotency_key_for_mutating_requests + def test_post_sends_one_idempotency_key_on_the_first_attempt_and_its_retries stub_request(:post, "https://api.workos.com/widgets") .to_return({status: 500, body: '{"message":"retry"}'}, {status: 200, body: "{}"}) @client.singleton_class.define_method(:sleep) { |_duration| nil } @client.request(method: :post, path: "/widgets", body: {name: "widget"}) - assert_requested(:post, "https://api.workos.com/widgets", times: 2) - assert_requested(:post, "https://api.workos.com/widgets", headers: {"Idempotency-Key" => /.+/}, times: 1) + assert_requested(:post, "https://api.workos.com/widgets", headers: {"Idempotency-Key" => /.+/}, times: 2) + keys = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.map { |signature| signature.headers["Idempotency-Key"] } + assert_equal 1, keys.uniq.size end def test_409_idempotency_error_raises_specific_error