⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
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)
# Explicitly 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
if retryable_error?(http_status) && attempt < retries
attempt += 1
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 || http_status == 429)

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
90 changes: 82 additions & 8 deletions spec/lib/workos/audit_logs_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
before do
WorkOS.configure do |config|
config.key = 'example_api_key'
config.audit_log_max_retries = 3
end
end

Expand Down Expand Up @@ -53,15 +54,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 +90,71 @@
end
end
end

context 'with retry logic using same idempotency key' do
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