airtable_client
A resilient Ruby client for the Airtable Web API with zero runtime dependencies.
Built on the Ruby standard library (Net::HTTP), with the operational behaviour you need when Airtable sits in a production path:
- Persistent connections — each table keeps a keep-alive connection, reconnecting transparently when it drops
- 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
- Automatic retries — HTTP 429/503 responses retry up to 3 times with exponential backoff and full jitter
-
Error classification — every API error raises
AirtableClient::Errorwith atype(matching airtable.js naming) andstatus_code - Batch operations — create/update/delete in bulk with automatic chunking (configurable, default 10 per request) and per-record partial-failure reporting
-
Upsert — find-or-create in one call via Airtable's
performUpsert -
Pluggable observability — bring your own logger and metrics via
AirtableClient.configure
Contents
- Installation
- Authentication
- Reading records
- Writing records
- Batch operations
- Error handling
- Configuration
- Thread safety
- Staying in sync with the Airtable API
- Documentation
- Development
Installation
Add to your Gemfile:
gem 'airtable_client'Or install directly:
$ gem install airtable_clientRequires Ruby 3.1+.
Moving from the airtable gem
airtable_client uses its own namespace, so it does not conflict with the old
airtable gem. Table and record methods keep their names. Change the entry
points:
airtable gem |
airtable_client |
|---|---|
require 'airtable' |
require 'airtable_client' |
Airtable::Client.new(token) |
AirtableClient.new(token) |
Airtable::Error |
AirtableClient::Error |
Authentication
Create a personal access token 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:
require 'airtable_client'
client = AirtableClient.new(ENV.fetch('AIRTABLE_ACCESS_TOKEN'))
table = client.table('appXXXXXXXXXXXXXX', 'Table Name')Reading records
# One page (up to 100 records), with sorting
records = table.records(sort: ['Name', :asc], limit: 50)
records.first[:name] # => "Bill Lowry"
records.offset # => pagination offset for the next page
# Every record in the table (paginates for you)
all = table.all(sort: ['Name', :asc])
# Filter with a formula, select specific fields, scope to a view
active = table.select(
formula: 'Active = 1',
fields: %w[Name Email],
view: 'Main View',
sort: ['Order', 'asc']
)
# A single record by id
record = table.find('rec02sKGVIzU65eV2')When interpolating user input into a formula, escape it:
formula = "{Email} = #{AirtableClient.escape_formula_value(user_email)}"
table.select(formula: formula)Writing records
# Create
record = AirtableClient::Record.new(name: 'Sarah Jaine', email: 'sarah@jaine.com')
table.create(record)
record.id # => "rec03sKOVIzU65eV4"
# Full replace (PUT)
record[:email] = 'sarahjaine@updated.com'
table.update(record)
# Partial update (PATCH) — only the given fields change
table.update_record_fields('rec03sKOVIzU65eV4', 'Email' => 'new@example.com')
# Delete
table.destroy('rec03sKOVIzU65eV4')Batch operations
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:
records = names.map { |n| AirtableClient::Record.new(name: n) }
result = table.create_batch(records)
result.successes # => [AirtableClient::Record, ...]
result.failures # => [[record, AirtableClient::Error], ...]
table.update_batch(records) # PATCH, records need ids
table.destroy_batch(record_ids) # by idUpsert
result = table.upsert(records, fields_to_merge_on: ['Email'])
result.created_record_ids # ids that were newly created rather than updatedError handling
API failures raise AirtableClient::Error:
begin
table.find('recDoesNotExist')
rescue AirtableClient::Error => e
e.type # => "NOT_FOUND" (Airtable's type when given, else classified from the status)
e.status_code # => 404
e.message # => "Record not found"
endTypes 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.
Configuration
AirtableClient.configure do |config|
# Anything responding to debug/info/warn. Without one, info/warn lines
# go to $stderr and debug (connection lifecycle) is suppressed.
config.logger = Logger.new($stdout)
# Records per batch request (default 10). Airtable's docs no longer state
# the cap — verify empirically before raising this.
config.batch_size = 10
# Called after every API response — wire up metrics here.
config.on_request = lambda do |event|
# event: { status_code:, table:, http_method:, duration_ms:,
# request_body_size:, response_body_size:,
# error_type:, error_message: }
StatsD.increment('airtable.request', tags: ["status:#{event[:status_code]}"])
end
endThread safety
-
AirtableClient::Tableholds a persistent connection and is not thread-safe — give each thread its own instance. - 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.
Staying in sync with the Airtable API
Two weekly scheduled workflows guard against silent drift between this gem and the live API:
-
Contract drift re-extracts Airtable's published OpenAPI contract (
ruby tools/extract_api_contract.rb) and compares it to the committed snapshot indocs/api/.contract/. Divergence opens anapi-driftissue containing the diff. -
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 asmoke-failureissue. It is credential-gated and never runs on pull requests.
Green silence on Monday mornings means the documented contract is unchanged and the gem still works against the real thing.
Documentation
- Getting started — the guided tour
- How-to recipes — Rails/Sidekiq, testing your app, pagination, partial batch failures
- Architecture — how the gem is built and why
- Airtable Web API reference — the official API contract, extracted locally
Development
$ bundle install
$ bundle exec rake # runs the minitest suite (WebMock — no live API calls)See CONTRIBUTING.md for conventions and SECURITY.md for reporting vulnerabilities.
Acknowledgements
Forked from 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.