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 +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE.txt +23 -0
- data/README.md +211 -0
- data/lib/airtable_client/batch_result.rb +55 -0
- data/lib/airtable_client/configuration.rb +75 -0
- data/lib/airtable_client/error.rb +87 -0
- data/lib/airtable_client/rate_limiter.rb +110 -0
- data/lib/airtable_client/record.rb +110 -0
- data/lib/airtable_client/record_set.rb +34 -0
- data/lib/airtable_client/resource.rb +187 -0
- data/lib/airtable_client/table.rb +402 -0
- data/lib/airtable_client/version.rb +3 -0
- data/lib/airtable_client.rb +56 -0
- metadata +114 -0
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
|
+
[](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
|