attio 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client"
4
+ require_relative "connection_pool"
5
+ require_relative "circuit_breaker"
6
+ require_relative "observability"
7
+ require_relative "webhooks"
8
+
9
+ module Attio
10
+ # Enhanced client with enterprise features
11
+ #
12
+ # @example With connection pooling
13
+ # client = Attio::EnhancedClient.new(
14
+ # api_key: 'your-api-key',
15
+ # connection_pool: { size: 10, timeout: 5 },
16
+ # circuit_breaker: { threshold: 5, timeout: 60 },
17
+ # instrumentation: { logger: Rails.logger, metrics: :datadog }
18
+ # )
19
+ class EnhancedClient < Client
20
+ attr_reader :pool, :circuit_breaker, :instrumentation, :webhooks
21
+
22
+ # Initialize enhanced client with enterprise features
23
+ #
24
+ # @param api_key [String] Attio API key
25
+ # @param timeout [Integer] Request timeout
26
+ # @param connection_pool [Hash] Pool configuration
27
+ # @param circuit_breaker [Hash] Circuit breaker configuration
28
+ # @param instrumentation [Hash] Observability configuration
29
+ # @param webhook_secret [String] Webhook signing secret
30
+ def initialize(
31
+ api_key:,
32
+ timeout: DEFAULT_TIMEOUT,
33
+ connection_pool: nil,
34
+ circuit_breaker: nil,
35
+ instrumentation: nil,
36
+ webhook_secret: nil
37
+ )
38
+ super(api_key: api_key, timeout: timeout)
39
+
40
+ setup_connection_pool(connection_pool) if connection_pool
41
+ setup_circuit_breaker(circuit_breaker) if circuit_breaker
42
+ setup_instrumentation(instrumentation) if instrumentation
43
+ setup_webhooks(webhook_secret) if webhook_secret
44
+ end
45
+
46
+ # Override connection to use pooled connections
47
+ def connection
48
+ return super unless @pool
49
+
50
+ @connection ||= begin
51
+ client = PooledHttpClient.new(@pool)
52
+ client = wrap_with_circuit_breaker(client) if @circuit_breaker
53
+ client = wrap_with_instrumentation(client) if @instrumentation
54
+ client
55
+ end
56
+ end
57
+
58
+ # Execute with automatic retries and circuit breaking
59
+ #
60
+ # @param endpoint [String] Optional endpoint key for circuit breaker
61
+ # @yield Block to execute
62
+ def execute(endpoint: nil, &block)
63
+ if @circuit_breaker && endpoint
64
+ @composite_breaker ||= CompositeCircuitBreaker.new
65
+ @composite_breaker.call(endpoint, &block)
66
+ elsif @circuit_breaker
67
+ @circuit_breaker.call(&block)
68
+ else
69
+ yield
70
+ end
71
+ end
72
+
73
+ # Health check for all components
74
+ #
75
+ # @return [Hash] Health status
76
+ def health_check
77
+ {
78
+ api: check_api_health,
79
+ pool: @pool&.healthy? || true,
80
+ circuit_breaker: circuit_breaker_health,
81
+ rate_limiter: rate_limiter.status[:remaining] > 0,
82
+ }
83
+ end
84
+
85
+ # Get comprehensive statistics
86
+ #
87
+ # @return [Hash] Statistics from all components
88
+ def stats
89
+ {
90
+ pool: @pool&.stats,
91
+ circuit_breaker: @circuit_breaker&.stats,
92
+ rate_limiter: rate_limiter.status,
93
+ instrumentation: @instrumentation&.metrics&.counters,
94
+ }
95
+ end
96
+
97
+ # Graceful shutdown
98
+ def shutdown!
99
+ @pool&.shutdown
100
+ @instrumentation&.disable!
101
+
102
+ # Gracefully stop background stats thread
103
+ return unless @stats_thread&.alive?
104
+
105
+ @stats_thread.kill
106
+ @stats_thread.join(5) # Wait up to 5 seconds for clean shutdown
107
+ end
108
+
109
+ private def setup_connection_pool(config)
110
+ pool_size = config[:size] || ConnectionPool::DEFAULT_SIZE
111
+ pool_timeout = config[:timeout] || ConnectionPool::DEFAULT_TIMEOUT
112
+
113
+ @pool = ConnectionPool.new(size: pool_size, timeout: pool_timeout) do
114
+ HttpClient.new(
115
+ base_url: API_BASE_URL,
116
+ headers: default_headers,
117
+ timeout: timeout,
118
+ rate_limiter: rate_limiter
119
+ )
120
+ end
121
+ end
122
+
123
+ private def setup_circuit_breaker(config)
124
+ @circuit_breaker = CircuitBreaker.new(
125
+ threshold: config[:threshold] || 5,
126
+ timeout: config[:timeout] || 60,
127
+ half_open_requests: config[:half_open_requests] || 3,
128
+ exceptions: [Attio::Error, Timeout::Error, Errno::ECONNREFUSED]
129
+ )
130
+
131
+ # Set up state change notifications
132
+ @circuit_breaker.on_state_change = lambda do |old_state, new_state|
133
+ @instrumentation&.record_circuit_breaker(
134
+ endpoint: "api",
135
+ old_state: old_state,
136
+ new_state: new_state
137
+ )
138
+ end
139
+ end
140
+
141
+ private def setup_instrumentation(config)
142
+ @instrumentation = Observability::Instrumentation.new(
143
+ logger: config[:logger],
144
+ metrics_backend: config[:metrics],
145
+ trace_backend: config[:traces]
146
+ )
147
+
148
+ # Start background stats reporter if pool exists
149
+ return unless @pool
150
+
151
+ @stats_thread = Thread.new do
152
+ loop do
153
+ sleep 60 # Report every minute
154
+ @instrumentation.record_pool_stats(@pool.stats) if @pool
155
+ rescue StandardError => e
156
+ @instrumentation.logger.error(
157
+ "Background stats thread error: #{e.class.name}: #{e.message}\n" \
158
+ "Backtrace: #{e.backtrace.join("\n")}"
159
+ )
160
+ # Continue the loop to keep the thread alive
161
+ end
162
+ rescue StandardError => e
163
+ @instrumentation.logger.fatal(
164
+ "Background stats thread crashed: #{e.class.name}: #{e.message}\n" \
165
+ "Backtrace: #{e.backtrace.join("\n")}"
166
+ )
167
+ # Thread will exit, but this prevents it from crashing silently
168
+ end
169
+ end
170
+
171
+ private def setup_webhooks(secret)
172
+ @webhooks = Webhooks.new(secret: secret)
173
+ end
174
+
175
+ private def wrap_with_circuit_breaker(client)
176
+ CircuitBreakerClient.new(client, @circuit_breaker)
177
+ end
178
+
179
+ private def wrap_with_instrumentation(client)
180
+ # Create instrumented wrapper
181
+ Class.new do
182
+ def initialize(client, instrumentation)
183
+ @client = client
184
+ @instrumentation = instrumentation
185
+ end
186
+
187
+ %i[get post patch put delete].each do |method|
188
+ define_method(method) do |*args|
189
+ start_time = Time.now
190
+ path = args[0]
191
+
192
+ begin
193
+ result = @client.send(method, *args)
194
+ status = result.is_a?(Hash) ? result["_status"] : nil
195
+ @instrumentation.record_api_call(
196
+ method: method,
197
+ path: path,
198
+ duration: Time.now - start_time,
199
+ status: status
200
+ )
201
+ result
202
+ rescue StandardError => e
203
+ @instrumentation.record_api_call(
204
+ method: method,
205
+ path: path,
206
+ duration: Time.now - start_time,
207
+ error: e
208
+ )
209
+ raise
210
+ end
211
+ end
212
+ end
213
+ end.new(client, @instrumentation)
214
+ end
215
+
216
+ private def default_headers
217
+ {
218
+ "Authorization" => "Bearer #{api_key}",
219
+ "Accept" => "application/json",
220
+ "Content-Type" => "application/json",
221
+ "User-Agent" => "Attio Ruby Client/#{VERSION}",
222
+ }
223
+ end
224
+
225
+ private def check_api_health
226
+ connection.get("meta/identify")
227
+ true
228
+ rescue StandardError
229
+ false
230
+ end
231
+
232
+ private def circuit_breaker_health
233
+ return true unless @circuit_breaker
234
+
235
+ CIRCUIT_STATES[@circuit_breaker.state]
236
+ end
237
+
238
+ CIRCUIT_STATES = {
239
+ closed: :healthy,
240
+ half_open: :recovering,
241
+ open: :unhealthy,
242
+ }.freeze
243
+ private_constant :CIRCUIT_STATES
244
+ end
245
+
246
+ # Factory method for creating enhanced client
247
+ #
248
+ # @example
249
+ # client = Attio.enhanced_client(
250
+ # api_key: ENV['ATTIO_API_KEY'],
251
+ # connection_pool: { size: 25 },
252
+ # circuit_breaker: { threshold: 10 }
253
+ # )
254
+ def self.enhanced_client(**options)
255
+ EnhancedClient.new(**options)
256
+ end
257
+ end
@@ -13,12 +13,13 @@ module Attio
13
13
  class HttpClient
14
14
  DEFAULT_TIMEOUT = 30
15
15
 
16
- attr_reader :base_url, :headers, :timeout
16
+ attr_reader :base_url, :headers, :timeout, :rate_limiter
17
17
 
18
- def initialize(base_url:, headers: {}, timeout: DEFAULT_TIMEOUT)
18
+ def initialize(base_url:, headers: {}, timeout: DEFAULT_TIMEOUT, rate_limiter: nil)
19
19
  @base_url = base_url
20
20
  @headers = headers
21
21
  @timeout = timeout
22
+ @rate_limiter = rate_limiter
22
23
  end
23
24
 
24
25
  def get(path, params = nil)
@@ -50,6 +51,13 @@ module Attio
50
51
  end
51
52
 
52
53
  private def execute_request(method, path, options = {})
54
+ # Use rate limiter if available
55
+ return @rate_limiter.execute { perform_request(method, path, options) } if @rate_limiter
56
+
57
+ perform_request(method, path, options)
58
+ end
59
+
60
+ private def perform_request(method, path, options = {})
53
61
  url = "#{base_url}/#{path}"
54
62
 
55
63
  request_options = {
@@ -71,7 +79,13 @@ module Attio
71
79
 
72
80
  private def handle_response(response)
73
81
  return handle_connection_error(response) if response.code == 0
74
- return parse_json(response.body) if (200..299).cover?(response.code)
82
+
83
+ if (200..299).cover?(response.code)
84
+ result = parse_json(response.body)
85
+ # Add headers to result for rate limiter to process
86
+ result["_headers"] = extract_rate_limit_headers(response) if @rate_limiter
87
+ return result
88
+ end
75
89
 
76
90
  handle_error_response(response)
77
91
  end
@@ -86,6 +100,12 @@ module Attio
86
100
  error_class = error_class_for_status(response.code)
87
101
  message = parse_error_message(response)
88
102
 
103
+ # Handle rate limit errors specially
104
+ if response.code == 429
105
+ retry_after = extract_retry_after(response)
106
+ raise RateLimitError.new(message, retry_after: retry_after, response: response, code: response.code)
107
+ end
108
+
89
109
  # Add status code to message for generic errors
90
110
  message = "Request failed with status #{response.code}: #{message}" if error_class == Error
91
111
 
@@ -127,6 +147,37 @@ module Attio
127
147
  end
128
148
  end
129
149
 
150
+ private def extract_rate_limit_headers(response)
151
+ headers = {}
152
+ response.headers.each do |key, value|
153
+ case key.downcase
154
+ when "x-ratelimit-limit"
155
+ headers["x-ratelimit-limit"] = value
156
+ when "x-ratelimit-remaining"
157
+ headers["x-ratelimit-remaining"] = value
158
+ when "x-ratelimit-reset"
159
+ headers["x-ratelimit-reset"] = value
160
+ end
161
+ end
162
+ headers
163
+ end
164
+
165
+ private def extract_retry_after(response)
166
+ retry_after = response.headers["retry-after"] || response.headers["Retry-After"]
167
+ return nil unless retry_after
168
+
169
+ # Try parsing as integer (seconds) first
170
+ parsed = retry_after.to_i
171
+ # If to_i returns 0 but the string isn't "0", it means parsing failed
172
+ return parsed if parsed > 0 || retry_after == "0"
173
+
174
+ # If not a valid integer, could be HTTP date, default to 60 seconds
175
+ 60
176
+ rescue StandardError
177
+ # If not an integer, could be HTTP date, default to 60 seconds
178
+ 60
179
+ end
180
+
130
181
  class TimeoutError < Error; end
131
182
  class ConnectionError < Error; end
132
183
  end