attio 0.2.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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +1 -45
- data/.gitignore +1 -0
- data/CHANGELOG.md +69 -0
- data/CLAUDE.md +391 -0
- data/Gemfile.lock +1 -1
- data/README.md +370 -24
- data/lib/attio/circuit_breaker.rb +299 -0
- data/lib/attio/client.rb +43 -1
- data/lib/attio/connection_pool.rb +190 -35
- data/lib/attio/enhanced_client.rb +257 -0
- data/lib/attio/errors.rb +30 -2
- data/lib/attio/http_client.rb +58 -3
- data/lib/attio/observability.rb +424 -0
- data/lib/attio/rate_limiter.rb +212 -0
- data/lib/attio/resources/base.rb +70 -2
- data/lib/attio/resources/bulk.rb +290 -0
- data/lib/attio/resources/deals.rb +183 -0
- data/lib/attio/resources/records.rb +29 -2
- data/lib/attio/resources/workspace_members.rb +103 -0
- data/lib/attio/version.rb +1 -1
- data/lib/attio/webhooks.rb +220 -0
- data/lib/attio.rb +12 -0
- metadata +10 -1
@@ -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
|
data/lib/attio/errors.rb
CHANGED
@@ -1,11 +1,39 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Attio
|
4
|
-
class
|
4
|
+
# Base error class for all Attio errors
|
5
|
+
class Error < StandardError
|
6
|
+
attr_reader :response, :code
|
5
7
|
|
8
|
+
def initialize(message = nil, response: nil, code: nil)
|
9
|
+
@response = response
|
10
|
+
@code = code
|
11
|
+
super(message)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
# Raised when authentication fails (401)
|
6
16
|
class AuthenticationError < Error; end
|
17
|
+
|
18
|
+
# Raised when a resource is not found (404)
|
7
19
|
class NotFoundError < Error; end
|
20
|
+
|
21
|
+
# Raised when validation fails (400/422)
|
8
22
|
class ValidationError < Error; end
|
9
|
-
|
23
|
+
|
24
|
+
# Raised when rate limit is exceeded (429)
|
25
|
+
class RateLimitError < Error
|
26
|
+
attr_reader :retry_after
|
27
|
+
|
28
|
+
def initialize(message = nil, retry_after: nil, **options)
|
29
|
+
@retry_after = retry_after
|
30
|
+
super(message, **options)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Raised when server error occurs (5xx)
|
10
35
|
class ServerError < Error; end
|
36
|
+
|
37
|
+
# Raised for generic API errors
|
38
|
+
class APIError < Error; end
|
11
39
|
end
|
data/lib/attio/http_client.rb
CHANGED
@@ -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 = {
|
@@ -57,6 +65,10 @@ module Attio
|
|
57
65
|
headers: headers.merge("Content-Type" => "application/json"),
|
58
66
|
timeout: timeout,
|
59
67
|
connecttimeout: timeout,
|
68
|
+
# SSL/TLS security settings
|
69
|
+
ssl_verifypeer: true,
|
70
|
+
ssl_verifyhost: 2,
|
71
|
+
followlocation: false, # Prevent following redirects for security
|
60
72
|
}.merge(options)
|
61
73
|
|
62
74
|
request = Typhoeus::Request.new(url, request_options)
|
@@ -67,7 +79,13 @@ module Attio
|
|
67
79
|
|
68
80
|
private def handle_response(response)
|
69
81
|
return handle_connection_error(response) if response.code == 0
|
70
|
-
|
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
|
71
89
|
|
72
90
|
handle_error_response(response)
|
73
91
|
end
|
@@ -82,6 +100,12 @@ module Attio
|
|
82
100
|
error_class = error_class_for_status(response.code)
|
83
101
|
message = parse_error_message(response)
|
84
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
|
+
|
85
109
|
# Add status code to message for generic errors
|
86
110
|
message = "Request failed with status #{response.code}: #{message}" if error_class == Error
|
87
111
|
|
@@ -123,6 +147,37 @@ module Attio
|
|
123
147
|
end
|
124
148
|
end
|
125
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
|
+
|
126
181
|
class TimeoutError < Error; end
|
127
182
|
class ConnectionError < Error; end
|
128
183
|
end
|