Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.4
3.1.4
7 changes: 6 additions & 1 deletion lib/workos/audit_logs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'net/http'
require 'uri'
require 'securerandom'

module WorkOS
# The Audit Logs module provides convenience methods for working with the
Expand All @@ -18,6 +19,9 @@ class << self
#
# @return [nil]
def create_event(organization:, event:, idempotency_key: nil)
# Auto-generate idempotency key if not provided
idempotency_key = SecureRandom.uuid if idempotency_key.nil?

request = post_request(
path: '/audit_logs/events',
auth: true,
Expand All @@ -28,7 +32,8 @@ def create_event(organization:, event:, idempotency_key: nil)
},
)

execute_request(request: request)
# Explicitely setting to 3 retries for the audit log event creation request
execute_request(request: request, retries: WorkOS.config.audit_log_max_retries)
end

# Create an Export of Audit Log Events.
Expand Down
77 changes: 67 additions & 10 deletions lib/workos/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,44 @@ def client
end
end

def execute_request(request:)
# rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
def execute_request(request:, retries: nil)
retries = retries.nil? ? WorkOS.config.max_retries : retries
attempt = 0
http_client = client

begin
response = client.request(request)
response = http_client.request(request)
http_status = response.code.to_i

if http_status >= 400
attempt += 1
if retryable_error?(http_status) && attempt <= retries
delay = calculate_retry_delay(attempt, response)
sleep(delay)
raise RetryableError.new(http_status: http_status)
else
handle_error_response(response: response)
end
end

response
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
raise TimeoutError.new(
message: 'API Timeout Error',
)
if attempt < retries
attempt += 1
delay = calculate_backoff(attempt)
sleep(delay)
retry
else
raise TimeoutError.new(
message: 'API Timeout Error',
)
end
rescue RetryableError
retry
end

http_status = response.code.to_i
handle_error_response(response: response) if http_status >= 400

response
end
# rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity

def get_request(path:, auth: false, params: {}, access_token: nil)
uri = URI(path)
Expand Down Expand Up @@ -123,6 +147,13 @@ def handle_error_response(response:)
http_status: http_status,
request_id: response['x-request-id'],
)
when 408
raise TimeoutError.new(
message: json['message'],
http_status: http_status,
request_id: response['x-request-id'],
retry_after: response['Retry-After'],
)
when 422
message = json['message']
code = json['code']
Expand Down Expand Up @@ -156,6 +187,32 @@ def handle_error_response(response:)

private

def retryable_error?(http_status)
http_status >= 500 || http_status == 408 || http_status == 429
end

def calculate_backoff(attempt)
base_delay = 1.0
max_delay = 30.0
jitter_percentage = 0.25

delay = [base_delay * (2**(attempt - 1)), max_delay].min
jitter = delay * jitter_percentage * rand
delay + jitter
end

def calculate_retry_delay(attempt, response)
# If it's a 408 or 429 with Retry-After header, use that
http_status = response.code.to_i
if [408, 429].include?(http_status) && response['Retry-After']
retry_after = response['Retry-After'].to_i
return retry_after if retry_after.positive?
end

# Otherwise use exponential backoff
calculate_backoff(attempt)
end

def extract_error(errors)
errors.map do |error|
"#{error['field']}: #{error['code']}"
Expand Down
4 changes: 3 additions & 1 deletion lib/workos/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
module WorkOS
# Configuration class sets config initializer
class Configuration
attr_accessor :api_hostname, :timeout, :key
attr_accessor :api_hostname, :timeout, :key, :max_retries, :audit_log_max_retries

def initialize
@timeout = 60
@max_retries = 0

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default to 0 retries

@audit_log_max_retries = 3
end

def key!
Expand Down
16 changes: 16 additions & 0 deletions lib/workos/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ def to_s
"#{status_string}#{@message}#{id_string}"
end
end

def retryable?
return true if http_status && (http_status >= 500 || http_status == 408)

false
end
end

# APIError is a generic error that may be raised in cases where none of the
Expand Down Expand Up @@ -83,4 +89,14 @@ class NotFoundError < WorkOSError; end

# UnprocessableEntityError is raised when a request is made that cannot be processed
class UnprocessableEntityError < WorkOSError; end

# RetryableError is raised internally to trigger retry logic for retryable HTTP errors
class RetryableError < StandardError
attr_reader :http_status

def initialize(http_status:)
@http_status = http_status
super()
end
end
end
97 changes: 89 additions & 8 deletions spec/lib/workos/audit_logs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,23 @@
end

context 'without idempotency key' do
it 'creates an event' do
VCR.use_cassette 'audit_logs/create_event', match_requests_on: %i[path body] do
response = described_class.create_event(
organization: 'org_123',
event: valid_event,
)
it 'creates an event with auto-generated idempotency_key' do
allow(SecureRandom).to receive(:uuid).and_return('test-uuid-1234')

expect(response.code).to eq '201'
end
request = double('request')
expect(described_class).to receive(:post_request).with(
path: '/audit_logs/events',
auth: true,
idempotency_key: 'test-uuid-1234',
body: hash_including(organization_id: 'org_123'),
).and_return(request)

allow(described_class).to receive(:execute_request).and_return(double(code: '201'))

described_class.create_event(
organization: 'org_123',
event: valid_event,
)
end
end

Expand All @@ -81,6 +89,79 @@
end
end
end

context 'with retry logic using same idempotency key' do
before do
WorkOS.config.max_retries = 3
end

after do
WorkOS.config.max_retries = 0
end

it 'retries with the same idempotency key on retryable errors' do
allow(described_class).to receive(:client).and_return(double('client'))

call_count = 0
allow(described_class.client).to receive(:request) do |request|
call_count += 1
# Verify the same idempotency key is used on every retry
expect(request['Idempotency-Key']).to eq('test-idempotency-key')

if call_count < 3
# Return 500 error for first 2 attempts
response = double('response', code: '500', body: '{"message": "Internal Server Error"}')
allow(response).to receive(:[]).with('x-request-id').and_return('test-request-id')
allow(response).to receive(:[]).with('Retry-After').and_return(nil)
response
else
# Success on 3rd attempt
double('response', code: '201', body: '{}')
end
end

expect(described_class).to receive(:sleep).exactly(2).times

response = described_class.create_event(
organization: 'org_123',
event: valid_event,
idempotency_key: 'test-idempotency-key',
)

expect(response.code).to eq('201')
expect(call_count).to eq(3)
end
end

context 'with retry limit exceeded' do
it 'stops retrying after hitting retry limit' do
allow(described_class).to receive(:client).and_return(double('client'))

call_count = 0
allow(described_class.client).to receive(:request) do |request|
call_count += 1
expect(request['Idempotency-Key']).to eq('test-idempotency-key')

response = double('response', code: '503', body: '{"message": "Service Unavailable"}')
allow(response).to receive(:[]).with('x-request-id').and_return('test-request-id')
allow(response).to receive(:[]).with('Retry-After').and_return(nil)
response
end

expect(described_class).to receive(:sleep).exactly(3).times

expect do
described_class.create_event(
organization: 'org_123',
event: valid_event,
idempotency_key: 'test-idempotency-key',
)
end.to raise_error(WorkOS::APIError)

# Should make 4 total attempts: 1 initial + 3 retries
expect(call_count).to eq(4)
end
end
end
end

Expand Down
Loading