Skip to content

Commit e52c6cf

Browse files
committed
feat: support rate limits
Return enriched error if we are rate limited for the caller to handle.
1 parent 3869e6f commit e52c6cf

File tree

9 files changed

+143
-14
lines changed

9 files changed

+143
-14
lines changed

accounts.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func (c Client) GetAccountMetadata(id string) (AccountMetadata, error) {
9393
Path: strings.Join([]string{accountPath, id, ""}, "/"),
9494
},
9595
}
96-
resp, err := c.c.Do(&req)
96+
resp, err := c.do(&req)
9797

9898
if err != nil {
9999
return AccountMetadata{}, err
@@ -123,7 +123,7 @@ func (c Client) GetAccountBalances(id string) (AccountBalances, error) {
123123
Path: strings.Join([]string{accountPath, id, balancesPath, ""}, "/"),
124124
},
125125
}
126-
resp, err := c.c.Do(&req)
126+
resp, err := c.do(&req)
127127

128128
if err != nil {
129129
return AccountBalances{}, err
@@ -153,7 +153,7 @@ func (c Client) GetAccountDetails(id string) (AccountDetails, error) {
153153
Path: strings.Join([]string{accountPath, id, detailsPath, ""}, "/"),
154154
},
155155
}
156-
resp, err := c.c.Do(&req)
156+
resp, err := c.do(&req)
157157

158158
if err != nil {
159159
return AccountDetails{}, err
@@ -183,7 +183,7 @@ func (c Client) GetAccountTransactions(id string) (AccountTransactions, error) {
183183
Path: strings.Join([]string{accountPath, id, transactionsPath, ""}, "/"),
184184
},
185185
}
186-
resp, err := c.c.Do(&req)
186+
resp, err := c.do(&req)
187187

188188
if err != nil {
189189
return AccountTransactions{}, err

agreements.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ func (c Client) CreateEndUserAgreement(eua EndUserAgreement) (EndUserAgreement,
3939
}
4040
req.Body = io.NopCloser(bytes.NewBuffer(data))
4141

42-
resp, err := c.c.Do(&req)
42+
resp, err := c.do(&req)
4343

4444
if err != nil {
4545
return EndUserAgreement{}, err

client.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"net/http"
9+
"strconv"
810
"strings"
911
"sync"
1012
"time"
@@ -103,3 +105,55 @@ func NewClient(secretId, secretKey string) (*Client, error) {
103105

104106
return c, nil
105107
}
108+
109+
// do sends the HTTP request and returns a RateLimitError when the response code
110+
// indicates that the request was throttled.
111+
func (c *Client) do(req *http.Request) (*http.Response, error) {
112+
resp, err := c.c.Do(req)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
if resp.StatusCode == http.StatusTooManyRequests {
118+
// We have been rate limited, parse the response and return a
119+
// RateLimitError for the caller to handle.
120+
body, _ := io.ReadAll(resp.Body)
121+
resp.Body.Close()
122+
123+
rl := parseRateLimit(resp.Header)
124+
return nil, &RateLimitError{APIError: &APIError{StatusCode: resp.StatusCode, Body: string(body)}, RateLimit: rl}
125+
}
126+
127+
return resp, nil
128+
}
129+
130+
// parseRateLimit extracts rate limit information from the HTTP headers
131+
// https://bankaccountdata.zendesk.com/hc/en-gb/articles/11529584398236-Bank-API-Rate-Limits-and-Rate-Limit-Headers
132+
func parseRateLimit(h http.Header) RateLimit {
133+
toInt := func(v string) int {
134+
i, _ := strconv.Atoi(v)
135+
return i
136+
}
137+
138+
rl := RateLimit{}
139+
140+
if v := h.Get("HTTP_X_RATELIMIT_ACCOUNT_SUCCESS_LIMIT"); v != "" {
141+
rl.Limit = toInt(v)
142+
} else {
143+
rl.Limit = toInt(h.Get("HTTP_X_RATELIMIT_LIMIT"))
144+
}
145+
146+
if v := h.Get("HTTP_X_RATELIMIT_ACCOUNT_SUCCESS_REMAINING"); v != "" {
147+
rl.Remaining = toInt(v)
148+
} else {
149+
rl.Remaining = toInt(h.Get("HTTP_X_RATELIMIT_REMAINING"))
150+
}
151+
152+
if v := h.Get("HTTP_X_RATELIMIT_ACCOUNT_SUCCESS_RESET"); v != "" {
153+
rl.Reset = toInt(v)
154+
} else {
155+
rl.Reset = toInt(h.Get("HTTP_X_RATELIMIT_RESET"))
156+
}
157+
158+
return rl
159+
}

client_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package nordigen
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
7+
"net/http/httptest"
68
"os"
79
"sync"
810
"testing"
@@ -72,3 +74,45 @@ func TestRefreshRefresh(t *testing.T) {
7274
}
7375
cancel() // Stop handler again
7476
}
77+
78+
func TestRateLimitError(t *testing.T) {
79+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80+
w.Header().Set("HTTP_X_RATELIMIT_LIMIT", "10")
81+
w.Header().Set("HTTP_X_RATELIMIT_REMAINING", "0")
82+
w.Header().Set("HTTP_X_RATELIMIT_RESET", "5")
83+
w.WriteHeader(http.StatusTooManyRequests)
84+
w.Write([]byte("rate limited"))
85+
}))
86+
defer ts.Close()
87+
88+
c := &Client{c: ts.Client()}
89+
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
90+
if err != nil {
91+
t.Errorf("NewRequest: %v", err)
92+
}
93+
94+
resp, err := c.do(req)
95+
if err == nil {
96+
t.Errorf("expected error")
97+
}
98+
if resp != nil {
99+
t.Errorf("expected nil response")
100+
}
101+
102+
rlErr, ok := err.(*RateLimitError)
103+
if !ok {
104+
t.Errorf("expected RateLimitError got %T", err)
105+
}
106+
if rlErr.RateLimit.Limit != 10 || rlErr.RateLimit.Remaining != 0 || rlErr.RateLimit.Reset != 5 {
107+
t.Errorf("unexpected rate limit values: %+v", rlErr.RateLimit)
108+
}
109+
110+
// Error should unwrap
111+
var apiErr *APIError
112+
if !errors.As(err, &apiErr) {
113+
t.Errorf("expected APIError, got %T", err)
114+
}
115+
if !errors.Is(err, apiErr) {
116+
t.Errorf("expected %v, got %v", apiErr, err)
117+
}
118+
}

errors.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package nordigen
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
"time"
6+
)
47

58
type APIError struct {
69
StatusCode int
@@ -15,3 +18,31 @@ func (e *APIError) Error() string {
1518
func (e *APIError) Unwrap() error {
1619
return e.Err
1720
}
21+
22+
// RateLimit contains information from rate limit headers.
23+
type RateLimit struct {
24+
Limit int // Maximum number of requests allowed
25+
Remaining int // Requests remaining before hitting the limit
26+
Reset int // Seconds until the limit resets
27+
}
28+
29+
// RateLimitError is returned when the API responds with HTTP 429. It embeds the
30+
// APIError and exposes the parsed rate limit headers so callers can decide how
31+
// to handle the throttling.
32+
type RateLimitError struct {
33+
*APIError
34+
RateLimit RateLimit
35+
}
36+
37+
// Resets returns the duration until the rate limit resets
38+
func (rl RateLimitError) Resets() time.Duration {
39+
return time.Duration(rl.RateLimit.Reset) * time.Second
40+
}
41+
42+
func (e *RateLimitError) Error() string {
43+
return fmt.Sprintf("RateLimitError %v limit=%d remaining=%d reset=%d", e.StatusCode, e.RateLimit.Limit, e.RateLimit.Remaining, e.RateLimit.Reset)
44+
}
45+
46+
func (e *RateLimitError) Unwrap() error {
47+
return e.APIError
48+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module github.com/frieser/nordigen-go-lib/v2
22

3-
go 1.16
3+
go 1.24

institutions.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func (c Client) ListInstitutions(country string) ([]Institution, error) {
3131
q.Add(countryParam, country)
3232
req.URL.RawQuery = q.Encode()
3333

34-
resp, err := c.c.Do(&req)
34+
resp, err := c.do(&req)
3535

3636
if err != nil {
3737
return nil, err
@@ -61,7 +61,7 @@ func (c Client) GetInstitution(institutionID string) (Institution, error) {
6161
Path: strings.Join([]string{institutionsPath, institutionID, ""}, "/"),
6262
},
6363
}
64-
resp, err := c.c.Do(&req)
64+
resp, err := c.do(&req)
6565

6666
if err != nil {
6767
return Institution{}, err

requisitions.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (c Client) CreateRequisition(r Requisition) (Requisition, error) {
4949
}
5050
req.Body = io.NopCloser(bytes.NewBuffer(data))
5151

52-
resp, err := c.c.Do(&req)
52+
resp, err := c.do(&req)
5353

5454
if err != nil {
5555
return Requisition{}, err
@@ -78,7 +78,7 @@ func (c Client) GetRequisition(id string) (r Requisition, err error) {
7878
Path: strings.Join([]string{requisitionsPath, id, ""}, "/"),
7979
},
8080
}
81-
resp, err := c.c.Do(&req)
81+
resp, err := c.do(&req)
8282

8383
if err != nil {
8484
return Requisition{}, err
@@ -128,7 +128,7 @@ func (c Client) fetchRequisitions(u *url.URL, allRequisitions *[]Requisition) er
128128
URL: u,
129129
}
130130

131-
resp, err := c.c.Do(&req)
131+
resp, err := c.do(&req)
132132
if err != nil {
133133
return err
134134
}

token.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (c *Client) newToken(ctx context.Context) error {
5252
}
5353
req = req.WithContext(ctx)
5454

55-
resp, err := c.c.Do(req)
55+
resp, err := c.do(req)
5656
if err != nil {
5757
return err
5858
}
@@ -94,7 +94,7 @@ func (c *Client) refreshToken(ctx context.Context) error {
9494
}
9595
req = req.WithContext(ctx)
9696

97-
resp, err := c.c.Do(req)
97+
resp, err := c.do(req)
9898
if err != nil {
9999
return err
100100
}

0 commit comments

Comments
 (0)