airtable_client 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eeda5df571d963886f3ff4a2875c7571fcbdce7f123b4aab42c63ee195aa8d15
4
+ data.tar.gz: 6df2779eddb32879cfef700caebdfeed3d489646b8926e1789a08ad897525ef5
5
+ SHA512:
6
+ metadata.gz: 44396ef7a183abc528738f152e141563cfca588cbc726f6dcd6ccb68087e5f31e760385f554ca4c9274099c79f6f5aef6c0e47460fe5ab3b4ae6c4c28a81cdc4
7
+ data.tar.gz: bd6a6ea475f522913a4864e13a1538d8753c7ec85321fcbee371fe3fb238a61cfb655268d7112f7bc464ab43eb8ccf3940d8b6aecfac6adfbc5900f19e0cd870
data/CHANGELOG.md ADDED
@@ -0,0 +1,57 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-30
11
+
12
+ First release under the `airtable_client` name. This gem is a fork of
13
+ [nesquena/airtable-ruby](https://github.com/nesquena/airtable-ruby), heavily
14
+ reworked.
15
+
16
+ ### Added
17
+
18
+ - Contract-drift watch: `tools/extract_api_contract.rb` snapshots Airtable's
19
+ published OpenAPI contract into `docs/api/.contract/`, and a weekly
20
+ workflow opens an `api-drift` issue when the live contract diverges.
21
+ - Credential-gated live smoke suite (`bundle exec rake smoke`) exercising a
22
+ real record round-trip; runs weekly once the smoke secrets are configured
23
+ and skips cleanly until then.
24
+ - Client-side thread-safe sliding-window rate limiter: 5 requests per second
25
+ per base.
26
+ - Automatic retry on HTTP 429 and 503 with exponential backoff and full jitter
27
+ (maximum 3 attempts).
28
+ - Automatic reconnect on dropped connections.
29
+ - Error classification via `AirtableClient::Error`, exposing `type` and
30
+ `status_code`, with type names matching airtable.js status-code types.
31
+ - Batch create, update, and delete with automatic chunking (configurable
32
+ via `batch_size`, default 10) and
33
+ `BatchResult` partial-failure reporting.
34
+ - Upsert support via `performUpsert`.
35
+ - Formula-value escaping helper: `AirtableClient.escape_formula_value`.
36
+ - Pluggable configuration via `AirtableClient.configure`: a logger and an
37
+ `on_request` instrumentation callback.
38
+
39
+ ### Changed
40
+
41
+ - Namespace renamed from `Airtable` to `AirtableClient`, and the gem from
42
+ `airtable` to `airtable_client`. `AirtableClient.new(token)` replaces
43
+ `Airtable::Client.new(token)`; nested classes move with it
44
+ (`AirtableClient::Table`, `AirtableClient::Error`). The gem no longer
45
+ conflicts with the `airtable` gem.
46
+ - HTTP transport replaced: HTTParty removed in favour of Ruby's built-in
47
+ `Net::HTTP`, with persistent connections.
48
+ - The gem now has zero runtime dependencies and requires Ruby >= 3.1 (tested
49
+ on 3.1, 3.2, 3.3, and 3.4).
50
+
51
+ ### Removed
52
+
53
+ - ActiveSupport runtime dependency.
54
+ - NewRelic and Rails coupling.
55
+
56
+ [unreleased]: https://github.com/Davidslv/airtable_client/compare/v0.1.0...HEAD
57
+ [0.1.0]: https://github.com/Davidslv/airtable_client/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,23 @@
1
+ Copyright (c) 2015 Nathan Esquenazi
2
+ Copyright (c) 2016 Airtable
3
+
4
+ MIT License
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining
7
+ a copy of this software and associated documentation files (the
8
+ "Software"), to deal in the Software without restriction, including
9
+ without limitation the rights to use, copy, modify, merge, publish,
10
+ distribute, sublicense, and/or sell copies of the Software, and to
11
+ permit persons to whom the Software is furnished to do so, subject to
12
+ the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
19
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
21
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
22
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # airtable_client
2
+
3
+ [![CI](https://github.com/Davidslv/airtable_client/actions/workflows/ci.yml/badge.svg)](https://github.com/Davidslv/airtable_client/actions/workflows/ci.yml)
4
+
5
+ A resilient Ruby client for the [Airtable Web API](https://airtable.com/developers/web/api/introduction) with **zero runtime dependencies**.
6
+
7
+ Built on the Ruby standard library (`Net::HTTP`), with the operational behaviour you need when Airtable sits in a production path:
8
+
9
+ - **Persistent connections** — each table keeps a keep-alive connection, reconnecting transparently when it drops
10
+ - **Client-side rate limiting** — a thread-safe sliding-window limiter holds you under Airtable's 5 requests/second/base cap instead of bouncing off 429s
11
+ - **Automatic retries** — HTTP 429/503 responses retry up to 3 times with exponential backoff and full jitter
12
+ - **Error classification** — every API error raises `AirtableClient::Error` with a `type` (matching [airtable.js](https://github.com/Airtable/airtable.js) naming) and `status_code`
13
+ - **Batch operations** — create/update/delete in bulk with automatic chunking (configurable, default 10 per request) and per-record partial-failure reporting
14
+ - **Upsert** — find-or-create in one call via Airtable's `performUpsert`
15
+ - **Pluggable observability** — bring your own logger and metrics via `AirtableClient.configure`
16
+
17
+ ## Contents
18
+
19
+ - [Installation](#installation)
20
+ - [Authentication](#authentication)
21
+ - [Reading records](#reading-records)
22
+ - [Writing records](#writing-records)
23
+ - [Batch operations](#batch-operations)
24
+ - [Error handling](#error-handling)
25
+ - [Configuration](#configuration)
26
+ - [Thread safety](#thread-safety)
27
+ - [Staying in sync with the Airtable API](#staying-in-sync-with-the-airtable-api)
28
+ - [Documentation](#documentation)
29
+ - [Development](#development)
30
+
31
+ ## Installation
32
+
33
+ Add to your Gemfile:
34
+
35
+ ```ruby
36
+ gem 'airtable_client'
37
+ ```
38
+
39
+ Or install directly:
40
+
41
+ ```console
42
+ $ gem install airtable_client
43
+ ```
44
+
45
+ Requires Ruby 3.1+.
46
+
47
+ ### Moving from the `airtable` gem
48
+
49
+ `airtable_client` uses its own namespace, so it does not conflict with the old
50
+ `airtable` gem. Table and record methods keep their names. Change the entry
51
+ points:
52
+
53
+ | `airtable` gem | `airtable_client` |
54
+ | --- | --- |
55
+ | `require 'airtable'` | `require 'airtable_client'` |
56
+ | `Airtable::Client.new(token)` | `AirtableClient.new(token)` |
57
+ | `Airtable::Error` | `AirtableClient::Error` |
58
+
59
+ ## Authentication
60
+
61
+ Create a [personal access token](https://airtable.com/create/tokens) with the scopes you need (typically `data.records:read` and `data.records:write`) and access to your base. Keep it out of source control — read it from the environment:
62
+
63
+ ```ruby
64
+ require 'airtable_client'
65
+
66
+ client = AirtableClient.new(ENV.fetch('AIRTABLE_ACCESS_TOKEN'))
67
+ table = client.table('appXXXXXXXXXXXXXX', 'Table Name')
68
+ ```
69
+
70
+ ## Reading records
71
+
72
+ ```ruby
73
+ # One page (up to 100 records), with sorting
74
+ records = table.records(sort: ['Name', :asc], limit: 50)
75
+ records.first[:name] # => "Bill Lowry"
76
+ records.offset # => pagination offset for the next page
77
+
78
+ # Every record in the table (paginates for you)
79
+ all = table.all(sort: ['Name', :asc])
80
+
81
+ # Filter with a formula, select specific fields, scope to a view
82
+ active = table.select(
83
+ formula: 'Active = 1',
84
+ fields: %w[Name Email],
85
+ view: 'Main View',
86
+ sort: ['Order', 'asc']
87
+ )
88
+
89
+ # A single record by id
90
+ record = table.find('rec02sKGVIzU65eV2')
91
+ ```
92
+
93
+ When interpolating user input into a formula, escape it:
94
+
95
+ ```ruby
96
+ formula = "{Email} = #{AirtableClient.escape_formula_value(user_email)}"
97
+ table.select(formula: formula)
98
+ ```
99
+
100
+ ## Writing records
101
+
102
+ ```ruby
103
+ # Create
104
+ record = AirtableClient::Record.new(name: 'Sarah Jaine', email: 'sarah@jaine.com')
105
+ table.create(record)
106
+ record.id # => "rec03sKOVIzU65eV4"
107
+
108
+ # Full replace (PUT)
109
+ record[:email] = 'sarahjaine@updated.com'
110
+ table.update(record)
111
+
112
+ # Partial update (PATCH) — only the given fields change
113
+ table.update_record_fields('rec03sKOVIzU65eV4', 'Email' => 'new@example.com')
114
+
115
+ # Delete
116
+ table.destroy('rec03sKOVIzU65eV4')
117
+ ```
118
+
119
+ ## Batch operations
120
+
121
+ Batch methods chunk into groups of `AirtableClient.configuration.batch_size` — default 10, Airtable's long-documented per-request maximum — and return an `AirtableClient::BatchResult` instead of raising on partial failure:
122
+
123
+ ```ruby
124
+ records = names.map { |n| AirtableClient::Record.new(name: n) }
125
+ result = table.create_batch(records)
126
+
127
+ result.successes # => [AirtableClient::Record, ...]
128
+ result.failures # => [[record, AirtableClient::Error], ...]
129
+
130
+ table.update_batch(records) # PATCH, records need ids
131
+ table.destroy_batch(record_ids) # by id
132
+ ```
133
+
134
+ ### Upsert
135
+
136
+ ```ruby
137
+ result = table.upsert(records, fields_to_merge_on: ['Email'])
138
+ result.created_record_ids # ids that were newly created rather than updated
139
+ ```
140
+
141
+ ## Error handling
142
+
143
+ API failures raise `AirtableClient::Error`:
144
+
145
+ ```ruby
146
+ begin
147
+ table.find('recDoesNotExist')
148
+ rescue AirtableClient::Error => e
149
+ e.type # => "NOT_FOUND" (Airtable's type when given, else classified from the status)
150
+ e.status_code # => 404
151
+ e.message # => "Record not found"
152
+ end
153
+ ```
154
+
155
+ Types follow airtable.js: `AUTHENTICATION_REQUIRED` (401), `NOT_AUTHORIZED` (403), `NOT_FOUND` (404), `INVALID_REQUEST` (422), `TOO_MANY_REQUESTS` (429), `SERVER_ERROR` (500), `SERVICE_UNAVAILABLE` (503). Note that 429/503 are retried automatically before they ever raise.
156
+
157
+ ## Configuration
158
+
159
+ ```ruby
160
+ AirtableClient.configure do |config|
161
+ # Anything responding to debug/info/warn. Without one, info/warn lines
162
+ # go to $stderr and debug (connection lifecycle) is suppressed.
163
+ config.logger = Logger.new($stdout)
164
+
165
+ # Records per batch request (default 10). Airtable's docs no longer state
166
+ # the cap — verify empirically before raising this.
167
+ config.batch_size = 10
168
+
169
+ # Called after every API response — wire up metrics here.
170
+ config.on_request = lambda do |event|
171
+ # event: { status_code:, table:, http_method:, duration_ms:,
172
+ # request_body_size:, response_body_size:,
173
+ # error_type:, error_message: }
174
+ StatsD.increment('airtable.request', tags: ["status:#{event[:status_code]}"])
175
+ end
176
+ end
177
+ ```
178
+
179
+ ## Thread safety
180
+
181
+ - `AirtableClient::Table` holds a persistent connection and is **not** thread-safe — give each thread its own instance.
182
+ - The rate limiter is process-global, thread-safe, and shared across all tables keyed by base, so concurrent threads collectively respect the 5 rps/base limit.
183
+
184
+ ## Staying in sync with the Airtable API
185
+
186
+ Two weekly scheduled workflows guard against silent drift between this gem and the live API:
187
+
188
+ - **Contract drift** re-extracts Airtable's published OpenAPI contract (`ruby tools/extract_api_contract.rb`) and compares it to the committed snapshot in `docs/api/.contract/`. Divergence opens an [`api-drift` issue](https://github.com/Davidslv/airtable_client/issues?q=label%3Aapi-drift) containing the diff.
189
+ - **Live smoke** (`bundle exec rake smoke`) runs a real record round-trip — create, find, update, batch, upsert, delete — against a dedicated throwaway base. Failure opens a [`smoke-failure` issue](https://github.com/Davidslv/airtable_client/issues?q=label%3Asmoke-failure). It is credential-gated and never runs on pull requests.
190
+
191
+ Green silence on Monday mornings means the documented contract is unchanged and the gem still works against the real thing.
192
+
193
+ ## Documentation
194
+
195
+ - [Getting started](docs/getting-started.md) — the guided tour
196
+ - [How-to recipes](docs/how-to.md) — Rails/Sidekiq, testing your app, pagination, partial batch failures
197
+ - [Architecture](docs/architecture.md) — how the gem is built and why
198
+ - [Airtable Web API reference](docs/api/) — the official API contract, extracted locally
199
+
200
+ ## Development
201
+
202
+ ```console
203
+ $ bundle install
204
+ $ bundle exec rake # runs the minitest suite (WebMock — no live API calls)
205
+ ```
206
+
207
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
208
+
209
+ ## Acknowledgements
210
+
211
+ Forked from [nesquena/airtable-ruby](https://github.com/nesquena/airtable-ruby) by Nathan Esquenazi and Alexander Sorokin, then substantially reworked: HTTParty replaced with persistent `Net::HTTP`, rate limiting, retries, error classification, batch/upsert support, and the removal of all runtime dependencies. Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,55 @@
1
+ class AirtableClient
2
+ # The outcome of a batch operation ({Table#create_batch},
3
+ # {Table#update_batch}, {Table#destroy_batch}, {Table#upsert}).
4
+ #
5
+ # Batch operations send one request per chunk of
6
+ # {Configuration#batch_size} records and never raise on per-chunk API
7
+ # errors — successes and failures are collected here so the caller decides
8
+ # how to handle partial failure.
9
+ #
10
+ # @example
11
+ # result = table.create_batch(records)
12
+ # unless result.all_succeeded?
13
+ # result.failures.each { |f| retry_later(f[:record], f[:error]) }
14
+ # end
15
+ class BatchResult
16
+ # @return [Array<Record>] records the API accepted (for destroy_batch:
17
+ # the raw response hashes of deleted records)
18
+ attr_reader :successes
19
+
20
+ # @return [Array<Hash>] one +{ record:, error: }+ hash per failed input;
21
+ # +:record+ is the original input (a {Record} or, for destroy_batch,
22
+ # an id string) and +:error+ is the {Error}
23
+ attr_reader :failures
24
+
25
+ # @return [Array<String>] for {Table#upsert} only: ids of records that
26
+ # were newly created rather than updated
27
+ attr_reader :created_record_ids
28
+
29
+ def initialize
30
+ @successes = []
31
+ @failures = []
32
+ @created_record_ids = []
33
+ end
34
+
35
+ # @api private
36
+ def add_success(record)
37
+ @successes << record
38
+ end
39
+
40
+ # @api private
41
+ def add_failure(record_or_input, error)
42
+ @failures << { record: record_or_input, error: error }
43
+ end
44
+
45
+ # @api private
46
+ def add_created_ids(ids)
47
+ @created_record_ids.concat(ids)
48
+ end
49
+
50
+ # @return [Boolean] true when no input failed
51
+ def all_succeeded?
52
+ @failures.empty?
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,75 @@
1
+ class AirtableClient
2
+ # Process-global gem configuration, set through {AirtableClient.configure}.
3
+ #
4
+ # @example
5
+ # AirtableClient.configure do |config|
6
+ # config.logger = Rails.logger
7
+ # config.on_request = ->(event) { StatsD.increment('airtable.request', tags: event) }
8
+ # config.batch_size = 10
9
+ # end
10
+ class Configuration
11
+ # Records per request for batch operations — Airtable's long-documented
12
+ # per-request maximum.
13
+ DEFAULT_BATCH_SIZE = 10
14
+
15
+ # Any object responding to +debug+/+info+/+warn+ (e.g. a +::Logger+).
16
+ # When nil (the default), info and warn messages are written to $stderr
17
+ # and debug messages (connection lifecycle) are suppressed.
18
+ #
19
+ # @return [Object, nil]
20
+ attr_accessor :logger
21
+
22
+ # Optional callable invoked after every API response — the hook for
23
+ # wiring metrics (StatsD, OpenTelemetry, NewRelic, ...). Receives one
24
+ # event hash: +{ status_code:, table:, http_method:, duration_ms:,
25
+ # request_body_size:, response_body_size:, error_type:, error_message: }+.
26
+ #
27
+ # @return [#call, nil]
28
+ attr_accessor :on_request
29
+
30
+ # How many records batch operations send per request (default
31
+ # {DEFAULT_BATCH_SIZE}). The current API docs no longer state the cap,
32
+ # so this is configurable — verify empirically before raising it, as
33
+ # oversized batches are rejected by the API.
34
+ #
35
+ # @return [Integer]
36
+ attr_reader :batch_size
37
+
38
+ def initialize
39
+ @logger = nil
40
+ @on_request = nil
41
+ @batch_size = DEFAULT_BATCH_SIZE
42
+ end
43
+
44
+ # @param value [Integer] a positive number of records per batch request
45
+ # @raise [ArgumentError] unless value is a positive Integer
46
+ def batch_size=(value)
47
+ unless value.is_a?(Integer) && value.positive?
48
+ raise ArgumentError, "batch_size must be a positive Integer, got #{value.inspect}"
49
+ end
50
+
51
+ @batch_size = value
52
+ end
53
+ end
54
+
55
+ class << self
56
+ # @return [Configuration] the process-global configuration
57
+ def configuration
58
+ @configuration ||= Configuration.new
59
+ end
60
+
61
+ # Yields the global configuration for setup.
62
+ #
63
+ # @yieldparam config [Configuration]
64
+ # @example
65
+ # AirtableClient.configure { |config| config.logger = Logger.new($stdout) }
66
+ def configure
67
+ yield(configuration)
68
+ end
69
+
70
+ # Restores the default configuration. Mainly useful in tests.
71
+ def reset_configuration!
72
+ @configuration = Configuration.new
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,87 @@
1
+
2
+ class AirtableClient
3
+ # Raised for every Airtable API error. Carries the Airtable error type and
4
+ # the HTTP status, so callers can branch on the failure mode.
5
+ #
6
+ # Note that HTTP 429 and 503 are retried automatically (up to 3 attempts)
7
+ # before this is raised.
8
+ #
9
+ # @example
10
+ # begin
11
+ # table.find('recMissing')
12
+ # rescue AirtableClient::Error => e
13
+ # e.type # => "NOT_FOUND"
14
+ # e.status_code # => 404
15
+ # end
16
+ class Error < StandardError
17
+
18
+ # @return [String, nil] human-readable message from the API
19
+ attr_reader :message
20
+
21
+ # @return [String, nil] Airtable's error type when the response includes
22
+ # one (e.g. +"UNKNOWN_COLUMN_NAME"+), otherwise classified from the
23
+ # status code via {STATUS_CODE_ERROR_TYPES}
24
+ attr_reader :type
25
+
26
+ # @return [Integer, nil] the HTTP status code
27
+ attr_reader :status_code
28
+
29
+ # Maps HTTP status codes to named error types, matching the official airtable.js error handler.
30
+ # Used as a fallback when the response body doesn't include a type.
31
+ STATUS_CODE_ERROR_TYPES = {
32
+ 401 => 'AUTHENTICATION_REQUIRED',
33
+ 403 => 'NOT_AUTHORIZED',
34
+ 404 => 'NOT_FOUND',
35
+ 422 => 'INVALID_REQUEST',
36
+ 429 => 'TOO_MANY_REQUESTS',
37
+ 500 => 'SERVER_ERROR',
38
+ 503 => 'SERVICE_UNAVAILABLE'
39
+ }.freeze
40
+
41
+ # @param error_hash [Hash] the API's error object,
42
+ # e.g. +{"type" => "UNKNOWN_COLUMN_NAME", "message" => "..."}+
43
+ # @param status_code [Integer, nil] the HTTP status code
44
+ def initialize(error_hash, status_code: nil)
45
+ @message = error_hash['message']
46
+ @type = error_hash['type']
47
+ @status_code = status_code
48
+ super(@message)
49
+ end
50
+
51
+ # Builds an Error from a raw HTTP status code and response body.
52
+ # Attempts JSON parse first; falls back to classifying by status code.
53
+ # The type from the JSON body takes precedence over the status code
54
+ # mapping, because Airtable returns specific types (e.g.
55
+ # UNKNOWN_COLUMN_NAME) that are more informative.
56
+ #
57
+ # @param status_code [Integer]
58
+ # @param body [String, nil] the raw response body
59
+ # @return [Error]
60
+ def self.from_response(status_code, body)
61
+ error_hash = parse_error_body(body)
62
+ error_hash['type'] ||= STATUS_CODE_ERROR_TYPES.fetch(status_code, 'UNKNOWN_ERROR')
63
+ error_hash['message'] ||= default_message_for(status_code, body)
64
+ new(error_hash, status_code: status_code)
65
+ end
66
+
67
+ class << self
68
+ private
69
+
70
+ def parse_error_body(body)
71
+ return {} if body.nil? || body.to_s.strip.empty?
72
+
73
+ parsed = JSON.parse(body)
74
+ parsed.is_a?(Hash) && parsed['error'].is_a?(Hash) ? parsed['error'] : {}
75
+ rescue JSON::ParserError
76
+ {}
77
+ end
78
+
79
+ def default_message_for(status_code, body)
80
+ type = STATUS_CODE_ERROR_TYPES.fetch(status_code, 'UNKNOWN_ERROR')
81
+ truncated = body.to_s[0..200]
82
+ "#{type} (HTTP #{status_code}): #{truncated}"
83
+ end
84
+ end
85
+
86
+ end
87
+ end
@@ -0,0 +1,110 @@
1
+ class AirtableClient
2
+ # Thread-safe sliding window rate limiter.
3
+ #
4
+ # Airtable enforces a limit of 5 requests per second per base. This limiter
5
+ # delays requests that would exceed the limit, preventing 429 responses.
6
+ #
7
+ # The limiter is process-global (singleton) and keyed by base ID (app_token),
8
+ # so all Table instances in the same Sidekiq process share it.
9
+ #
10
+ # Usage is automatic — Table calls RateLimiter.wait! before each request.
11
+ class RateLimiter
12
+ DEFAULT_MAX_REQUESTS = 5
13
+ DEFAULT_WINDOW_SECONDS = 1.0
14
+
15
+ def initialize(max_requests: DEFAULT_MAX_REQUESTS, window_seconds: DEFAULT_WINDOW_SECONDS, clock: nil)
16
+ @max_requests = max_requests
17
+ @window_seconds = window_seconds
18
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
19
+ @buckets = {}
20
+ @buckets_mutex = Mutex.new
21
+ end
22
+
23
+ # Blocks until a request slot is available for the given base.
24
+ #
25
+ # @param base_id [String] the base id the request targets
26
+ # @return [void]
27
+ def wait!(base_id)
28
+ bucket = bucket_for(base_id)
29
+ bucket.wait!
30
+ end
31
+
32
+ class << self
33
+ def instance
34
+ @instance_mutex.synchronize do
35
+ @instance ||= new
36
+ end
37
+ end
38
+
39
+ def instance=(limiter)
40
+ @instance_mutex.synchronize do
41
+ @instance = limiter
42
+ end
43
+ end
44
+
45
+ def reset!
46
+ @instance_mutex.synchronize do
47
+ @instance = nil
48
+ end
49
+ end
50
+ end
51
+
52
+ @instance_mutex = Mutex.new
53
+ @instance = nil
54
+
55
+ private
56
+
57
+ # Note: buckets are created per base_id and never evicted. For typical
58
+ # usage (a handful of bases), this is fine. If the process touches
59
+ # thousands of distinct bases over its lifetime, consider adding LRU
60
+ # eviction or periodic cleanup.
61
+ def bucket_for(base_id)
62
+ @buckets_mutex.synchronize do
63
+ @buckets[base_id] ||= Bucket.new(@max_requests, @window_seconds, @clock)
64
+ end
65
+ end
66
+
67
+ # Per-base sliding window bucket. Tracks timestamps of recent requests
68
+ # and sleeps when the window is full.
69
+ #
70
+ # Thread-safety: the mutex is released during sleep so other threads
71
+ # targeting the same base can proceed once a slot opens.
72
+ class Bucket
73
+ def initialize(max_requests, window_seconds, clock)
74
+ @max_requests = max_requests
75
+ @window_seconds = window_seconds
76
+ @clock = clock
77
+ @timestamps = []
78
+ @mutex = Mutex.new
79
+ end
80
+
81
+ def wait!
82
+ loop do
83
+ sleep_time = nil
84
+
85
+ @mutex.synchronize do
86
+ now = @clock.call
87
+ evict_expired!(now)
88
+
89
+ if @timestamps.length >= @max_requests
90
+ sleep_time = @window_seconds - (now - @timestamps.first)
91
+ sleep_time = 0.001 if sleep_time <= 0
92
+ else
93
+ @timestamps << now
94
+ return
95
+ end
96
+ end
97
+
98
+ # Sleep WITHOUT holding the mutex so other threads aren't blocked
99
+ Kernel.sleep(sleep_time)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def evict_expired!(now)
106
+ @timestamps.reject! { |t| now - t >= @window_seconds }
107
+ end
108
+ end
109
+ end
110
+ end