Skip to content

Instantly share code, notes, and snippets.

@zacharysyoung
Last active March 30, 2023 21:33
Show Gist options
  • Save zacharysyoung/770723808d0e6377d6b126d1c2cec382 to your computer and use it in GitHub Desktop.
Save zacharysyoung/770723808d0e6377d6b126d1c2cec382 to your computer and use it in GitHub Desktop.
Airtable Rate-limiting, concurrent requests

Testing Airtable Rate-limiting

The following code has revealed different behavior over time. When I first tested this, I saw behavior like the following where 50 requests were kicked off at once, all requests completed (with a 200), but subsequent requests took longer and longer. The following output also shows a minimum response time of 1.5s, which I think (I remember) shows that Airtable's dynamic back-offs were sticky:

Submitting 50 requests "at once"

All 50 are sent in the span of one second, but each subsequent request takes progressively longer to respond:

started request 42 at Jul 21 11:23:27.565001, ended in 1.50s
started request 17 at Jul 21 11:23:27.565066, ended in 1.53s
started request 13 at Jul 21 11:23:27.565545, ended in 1.55s
started request  6 at Jul 21 11:23:27.565309, ended in 1.59s
started request 36 at Jul 21 11:23:27.564799, ended in 1.61s
started request 14 at Jul 21 11:23:27.565381, ended in 1.62s
started request 27 at Jul 21 11:23:27.564980, ended in 1.64s
started request 39 at Jul 21 11:23:27.564951, ended in 1.65s
started request 34 at Jul 21 11:23:27.564746, ended in 1.67s
started request 18 at Jul 21 11:23:27.565072, ended in 1.68s
started request 45 at Jul 21 11:23:27.565177, ended in 1.70s
started request 22 at Jul 21 11:23:27.565197, ended in 1.71s
started request 32 at Jul 21 11:23:27.564678, ended in 1.73s
started request 49 at Jul 21 11:23:27.565287, ended in 1.75s
started request  9 at Jul 21 11:23:27.565290, ended in 1.76s
started request 38 at Jul 21 11:23:27.564612, ended in 1.78s
started request 29 at Jul 21 11:23:27.564628, ended in 1.79s
started request 20 at Jul 21 11:23:27.565144, ended in 1.81s
started request  4 at Jul 21 11:23:27.564470, ended in 1.83s
started request 48 at Jul 21 11:23:27.565283, ended in 1.84s
started request  3 at Jul 21 11:23:27.564935, ended in 1.86s
started request  1 at Jul 21 11:23:27.564494, ended in 1.87s
started request 47 at Jul 21 11:23:27.565156, ended in 1.89s
started request 46 at Jul 21 11:23:27.565184, ended in 3.74s
started request 33 at Jul 21 11:23:27.564731, ended in 3.76s
started request 15 at Jul 21 11:23:27.565349, ended in 3.78s
started request 30 at Jul 21 11:23:27.564635, ended in 3.80s
started request 23 at Jul 21 11:23:27.565204, ended in 3.81s
started request  7 at Jul 21 11:23:27.565303, ended in 3.83s
started request 31 at Jul 21 11:23:27.564641, ended in 3.85s
started request 11 at Jul 21 11:23:27.565303, ended in 3.88s
started request 12 at Jul 21 11:23:27.565291, ended in 3.88s
started request 43 at Jul 21 11:23:27.565046, ended in 3.90s
started request 44 at Jul 21 11:23:27.564943, ended in 3.96s
started request 41 at Jul 21 11:23:27.564993, ended in 3.96s
started request 40 at Jul 21 11:23:27.564982, ended in 3.98s
started request 21 at Jul 21 11:23:27.565054, ended in 3.99s
started request 35 at Jul 21 11:23:27.564753, ended in 4.02s
started request 19 at Jul 21 11:23:27.565085, ended in 4.04s
started request  2 at Jul 21 11:23:27.564474, ended in 4.05s
started request 24 at Jul 21 11:23:27.565191, ended in 4.07s
started request  8 at Jul 21 11:23:27.565304, ended in 4.08s
started request 26 at Jul 21 11:23:27.565276, ended in 4.10s
started request 25 at Jul 21 11:23:27.565257, ended in 4.11s
started request 10 at Jul 21 11:23:27.565295, ended in 4.13s
started request 28 at Jul 21 11:23:27.564622, ended in 7.16s
started request 16 at Jul 21 11:23:27.565060, ended in 7.18s
started request 37 at Jul 21 11:23:27.564810, ended in 7.20s
started request  5 at Jul 21 11:23:27.565298, ended in 7.22s
started request 50 at Jul 21 11:23:27.564445, ended in 7.35s
Got 0 bad responses out of 50 requests in 7.3542835s

Now, I get back at least 429 (HTTP rate-limit) responses when running at 50-requests-at-a-pop:

N Resp Min (ms) Mean (ms) Max (ms) Stddev (ms) 200s 429s Errors Other
5 447 476 522 28 5 0 0 0
5 452 494 533 34 5 0 0 0
5 540 670 725 76 5 0 0 0
10 454 526 590 47 10 0 0 0
10 425 504 570 47 10 0 0 0
10 451 510 566 39 10 0 0 0
25 423 655 826 116 25 0 0 0
25 422 593 741 91 25 0 0 0
25 413 615 800 112 25 0 0 0
40 429 759 1035 178 40 0 0 0
40 426 718 968 149 40 0 0 0
40 458 770 1101 202 40 0 0 0
50 432 563 1004 312 40 10 0 0
50 429 566 970 318 40 10 0 0
50 414 562 961 316 40 10 0 0
// Make n-number Airtable API requests, as concurrently as possible.
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
)
const (
api = "https://api.airtable.com/v0"
app = "app3n84SwUnqPsvgC"
tbl = "Table_1"
viw = "Single_record"
URL = api + "/" + app + "/" + tbl + "?view=" + viw
)
type CSVMethod string
const (
csvResults CSVMethod = "results"
csvStats CSVMethod = "stats"
)
func usage() {
fmt.Printf("usage: go run main.go CONFIG_JSON N_REQS [%s|%s]\n", csvResults, csvStats)
os.Exit(1)
}
var (
apiKey string
nReqs int
method CSVMethod
)
func init() {
args := os.Args
if len(args) < 4 {
usage()
}
data, err := os.ReadFile(args[1])
must(err)
// Get API key secret out of a JSON file
var config struct {
APIKey string `json:"APIKey"`
}
err = json.Unmarshal(data, &config)
must(err)
apiKey = config.APIKey
nReqs, err = strconv.Atoi(args[2])
must(err)
method = CSVMethod(args[3])
if method != csvResults && method != csvStats {
usage()
}
}
// GETResult holds stats on an HTTP GET request and its response.
type GETResult struct {
x int // initial order
start time.Time // request start time
code int // response status code
duration time.Duration // how long the response took
err error // any other error while requesting/responding
}
var nilTime = time.Time{}
func (gr GETResult) Header() []string {
return []string{"X", "Err", "Start (Unix µs)", "StatusCode", "Duration (ms)"}
}
func (gr GETResult) ToRecord() []string {
x := strconv.Itoa(gr.x)
err := ""
if gr.err != nil {
err = gr.err.Error()
}
start := strconv.Itoa(int(gr.start.UnixMicro()))
code := strconv.Itoa(gr.code)
dur := strconv.Itoa(int(gr.duration.Milliseconds()))
return []string{x, err, start, code, dur}
}
func main() {
chResult := make(chan GETResult, nReqs)
results := make([]GETResult, nReqs)
// Start concurrent requests and accumulate all responses
for i := 0; i < nReqs; i++ {
go func(x int) {
makeRequest(x, chResult)
}(i)
}
for i := 0; i < nReqs; i++ {
results[i] = <-chResult
}
var records [][]string
switch method {
case csvResults:
records = makeResultsCSV(results)
case csvStats:
records = makeStatsCSV(results)
}
w := csv.NewWriter(os.Stdout)
w.WriteAll(records)
w.Flush()
if err := w.Error(); err != nil {
fmt.Println(err)
}
}
func makeRequest(x int, ch chan<- GETResult) {
gr := GETResult{x: x, start: nilTime, code: -1, duration: -1, err: nil}
req, err := http.NewRequest("GET", URL, nil)
if err != nil {
gr.err = err
ch <- gr
return
}
req.Header.Add("Authorization", "Bearer "+apiKey)
gr.start = time.Now()
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
gr.err = err
ch <- gr
return
}
gr.code = resp.StatusCode
gr.duration = time.Since(gr.start)
ch <- gr
}
func makeResultsCSV(results []GETResult) (records [][]string) {
records = make([][]string, len(results)+1)
records[0] = results[0].Header()
for i, result := range results {
records[i+1] = result.ToRecord()
}
return
}
func makeStatsCSV(results []GETResult) (records [][]string) {
var (
ct = 0
min = math.MaxInt
mean = 0.0
max = math.MinInt
stddev = 0.0
sum = 0.0
ct200 = 0
ct429 = 0
ctErr = 0
ctOther = 0
dur = 0 // milliseconds
durs []float64
durElem = 0.0
)
ct = len(results)
// Loop to count statuses; get min and max; sum for mean; store durs for Stddev
durs = make([]float64, ct)
for i, gr := range results {
switch {
case gr.err != nil:
ctErr += 1
case gr.code == 200:
ct200 += 1
case gr.code == 429:
ct429 += 1
default:
ctOther += 1
}
if gr.code != 200 {
continue
}
dur = int(gr.duration.Milliseconds())
switch {
case dur < min:
min = dur
case dur > max:
max = dur
}
sum += float64(dur)
durs[i] = float64(dur)
}
mean = sum / float64(ct)
sum = 0.0
for _, dur := range durs {
durElem = dur - mean
sum += durElem * durElem
}
stddev = math.Sqrt(sum / float64(ct-1))
_i := func(x int) string { return strconv.Itoa(x) }
_f := func(x float64) string { return strconv.Itoa(int(math.Round(x))) }
records = make([][]string, 2)
records[0] = []string{
"N Resp", "Min (ms)", "Mean (ms)", "Max (ms)", "Stddev (ms)", "200s", "429s", "Errors", "Other",
}
records[1] = []string{
_i(ct), _i(min), _f(mean), _i(max), _f(stddev), _i(ct200), _i(ct429), _i(ctErr), _i(ctOther),
}
return
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
#!/bin/sh
# Clean previous
rm -rf stats*.csv
# 3 runs of 5, 10, ...
for x in 5 10 25 40 50; do
for y in 1 2 3; do
go run main.go .secrets.json "$x" stats > "stats_${x}_${y}.csv"
done
done
# Combine stats
gocsv stack stats_*.csv | gocsv sort -c 1 > stats.csv
# Convert to MD
echo '# Stats' > stats.md
echo >> stats.md
gocsv viewmd stats.csv | sed 's/-|/-:|/g' >> stats.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment