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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ # Circuit breaker pattern for fault tolerance
5
+ #
6
+ # @example Basic usage
7
+ # breaker = CircuitBreaker.new(
8
+ # threshold: 5,
9
+ # timeout: 60,
10
+ # half_open_requests: 3
11
+ # )
12
+ #
13
+ # breaker.call do
14
+ # # API call that might fail
15
+ # client.records.list
16
+ # end
17
+ class CircuitBreaker
18
+ class OpenCircuitError < StandardError; end
19
+
20
+ # Circuit states
21
+ CLOSED = :closed
22
+ OPEN = :open
23
+ HALF_OPEN = :half_open
24
+
25
+ attr_reader :state, :failure_count, :success_count, :last_failure_time
26
+ attr_accessor :on_state_change
27
+
28
+ # Initialize circuit breaker
29
+ #
30
+ # @param threshold [Integer] Number of failures before opening circuit
31
+ # @param timeout [Integer] Seconds before attempting to close circuit
32
+ # @param half_open_requests [Integer] Successful requests needed to close circuit
33
+ # @param exceptions [Array<Class>] Exception classes to catch
34
+ def initialize(
35
+ threshold: 5,
36
+ timeout: 60,
37
+ half_open_requests: 3,
38
+ exceptions: [StandardError]
39
+ )
40
+ @threshold = threshold
41
+ @timeout = timeout
42
+ @half_open_requests = half_open_requests
43
+ @exceptions = exceptions
44
+
45
+ @state = CLOSED
46
+ @failure_count = 0
47
+ @success_count = 0
48
+ @last_failure_time = nil
49
+ @half_open_successes = 0
50
+
51
+ @mutex = Mutex.new
52
+ @on_state_change = nil
53
+
54
+ # Statistics
55
+ @stats = {
56
+ requests: 0,
57
+ failures: 0,
58
+ successes: 0,
59
+ rejections: 0,
60
+ state_changes: 0,
61
+ }
62
+ end
63
+
64
+ # Execute a block with circuit breaker protection
65
+ #
66
+ # @yield Block to execute
67
+ # @return Result of the block
68
+ # @raise [OpenCircuitError] if circuit is open
69
+ def call
70
+ @mutex.synchronize do
71
+ @stats[:requests] += 1
72
+
73
+ case @state
74
+ when OPEN
75
+ if can_attempt_reset?
76
+ transition_to(HALF_OPEN)
77
+ else
78
+ @stats[:rejections] += 1
79
+ raise OpenCircuitError, "Circuit breaker is open (#{time_until_retry}s until retry)"
80
+ end
81
+ when HALF_OPEN
82
+ # Allow limited requests through
83
+ if @half_open_successes >= @half_open_requests
84
+ # Already proven stable, close the circuit
85
+ transition_to(CLOSED)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Execute the block outside the mutex
91
+ begin
92
+ result = yield
93
+ record_success
94
+ result
95
+ rescue *@exceptions => e
96
+ record_failure
97
+ raise e
98
+ end
99
+ end
100
+
101
+ # Manually trip the circuit breaker
102
+ def trip!
103
+ @mutex.synchronize do
104
+ transition_to(OPEN)
105
+ end
106
+ end
107
+
108
+ # Manually reset the circuit breaker
109
+ def reset!
110
+ @mutex.synchronize do
111
+ @failure_count = 0
112
+ @success_count = 0
113
+ @half_open_successes = 0
114
+ @last_failure_time = nil
115
+ transition_to(CLOSED)
116
+ end
117
+ end
118
+
119
+ # Check if circuit allows requests
120
+ #
121
+ # @return [Boolean] True if requests are allowed
122
+ def allow_request?
123
+ @state != OPEN || can_attempt_reset?
124
+ end
125
+
126
+ # Get circuit breaker statistics
127
+ #
128
+ # @return [Hash] Statistics
129
+ def stats
130
+ @mutex.synchronize do
131
+ @stats.merge(
132
+ state: @state,
133
+ failure_count: @failure_count,
134
+ success_count: @success_count,
135
+ threshold: @threshold,
136
+ time_until_retry: @state == OPEN ? time_until_retry : nil
137
+ )
138
+ end
139
+ end
140
+
141
+ # Time remaining until circuit can attempt reset
142
+ #
143
+ # @return [Integer] Seconds until retry, 0 if ready
144
+ def time_until_retry
145
+ return 0 unless @state == OPEN
146
+ return 0 unless @last_failure_time
147
+
148
+ elapsed = Time.now - @last_failure_time
149
+ remaining = @timeout - elapsed
150
+ remaining > 0 ? remaining.to_i : 0
151
+ end
152
+
153
+ private def record_success
154
+ @mutex.synchronize do
155
+ @stats[:successes] += 1
156
+ @success_count += 1
157
+
158
+ case @state
159
+ when HALF_OPEN
160
+ @half_open_successes += 1
161
+ if @half_open_successes >= @half_open_requests
162
+ # Circuit has proven stable
163
+ transition_to(CLOSED)
164
+ end
165
+ when CLOSED
166
+ # Reset failure count on success
167
+ @failure_count = 0
168
+ end
169
+ end
170
+ end
171
+
172
+ private def record_failure
173
+ @mutex.synchronize do
174
+ @stats[:failures] += 1
175
+ @failure_count += 1
176
+ @last_failure_time = Time.now
177
+
178
+ case @state
179
+ when CLOSED
180
+ transition_to(OPEN) if @failure_count >= @threshold
181
+ when HALF_OPEN
182
+ # Single failure in half-open state reopens circuit
183
+ transition_to(OPEN)
184
+ end
185
+ end
186
+ end
187
+
188
+ private def can_attempt_reset?
189
+ return false unless @last_failure_time
190
+
191
+ Time.now - @last_failure_time >= @timeout
192
+ end
193
+
194
+ private def transition_to(new_state)
195
+ return if @state == new_state
196
+
197
+ old_state = @state
198
+ @state = new_state
199
+ @stats[:state_changes] += 1
200
+
201
+ # Reset counters for new state
202
+ case new_state
203
+ when CLOSED
204
+ @failure_count = 0
205
+ @half_open_successes = 0
206
+ when HALF_OPEN
207
+ @half_open_successes = 0
208
+ end
209
+
210
+ # Notify state change
211
+ @on_state_change&.call(old_state, new_state)
212
+ end
213
+ end
214
+
215
+ # Wrapper for circuit breaker protected HTTP client
216
+ class CircuitBreakerClient
217
+ def initialize(client, circuit_breaker)
218
+ @client = client
219
+ @circuit_breaker = circuit_breaker
220
+ end
221
+
222
+ def get(path, params = nil)
223
+ @circuit_breaker.call { @client.get(path, params) }
224
+ end
225
+
226
+ def post(path, body = {})
227
+ @circuit_breaker.call { @client.post(path, body) }
228
+ end
229
+
230
+ def patch(path, body = {})
231
+ @circuit_breaker.call { @client.patch(path, body) }
232
+ end
233
+
234
+ def put(path, body = {})
235
+ @circuit_breaker.call { @client.put(path, body) }
236
+ end
237
+
238
+ def delete(path, body = nil)
239
+ @circuit_breaker.call { @client.delete(path, body) }
240
+ end
241
+ end
242
+
243
+ # Composite circuit breaker for multiple endpoints
244
+ class CompositeCircuitBreaker
245
+ def initialize(default_config = {})
246
+ @breakers = {}
247
+ @default_config = {
248
+ threshold: 5,
249
+ timeout: 60,
250
+ half_open_requests: 3,
251
+ }.merge(default_config)
252
+ @mutex = Mutex.new
253
+ end
254
+
255
+ # Get or create circuit breaker for endpoint
256
+ #
257
+ # @param key [String] Endpoint key
258
+ # @param config [Hash] Optional config overrides
259
+ # @return [CircuitBreaker] Circuit breaker for endpoint
260
+ def for_endpoint(key, config = {})
261
+ @mutex.synchronize do
262
+ @breakers[key] ||= CircuitBreaker.new(**@default_config, **config)
263
+ end
264
+ end
265
+
266
+ # Execute with circuit breaker for endpoint
267
+ #
268
+ # @param key [String] Endpoint key
269
+ # @yield Block to execute
270
+ def call(key, &block)
271
+ for_endpoint(key).call(&block)
272
+ end
273
+
274
+ # Get all circuit breaker states
275
+ #
276
+ # @return [Hash] States by endpoint
277
+ def states
278
+ @mutex.synchronize do
279
+ @breakers.transform_values(&:state)
280
+ end
281
+ end
282
+
283
+ # Get aggregated statistics
284
+ #
285
+ # @return [Hash] Combined statistics
286
+ def stats
287
+ @mutex.synchronize do
288
+ @breakers.transform_values(&:stats)
289
+ end
290
+ end
291
+
292
+ # Reset all circuit breakers
293
+ def reset_all!
294
+ @mutex.synchronize do
295
+ @breakers.each_value(&:reset!)
296
+ end
297
+ end
298
+ end
299
+ end
data/lib/attio/client.rb CHANGED
@@ -59,7 +59,8 @@ module Attio
59
59
  "Content-Type" => "application/json",
60
60
  "User-Agent" => "Attio Ruby Client/#{VERSION}",
61
61
  },
62
- timeout: timeout
62
+ timeout: timeout,
63
+ rate_limiter: rate_limiter
63
64
  )
64
65
  end
65
66
 
@@ -171,15 +172,6 @@ module Attio
171
172
  @deals ||= Resources::Deals.new(self)
172
173
  end
173
174
 
174
- # Access to the Meta API resource.
175
- #
176
- # @return [Resources::Meta] Meta resource instance
177
- # @example
178
- # info = client.meta.identify
179
- def meta
180
- @meta ||= Resources::Meta.new(self)
181
- end
182
-
183
175
  # Access to the Bulk Operations API resource.
184
176
  #
185
177
  # @return [Resources::Bulk] Bulk operations resource instance
@@ -1,36 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Attio
4
- # Thread-safe connection pool for managing HTTP connections
5
- #
6
- # This class provides a pool of connections that can be shared
7
- # across threads for improved performance and resource management.
6
+ # Thread-safe connection pool for high-throughput operations
8
7
  #
9
- # @example Creating a connection pool
10
- # pool = ConnectionPool.new(size: 10) { HttpClient.new }
8
+ # @example Basic usage
9
+ # pool = ConnectionPool.new(size: 5) do
10
+ # Attio::HttpClient.new(base_url: API_URL, headers: headers)
11
+ # end
11
12
  #
12
- # @example Using a connection from the pool
13
- # pool.with_connection do |conn|
14
- # conn.get("/endpoint")
13
+ # pool.with do |connection|
14
+ # connection.get("records")
15
15
  # end
16
16
  class ConnectionPool
17
- DEFAULT_POOL_SIZE = 5
18
- DEFAULT_TIMEOUT = 5 # seconds to wait for connection
17
+ class TimeoutError < StandardError; end
18
+ class PoolShuttingDownError < StandardError; end
19
+
20
+ DEFAULT_SIZE = 5
21
+ DEFAULT_TIMEOUT = 5
22
+
23
+ attr_reader :size, :timeout, :available, :allocated
19
24
 
20
- attr_reader :size, :timeout
25
+ # Initialize a new connection pool
26
+ #
27
+ # @param size [Integer] Maximum number of connections
28
+ # @param timeout [Integer] Seconds to wait for available connection
29
+ # @yield Block that creates a new connection
30
+ def initialize(size: DEFAULT_SIZE, timeout: DEFAULT_TIMEOUT, &block)
31
+ raise ArgumentError, "Block required to create connections" unless block_given?
21
32
 
22
- def initialize(size: DEFAULT_POOL_SIZE, timeout: DEFAULT_TIMEOUT, &block)
23
33
  @size = size
24
34
  @timeout = timeout
25
- @available = Queue.new
26
- @key = :"#{object_id}_connection"
27
- @block = block
35
+ @create_block = block
36
+ @available = []
37
+ @allocated = {}
28
38
  @mutex = Mutex.new
39
+ @resource = ConditionVariable.new
40
+ @shutting_down = false
41
+ @created = 0
29
42
 
30
- size.times { @available << create_connection }
43
+ # Stats tracking
44
+ @stats = {
45
+ requests: 0,
46
+ timeouts: 0,
47
+ wait_time: 0,
48
+ active: 0,
49
+ created: 0,
50
+ destroyed: 0,
51
+ }
31
52
  end
32
53
 
33
- def with_connection
54
+ # Execute a block with a connection from the pool
55
+ #
56
+ # @yield [connection] Block to execute with connection
57
+ # @return Result of the block
58
+ def with
34
59
  connection = checkout
35
60
  begin
36
61
  yield connection
@@ -39,43 +64,173 @@ module Attio
39
64
  end
40
65
  end
41
66
 
67
+ # Check out a connection from the pool
68
+ #
69
+ # @return [Object] A connection from the pool
70
+ # @raise [TimeoutError] if no connection available within timeout
42
71
  def checkout
43
- deadline = Time.now + timeout
72
+ start_time = Time.now
73
+ deadline = start_time + @timeout
44
74
 
45
- loop do
46
- return @available.pop(true) if @available.size.positive?
75
+ @mutex.synchronize do
76
+ raise PoolShuttingDownError, "Pool is shutting down" if @shutting_down
77
+
78
+ @stats[:requests] += 1
79
+
80
+ loop do
81
+ # Return available connection
82
+ if (connection = @available.pop)
83
+ @allocated[Thread.current] = connection
84
+ @stats[:active] += 1
85
+ @stats[:wait_time] += (Time.now - start_time)
86
+ return connection
87
+ end
47
88
 
48
- raise TimeoutError, "Couldn't acquire connection within #{timeout} seconds" if Time.now >= deadline
89
+ # Create new connection if under limit
90
+ if @created < @size
91
+ connection = create_connection
92
+ @allocated[Thread.current] = connection
93
+ @stats[:active] += 1
94
+ @stats[:wait_time] += (Time.now - start_time)
95
+ return connection
96
+ end
49
97
 
50
- sleep(0.01)
98
+ # Wait for available connection
99
+ remaining = deadline - Time.now
100
+ if remaining <= 0
101
+ @stats[:timeouts] += 1
102
+ raise TimeoutError, "Timed out waiting for connection after #{@timeout}s"
103
+ end
104
+
105
+ @resource.wait(@mutex, remaining)
106
+ end
51
107
  end
52
- rescue ThreadError
53
- raise TimeoutError, "No connections available"
54
108
  end
55
109
 
110
+ # Return a connection to the pool
111
+ #
112
+ # @param connection [Object] Connection to return
56
113
  def checkin(connection)
57
114
  @mutex.synchronize do
58
- @available << connection if connection
115
+ if @allocated[Thread.current] == connection
116
+ @allocated.delete(Thread.current)
117
+ @stats[:active] -= 1
118
+
119
+ if @shutting_down
120
+ destroy_connection(connection)
121
+ else
122
+ @available.push(connection)
123
+ @resource.signal
124
+ end
125
+ end
59
126
  end
60
127
  end
61
128
 
129
+ # Shutdown the pool and close all connections
62
130
  def shutdown
63
131
  @mutex.synchronize do
64
- @available.close
65
- while (connection = begin
66
- @available.pop(true)
67
- rescue StandardError
68
- nil
69
- end)
70
- connection.close if connection.respond_to?(:close)
132
+ @shutting_down = true
133
+
134
+ # Close available connections
135
+ while (connection = @available.pop)
136
+ destroy_connection(connection)
137
+ end
138
+
139
+ # NOTE: allocated connections will be closed when checked in
140
+ @resource.broadcast
141
+ end
142
+ end
143
+
144
+ # Reset the pool by closing all connections
145
+ def reset!
146
+ @mutex.synchronize do
147
+ # Close all available connections
148
+ while (connection = @available.pop)
149
+ destroy_connection(connection)
71
150
  end
151
+
152
+ @created = 0
153
+ @stats[:created] = 0
154
+ @stats[:destroyed] = 0
155
+ end
156
+ end
157
+
158
+ # Get pool statistics
159
+ #
160
+ # @return [Hash] Pool statistics
161
+ def stats
162
+ @mutex.synchronize do
163
+ @stats.merge(
164
+ size: @size,
165
+ available: @available.size,
166
+ allocated: @allocated.size,
167
+ created: @created
168
+ )
169
+ end
170
+ end
171
+
172
+ # Current pool utilization (0.0 to 1.0)
173
+ #
174
+ # @return [Float] Utilization percentage
175
+ def utilization
176
+ @mutex.synchronize do
177
+ return 0.0 if @size == 0
178
+
179
+ @allocated.size.to_f / @size
72
180
  end
73
181
  end
74
182
 
183
+ # Check if pool is healthy
184
+ #
185
+ # @return [Boolean] True if pool is functioning normally
186
+ def healthy?
187
+ return false if @shutting_down
188
+ return true if @stats[:requests] == 0
189
+
190
+ @stats[:timeouts] < (@stats[:requests] * 0.01)
191
+ end
192
+
75
193
  private def create_connection
76
- @block.call
194
+ connection = @create_block.call
195
+ @created += 1
196
+ @stats[:created] += 1
197
+ connection
77
198
  end
78
199
 
79
- class TimeoutError < StandardError; end
200
+ private def destroy_connection(connection)
201
+ # Call close if connection responds to it
202
+ connection.close if connection.respond_to?(:close)
203
+ @stats[:destroyed] += 1
204
+ rescue StandardError => e
205
+ # Log but don't raise on close errors
206
+ warn "Error closing connection: #{e.message}"
207
+ end
208
+ end
209
+
210
+ # Wrapper for pooled HTTP connections
211
+ class PooledHttpClient
212
+ def initialize(pool)
213
+ @pool = pool
214
+ end
215
+
216
+ def get(path, params = nil)
217
+ @pool.with { |conn| conn.get(path, params) }
218
+ end
219
+
220
+ def post(path, body = {})
221
+ @pool.with { |conn| conn.post(path, body) }
222
+ end
223
+
224
+ def patch(path, body = {})
225
+ @pool.with { |conn| conn.patch(path, body) }
226
+ end
227
+
228
+ def put(path, body = {})
229
+ @pool.with { |conn| conn.put(path, body) }
230
+ end
231
+
232
+ def delete(path, body = nil)
233
+ @pool.with { |conn| conn.delete(path, body) }
234
+ end
80
235
  end
81
236
  end