diff --git a/client.go b/client.go new file mode 100644 index 0000000..4ee1759 --- /dev/null +++ b/client.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "golang.org/x/time/rate" +) + +const baseURL = "https://api.solidarity.tech/v1" + +type Client struct { + key string + dryRun bool + http *http.Client + limiter *rate.Limiter +} + +func NewClient(key string, dryRun bool) *Client { + return &Client{ + key: key, + dryRun: dryRun, + http: &http.Client{Timeout: 30 * time.Second}, + // 60 requests per 30 seconds: 2/sec sustained, burst of 60. + limiter: rate.NewLimiter(2, 60), + } +} + +// do applies the rate limit, retries once on 429, and returns the raw body. +func (c *Client) do(ctx context.Context, method, path string, body io.Reader) ([]byte, error) { + if err := c.limiter.Wait(ctx); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.key) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + wait := 30 * time.Second + if s := resp.Header.Get("Retry-After"); s != "" { + if secs, err := strconv.Atoi(s); err == nil { + wait = time.Duration(secs) * time.Second + } + } + log.Printf("rate limited; waiting %s", wait) + select { + case <-time.After(wait): + case <-ctx.Done(): + return nil, ctx.Err() + } + return c.do(ctx, method, path, body) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("%s %s: %d: %s", method, path, resp.StatusCode, truncate(raw)) + } + return raw, nil +} + +func truncate(b []byte) string { + if len(b) > 300 { + return string(b[:300]) + "..." + } + return string(b) +} diff --git a/go.mod b/go.mod index dbec5e2..e32f647 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/time v0.16.0 // indirect modernc.org/libc v1.75.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect diff --git a/go.sum b/go.sum index 5162069..2e032a4 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/store.go b/store.go index e5a53e4..baa3bb5 100644 --- a/store.go +++ b/store.go @@ -2,7 +2,8 @@ package main import ( "database/sql" - "log" + "errors" + "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" )