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,424 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "logger"
|
5
|
+
|
6
|
+
module Attio
|
7
|
+
# Observability and instrumentation for Attio client
|
8
|
+
#
|
9
|
+
# @example Basic usage
|
10
|
+
# client.instrumentation = Attio::Observability.new(
|
11
|
+
# logger: Rails.logger,
|
12
|
+
# metrics_backend: :datadog
|
13
|
+
# )
|
14
|
+
module Observability
|
15
|
+
# Base instrumentation class
|
16
|
+
class Instrumentation
|
17
|
+
attr_reader :logger, :metrics, :traces
|
18
|
+
|
19
|
+
def initialize(logger: nil, metrics_backend: nil, trace_backend: nil)
|
20
|
+
@logger = logger || Logger.new($stdout)
|
21
|
+
@metrics = Metrics.for(metrics_backend) if metrics_backend
|
22
|
+
@traces = Traces.for(trace_backend) if trace_backend
|
23
|
+
@enabled = true
|
24
|
+
end
|
25
|
+
|
26
|
+
# Record an API call
|
27
|
+
#
|
28
|
+
# @param method [Symbol] HTTP method
|
29
|
+
# @param path [String] API path
|
30
|
+
# @param duration [Float] Call duration in seconds
|
31
|
+
# @param status [Integer] HTTP status code
|
32
|
+
# @param error [Exception] Error if any
|
33
|
+
def record_api_call(method:, path:, duration:, status: nil, error: nil)
|
34
|
+
return unless @enabled
|
35
|
+
|
36
|
+
log_api_call(method, path, duration, status, error)
|
37
|
+
record_api_metrics(method, path, duration, error) if @metrics
|
38
|
+
record_api_trace(method, path, status, error) if @traces
|
39
|
+
end
|
40
|
+
|
41
|
+
# Record rate limit information
|
42
|
+
#
|
43
|
+
# @param remaining [Integer] Requests remaining
|
44
|
+
# @param limit [Integer] Rate limit
|
45
|
+
# @param reset_at [Time] When limit resets
|
46
|
+
def record_rate_limit(remaining:, limit:, reset_at:)
|
47
|
+
return unless @enabled
|
48
|
+
|
49
|
+
utilization = 1.0 - (remaining.to_f / limit)
|
50
|
+
|
51
|
+
@logger.debug(format_log_entry(
|
52
|
+
event: "rate_limit",
|
53
|
+
remaining: remaining,
|
54
|
+
limit: limit,
|
55
|
+
utilization: utilization.round(3),
|
56
|
+
reset_in: (reset_at - Time.now).to_i
|
57
|
+
))
|
58
|
+
|
59
|
+
@metrics&.gauge("attio.rate_limit.remaining", remaining)
|
60
|
+
@metrics&.gauge("attio.rate_limit.utilization", utilization)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Record cache hit/miss
|
64
|
+
#
|
65
|
+
# @param key [String] Cache key
|
66
|
+
# @param hit [Boolean] Whether it was a hit
|
67
|
+
def record_cache(key:, hit:)
|
68
|
+
return unless @enabled
|
69
|
+
|
70
|
+
@logger.debug(format_log_entry(
|
71
|
+
event: "cache",
|
72
|
+
key: key,
|
73
|
+
hit: hit
|
74
|
+
))
|
75
|
+
|
76
|
+
@metrics&.increment("attio.cache.#{hit ? 'hits' : 'misses'}")
|
77
|
+
end
|
78
|
+
|
79
|
+
# Record circuit breaker state change
|
80
|
+
#
|
81
|
+
# @param endpoint [String] Endpoint name
|
82
|
+
# @param old_state [Symbol] Previous state
|
83
|
+
# @param new_state [Symbol] New state
|
84
|
+
def record_circuit_breaker(endpoint:, old_state:, new_state:)
|
85
|
+
return unless @enabled
|
86
|
+
|
87
|
+
@logger.warn(format_log_entry(
|
88
|
+
event: "circuit_breaker",
|
89
|
+
endpoint: endpoint,
|
90
|
+
old_state: old_state,
|
91
|
+
new_state: new_state
|
92
|
+
))
|
93
|
+
|
94
|
+
@metrics&.increment("attio.circuit_breaker.transitions", tags: {
|
95
|
+
from: old_state,
|
96
|
+
to: new_state,
|
97
|
+
})
|
98
|
+
end
|
99
|
+
|
100
|
+
# Record connection pool stats
|
101
|
+
#
|
102
|
+
# @param stats [Hash] Pool statistics
|
103
|
+
def record_pool_stats(stats)
|
104
|
+
return unless @enabled
|
105
|
+
|
106
|
+
@metrics&.gauge("attio.pool.size", stats[:size])
|
107
|
+
@metrics&.gauge("attio.pool.available", stats[:available])
|
108
|
+
@metrics&.gauge("attio.pool.allocated", stats[:allocated])
|
109
|
+
@metrics&.gauge("attio.pool.utilization", stats[:allocated].to_f / stats[:size])
|
110
|
+
end
|
111
|
+
|
112
|
+
# Disable instrumentation
|
113
|
+
def disable!
|
114
|
+
@enabled = false
|
115
|
+
end
|
116
|
+
|
117
|
+
# Enable instrumentation
|
118
|
+
def enable!
|
119
|
+
@enabled = true
|
120
|
+
end
|
121
|
+
|
122
|
+
private def format_log_entry(**fields)
|
123
|
+
fields[:timestamp] = Time.now.iso8601
|
124
|
+
fields[:service] = "attio-ruby"
|
125
|
+
JSON.generate(fields)
|
126
|
+
end
|
127
|
+
|
128
|
+
private def sanitize_path(path)
|
129
|
+
# Remove IDs from paths for metric aggregation
|
130
|
+
path.gsub(%r{/[a-f0-9-]{36}}, "/:id") # UUIDs
|
131
|
+
.gsub(%r{/[a-zA-Z]+-\d+-[a-zA-Z]+}, "/:id") # IDs like abc-123-def
|
132
|
+
.gsub(%r{/\d+}, "/:id") # Numeric IDs
|
133
|
+
end
|
134
|
+
|
135
|
+
private def log_api_call(method, path, duration, status, error)
|
136
|
+
@logger.info(format_log_entry(
|
137
|
+
event: "api_call",
|
138
|
+
method: method,
|
139
|
+
path: path,
|
140
|
+
duration_ms: (duration * 1000).round(2),
|
141
|
+
status: status,
|
142
|
+
error: error&.class&.name
|
143
|
+
))
|
144
|
+
end
|
145
|
+
|
146
|
+
private def record_api_metrics(method, path, duration, error)
|
147
|
+
@metrics.increment("attio.api.calls", tags: { method: method, path: sanitize_path(path) })
|
148
|
+
@metrics.histogram("attio.api.duration", duration * 1000, tags: { method: method })
|
149
|
+
|
150
|
+
return unless error
|
151
|
+
|
152
|
+
@metrics.increment("attio.api.errors", tags: {
|
153
|
+
method: method,
|
154
|
+
error_class: error.class.name,
|
155
|
+
})
|
156
|
+
end
|
157
|
+
|
158
|
+
private def record_api_trace(method, path, status, error)
|
159
|
+
@traces.span("attio.api.call") do |span|
|
160
|
+
span.set_attribute("http.method", method.to_s)
|
161
|
+
span.set_attribute("http.path", path)
|
162
|
+
span.set_attribute("http.status_code", status) if status
|
163
|
+
span.set_attribute("error", true) if error
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Metrics backends
|
169
|
+
module Metrics
|
170
|
+
def self.for(backend)
|
171
|
+
case backend
|
172
|
+
when :datadog then Datadog.new
|
173
|
+
when :statsd then StatsD.new
|
174
|
+
when :prometheus then Prometheus.new
|
175
|
+
when :memory then Memory.new
|
176
|
+
else
|
177
|
+
raise ArgumentError, "Unknown metrics backend: #{backend}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# In-memory metrics for testing
|
182
|
+
class Memory
|
183
|
+
attr_reader :counters, :gauges, :histograms
|
184
|
+
|
185
|
+
def initialize
|
186
|
+
@counters = Hash.new(0)
|
187
|
+
@gauges = {}
|
188
|
+
@histograms = Hash.new { |h, k| h[k] = [] }
|
189
|
+
end
|
190
|
+
|
191
|
+
def increment(metric, tags: {})
|
192
|
+
key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
|
193
|
+
@counters[key] += 1
|
194
|
+
end
|
195
|
+
|
196
|
+
def gauge(metric, value, tags: {})
|
197
|
+
key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
|
198
|
+
@gauges[key] = value
|
199
|
+
end
|
200
|
+
|
201
|
+
def histogram(metric, value, tags: {})
|
202
|
+
key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
|
203
|
+
@histograms[key] << value
|
204
|
+
end
|
205
|
+
|
206
|
+
def reset!
|
207
|
+
@counters.clear
|
208
|
+
@gauges.clear
|
209
|
+
@histograms.clear
|
210
|
+
end
|
211
|
+
|
212
|
+
private def format_tags(tags)
|
213
|
+
# Format tags in a consistent way that works across Ruby versions
|
214
|
+
sorted = tags.sort.to_h
|
215
|
+
content = sorted.map { |k, v| ":#{k}=>#{v.inspect}" }.join(", ")
|
216
|
+
"{#{content}}"
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# StatsD metrics
|
221
|
+
class StatsD
|
222
|
+
def initialize
|
223
|
+
require "statsd-ruby"
|
224
|
+
@client = ::Statsd.new("localhost", 8125)
|
225
|
+
rescue LoadError
|
226
|
+
raise "Please add 'statsd-ruby' to your Gemfile"
|
227
|
+
end
|
228
|
+
|
229
|
+
def increment(metric, tags: {})
|
230
|
+
@client.increment(metric, tags: format_tags(tags))
|
231
|
+
end
|
232
|
+
|
233
|
+
def gauge(metric, value, tags: {})
|
234
|
+
@client.gauge(metric, value, tags: format_tags(tags))
|
235
|
+
end
|
236
|
+
|
237
|
+
def histogram(metric, value, tags: {})
|
238
|
+
@client.histogram(metric, value, tags: format_tags(tags))
|
239
|
+
end
|
240
|
+
|
241
|
+
private def format_tags(tags)
|
242
|
+
tags.map { |k, v| "#{k}:#{v}" }
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Datadog metrics
|
247
|
+
class Datadog
|
248
|
+
def initialize
|
249
|
+
require "datadog/statsd"
|
250
|
+
@client = ::Datadog::Statsd.new("localhost", 8125)
|
251
|
+
rescue LoadError
|
252
|
+
raise "Please add 'dogstatsd-ruby' to your Gemfile"
|
253
|
+
end
|
254
|
+
|
255
|
+
def increment(metric, tags: {})
|
256
|
+
@client.increment(metric, tags: format_tags(tags))
|
257
|
+
end
|
258
|
+
|
259
|
+
def gauge(metric, value, tags: {})
|
260
|
+
@client.gauge(metric, value, tags: format_tags(tags))
|
261
|
+
end
|
262
|
+
|
263
|
+
def histogram(metric, value, tags: {})
|
264
|
+
@client.histogram(metric, value, tags: format_tags(tags))
|
265
|
+
end
|
266
|
+
|
267
|
+
private def format_tags(tags)
|
268
|
+
tags.map { |k, v| "#{k}:#{v}" }
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# Prometheus metrics
|
273
|
+
class Prometheus
|
274
|
+
def initialize
|
275
|
+
require "prometheus/client"
|
276
|
+
@registry = ::Prometheus::Client.registry
|
277
|
+
@counters = {}
|
278
|
+
@gauges = {}
|
279
|
+
@histograms = {}
|
280
|
+
rescue LoadError
|
281
|
+
raise "Please add 'prometheus-client' to your Gemfile"
|
282
|
+
end
|
283
|
+
|
284
|
+
def increment(metric, tags: {})
|
285
|
+
counter = @counters[metric] ||= @registry.counter(
|
286
|
+
metric.to_sym,
|
287
|
+
docstring: "Counter for #{metric}",
|
288
|
+
labels: tags.keys
|
289
|
+
)
|
290
|
+
counter.increment(labels: tags)
|
291
|
+
end
|
292
|
+
|
293
|
+
def gauge(metric, value, tags: {})
|
294
|
+
gauge = @gauges[metric] ||= @registry.gauge(
|
295
|
+
metric.to_sym,
|
296
|
+
docstring: "Gauge for #{metric}",
|
297
|
+
labels: tags.keys
|
298
|
+
)
|
299
|
+
gauge.set(value, labels: tags)
|
300
|
+
end
|
301
|
+
|
302
|
+
def histogram(metric, value, tags: {})
|
303
|
+
histogram = @histograms[metric] ||= @registry.histogram(
|
304
|
+
metric.to_sym,
|
305
|
+
docstring: "Histogram for #{metric}",
|
306
|
+
labels: tags.keys
|
307
|
+
)
|
308
|
+
histogram.observe(value, labels: tags)
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
# Tracing backends
|
314
|
+
module Traces
|
315
|
+
def self.for(backend)
|
316
|
+
case backend
|
317
|
+
when :opentelemetry then OpenTelemetry.new
|
318
|
+
when :datadog then DatadogAPM.new
|
319
|
+
when :memory then Memory.new
|
320
|
+
else
|
321
|
+
raise ArgumentError, "Unknown trace backend: #{backend}"
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
# OpenTelemetry tracing
|
326
|
+
class OpenTelemetry
|
327
|
+
def initialize
|
328
|
+
require "opentelemetry-sdk"
|
329
|
+
@tracer = ::OpenTelemetry.tracer_provider.tracer("attio-ruby")
|
330
|
+
rescue LoadError
|
331
|
+
raise "Please add 'opentelemetry-sdk' to your Gemfile"
|
332
|
+
end
|
333
|
+
|
334
|
+
def span(name, &block)
|
335
|
+
@tracer.in_span(name, &block)
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
# Datadog APM tracing
|
340
|
+
class DatadogAPM
|
341
|
+
def initialize
|
342
|
+
require "datadog"
|
343
|
+
@tracer = ::Datadog::Tracing
|
344
|
+
rescue LoadError
|
345
|
+
raise "Please add 'datadog' to your Gemfile"
|
346
|
+
end
|
347
|
+
|
348
|
+
def span(name)
|
349
|
+
@tracer.trace(name) do |span|
|
350
|
+
yield(span) if block_given?
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# In-memory tracing for testing
|
356
|
+
class Memory
|
357
|
+
attr_reader :spans
|
358
|
+
|
359
|
+
def initialize
|
360
|
+
@spans = []
|
361
|
+
end
|
362
|
+
|
363
|
+
def span(name)
|
364
|
+
span = Span.new(name)
|
365
|
+
@spans << span
|
366
|
+
yield(span) if block_given?
|
367
|
+
span
|
368
|
+
end
|
369
|
+
|
370
|
+
def reset!
|
371
|
+
@spans.clear
|
372
|
+
end
|
373
|
+
|
374
|
+
class Span
|
375
|
+
attr_reader :name, :attributes
|
376
|
+
|
377
|
+
def initialize(name)
|
378
|
+
@name = name
|
379
|
+
@attributes = {}
|
380
|
+
end
|
381
|
+
|
382
|
+
def set_attribute(key, value)
|
383
|
+
@attributes[key] = value
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
# Middleware for automatic instrumentation
|
390
|
+
class Middleware
|
391
|
+
def initialize(app, instrumentation)
|
392
|
+
@app = app
|
393
|
+
@instrumentation = instrumentation
|
394
|
+
end
|
395
|
+
|
396
|
+
def call(env)
|
397
|
+
start_time = Time.now
|
398
|
+
method = env[:method]
|
399
|
+
path = env[:url].path
|
400
|
+
|
401
|
+
begin
|
402
|
+
response = @app.call(env)
|
403
|
+
|
404
|
+
@instrumentation.record_api_call(
|
405
|
+
method: method,
|
406
|
+
path: path,
|
407
|
+
duration: Time.now - start_time,
|
408
|
+
status: response.status
|
409
|
+
)
|
410
|
+
|
411
|
+
response
|
412
|
+
rescue StandardError => e
|
413
|
+
@instrumentation.record_api_call(
|
414
|
+
method: method,
|
415
|
+
path: path,
|
416
|
+
duration: Time.now - start_time,
|
417
|
+
error: e
|
418
|
+
)
|
419
|
+
raise
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|
424
|
+
end
|
@@ -0,0 +1,212 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attio
|
4
|
+
# Rate limiter with intelligent retry and backoff strategies
|
5
|
+
#
|
6
|
+
# @example Using the rate limiter
|
7
|
+
# limiter = Attio::RateLimiter.new(
|
8
|
+
# max_requests: 100,
|
9
|
+
# window_seconds: 60,
|
10
|
+
# max_retries: 3
|
11
|
+
# )
|
12
|
+
#
|
13
|
+
# limiter.execute { client.records.list }
|
14
|
+
class RateLimiter
|
15
|
+
attr_reader :max_requests, :window_seconds, :max_retries
|
16
|
+
attr_accessor :current_limit, :remaining, :reset_at
|
17
|
+
|
18
|
+
# Initialize a new rate limiter
|
19
|
+
#
|
20
|
+
# @param max_requests [Integer] Maximum requests per window
|
21
|
+
# @param window_seconds [Integer] Time window in seconds
|
22
|
+
# @param max_retries [Integer] Maximum retry attempts
|
23
|
+
# @param enable_jitter [Boolean] Add jitter to backoff delays
|
24
|
+
def initialize(max_requests: 1000, window_seconds: 3600, max_retries: 3, enable_jitter: true)
|
25
|
+
@max_requests = max_requests
|
26
|
+
@window_seconds = window_seconds
|
27
|
+
@max_retries = max_retries
|
28
|
+
@enable_jitter = enable_jitter
|
29
|
+
|
30
|
+
@current_limit = max_requests
|
31
|
+
@remaining = max_requests
|
32
|
+
@reset_at = Time.now + window_seconds
|
33
|
+
|
34
|
+
@mutex = Mutex.new
|
35
|
+
@request_queue = []
|
36
|
+
@request_times = []
|
37
|
+
end
|
38
|
+
|
39
|
+
# Execute a block with rate limiting
|
40
|
+
#
|
41
|
+
# @yield The block to execute
|
42
|
+
# @return The result of the block
|
43
|
+
def execute
|
44
|
+
raise ArgumentError, "Block required" unless block_given?
|
45
|
+
|
46
|
+
@mutex.synchronize do
|
47
|
+
wait_if_needed
|
48
|
+
track_request
|
49
|
+
end
|
50
|
+
|
51
|
+
attempt = 0
|
52
|
+
begin
|
53
|
+
result = yield
|
54
|
+
# Thread-safe header update
|
55
|
+
@mutex.synchronize do
|
56
|
+
update_from_headers(result) if result.is_a?(Hash) && result["_headers"]
|
57
|
+
end
|
58
|
+
result
|
59
|
+
rescue Attio::RateLimitError => e
|
60
|
+
attempt += 1
|
61
|
+
raise e unless attempt <= @max_retries
|
62
|
+
|
63
|
+
wait_time = calculate_backoff(attempt, e)
|
64
|
+
sleep(wait_time)
|
65
|
+
retry
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Check if rate limit is exceeded
|
70
|
+
#
|
71
|
+
# @return [Boolean] True if rate limit would be exceeded
|
72
|
+
def rate_limited?
|
73
|
+
@mutex.synchronize do
|
74
|
+
cleanup_old_requests
|
75
|
+
@request_times.size >= @max_requests
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Get current rate limit status
|
80
|
+
#
|
81
|
+
# @return [Hash] Current status
|
82
|
+
def status
|
83
|
+
@mutex.synchronize do
|
84
|
+
cleanup_old_requests
|
85
|
+
{
|
86
|
+
limit: @current_limit,
|
87
|
+
remaining: [@remaining, @max_requests - @request_times.size].min,
|
88
|
+
reset_at: @reset_at,
|
89
|
+
reset_in: [@reset_at - Time.now, 0].max.to_i,
|
90
|
+
current_usage: @request_times.size,
|
91
|
+
}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Update rate limit info from response headers
|
96
|
+
# NOTE: This method should be called within a mutex lock
|
97
|
+
#
|
98
|
+
# @param response [Hash] Response containing headers
|
99
|
+
private def update_from_headers(response)
|
100
|
+
return unless response.is_a?(Hash)
|
101
|
+
|
102
|
+
headers = response["_headers"] || {}
|
103
|
+
|
104
|
+
@current_limit = headers["x-ratelimit-limit"].to_i if headers["x-ratelimit-limit"]
|
105
|
+
@remaining = headers["x-ratelimit-remaining"].to_i if headers["x-ratelimit-remaining"]
|
106
|
+
@reset_at = Time.at(headers["x-ratelimit-reset"].to_i) if headers["x-ratelimit-reset"]
|
107
|
+
end
|
108
|
+
|
109
|
+
# Reset the rate limiter
|
110
|
+
def reset!
|
111
|
+
@mutex.synchronize do
|
112
|
+
@request_times.clear
|
113
|
+
@remaining = @max_requests
|
114
|
+
@reset_at = Time.now + @window_seconds
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Queue a request for later execution
|
119
|
+
#
|
120
|
+
# @param priority [Integer] Priority (lower = higher priority)
|
121
|
+
# @yield Block to execute
|
122
|
+
def queue_request(priority: 5, &block)
|
123
|
+
@mutex.synchronize do
|
124
|
+
@request_queue << { priority: priority, block: block, queued_at: Time.now }
|
125
|
+
@request_queue.sort_by! { |r| [r[:priority], r[:queued_at]] }
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Process queued requests
|
130
|
+
#
|
131
|
+
# @param max_per_batch [Integer] Maximum requests to process
|
132
|
+
# @return [Array] Results from processed requests
|
133
|
+
def process_queue(max_per_batch: 10)
|
134
|
+
results = []
|
135
|
+
processed = 0
|
136
|
+
|
137
|
+
while processed < max_per_batch
|
138
|
+
request = @mutex.synchronize { @request_queue.shift }
|
139
|
+
break unless request
|
140
|
+
|
141
|
+
begin
|
142
|
+
result = execute(&request[:block])
|
143
|
+
results << { success: true, result: result }
|
144
|
+
rescue StandardError => e
|
145
|
+
results << { success: false, error: e }
|
146
|
+
end
|
147
|
+
|
148
|
+
processed += 1
|
149
|
+
end
|
150
|
+
|
151
|
+
results
|
152
|
+
end
|
153
|
+
|
154
|
+
private def wait_if_needed
|
155
|
+
cleanup_old_requests
|
156
|
+
|
157
|
+
if @request_times.size >= @max_requests
|
158
|
+
wait_time = @request_times.first + @window_seconds - Time.now
|
159
|
+
if wait_time > 0
|
160
|
+
sleep(wait_time)
|
161
|
+
cleanup_old_requests
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
return unless @remaining <= 0 && @reset_at > Time.now
|
166
|
+
|
167
|
+
wait_time = @reset_at - Time.now
|
168
|
+
sleep(wait_time) if wait_time > 0
|
169
|
+
end
|
170
|
+
|
171
|
+
private def track_request
|
172
|
+
@request_times << Time.now
|
173
|
+
@remaining = [@remaining - 1, 0].max
|
174
|
+
end
|
175
|
+
|
176
|
+
private def cleanup_old_requests
|
177
|
+
cutoff = Time.now - @window_seconds
|
178
|
+
@request_times.reject! { |time| time < cutoff }
|
179
|
+
end
|
180
|
+
|
181
|
+
private def calculate_backoff(attempt, error = nil)
|
182
|
+
base_wait = 2**attempt
|
183
|
+
|
184
|
+
# Use server-provided retry-after if available
|
185
|
+
base_wait = error.retry_after if error && error.respond_to?(:retry_after) && error.retry_after
|
186
|
+
|
187
|
+
# Add jitter to prevent thundering herd
|
188
|
+
if @enable_jitter
|
189
|
+
jitter = rand * base_wait * 0.1
|
190
|
+
base_wait + jitter
|
191
|
+
else
|
192
|
+
base_wait
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
# Middleware for automatic rate limiting
|
198
|
+
class RateLimitMiddleware
|
199
|
+
def initialize(app, rate_limiter)
|
200
|
+
@app = app
|
201
|
+
@rate_limiter = rate_limiter
|
202
|
+
end
|
203
|
+
|
204
|
+
def call(env)
|
205
|
+
@rate_limiter.execute do
|
206
|
+
response = @app.call(env)
|
207
|
+
# Headers are automatically updated within execute block
|
208
|
+
response
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|