Respect provider rate limits and preserve bank connections on throttling

This commit is contained in:
Lars Nolden
2026-09-10 17:25:09 +02:00
parent 2259db3e85
commit ba3ea6ae5a
14 changed files with 1250 additions and 95 deletions
+200 -3
View File
@@ -13,11 +13,15 @@ import (
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"finance-duck/internal/ratelimit"
)
func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa.PrivateKey) {
@@ -138,7 +142,8 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
case "/sessions/session-1":
fmt.Fprintf(w, `{"status":"AUTHORIZED","access":{"valid_until":%q},"aspsp":{"name":"N26","country":"DE"},"accounts":["uid-one"],"accounts_data":[{"uid":"uid-one","identification_hash":"stable-hash"}]}`, expiry)
case "/accounts/uid-one/details":
fmt.Fprint(w, `{"account_id":{"iban":"DE02120300000000202051"},"details":"Main account","currency":"EUR"}`)
t.Error("session membership must not require account details")
http.Error(w, "account details unavailable", http.StatusServiceUnavailable)
case "/accounts/uid-one/balances":
fmt.Fprint(w, `{"balances":[{"name":"Booked","balance_amount":{"currency":"EUR","amount":"1234.5678"},"balance_type":"CLBD","reference_date":"2026-09-01"}]}`)
case "/accounts/uid-one/transactions":
@@ -180,8 +185,8 @@ func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(status.Accounts) != 1 || status.Accounts[0].ID != session.Accounts[0].ID || status.Accounts[0].ExternalAccountID != "uid-one" {
t.Fatalf("account identity changed between session DTOs: %+v", status)
if len(status.AccountIDs) != 1 || status.AccountIDs[0] != session.Accounts[0].ExternalAccountID || status.ValidUntil != expiry {
t.Fatalf("incorrect consent membership or expiry: %+v", status)
}
balances, err := p.Balances(context.Background(), "uid-one")
if err != nil || len(balances) != 1 || balances[0].Amount.String() != "1234.5678" || balances[0].Type != "CLBD" {
@@ -265,6 +270,198 @@ func TestEnableBankingExpiredConsentRequiresReconnect(t *testing.T) {
}
}
func TestEnableBankingRejectsInvalidSessionMembership(t *testing.T) {
for name, payload := range map[string]string{
"empty UID": `{"status":"AUTHORIZED","accounts":[""],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`,
"non-string UID": `{"status":"AUTHORIZED","accounts":[{}],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`,
"invalid expiry": `{"status":"AUTHORIZED","accounts":["uid"],"access":{"valid_until":"not-a-date"}}`,
} {
t.Run(name, func(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, payload)
})
status, err := p.Status(context.Background(), "session")
if err == nil || status.ValidUntil != "" || status.AccountIDs != nil || errors.Is(err, ErrReconnect) {
t.Fatalf("invalid response returned usable membership or claimed revoked consent: %+v %v", status, err)
}
})
}
}
type bankingRoundTripFunc func(*http.Request) (*http.Response, error)
func (f bankingRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
type bankingClosedBody struct {
io.ReadCloser
closed *atomic.Int32
onClose func()
}
func (b *bankingClosedBody) Close() error {
b.closed.Add(1)
err := b.ReadCloser.Close()
if b.onClose != nil {
b.onClose()
}
return err
}
func TestEnableBankingGETRecoversAfterRateLimit(t *testing.T) {
for _, endpoint := range []string{"status", "transactions"} {
t.Run(endpoint, func(t *testing.T) {
t.Parallel()
var calls, closed atomic.Int32
var first atomic.Int64
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
first.Store(time.Now().UnixNano())
w.Header().Set("Retry-After", "1")
http.Error(w, "private provider response", http.StatusTooManyRequests)
return
}
if time.Since(time.Unix(0, first.Load())) < time.Second {
t.Error("retried before provider cooldown elapsed")
}
if closed.Load() != 1 {
t.Error("retried without closing the rate-limit response")
}
if endpoint == "status" {
fmt.Fprint(w, `{"status":"AUTHORIZED","accounts":["uid"],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`)
} else {
fmt.Fprint(w, `{"transactions":[{"entry_reference":"entry","transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-01"}]}`)
}
})
transport := p.HTTPClient.Transport
p.HTTPClient.Timeout = 500 * time.Millisecond
p.HTTPClient.Transport = bankingRoundTripFunc(func(r *http.Request) (*http.Response, error) {
response, err := transport.RoundTrip(r)
if err == nil && response.StatusCode == http.StatusTooManyRequests {
response.Body = &bankingClosedBody{ReadCloser: response.Body, closed: &closed}
}
return response, err
})
if endpoint == "status" {
status, err := p.Status(context.Background(), "session")
if err != nil || len(status.AccountIDs) != 1 || status.AccountIDs[0] != "uid" {
t.Fatalf("session membership did not recover: %+v %v", status, err)
}
} else {
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "")
if err != nil || len(rows) != 1 || rows[0].ExternalID != "entry" || rows[0].Amount.String() != "1.00" {
t.Fatalf("transaction retrieval did not recover: %+v %v", rows, err)
}
}
if calls.Load() != 2 || closed.Load() != 1 {
t.Fatalf("unexpected retry requests or response leaks: calls=%d closed=%d", calls.Load(), closed.Load())
}
})
}
}
func TestEnableBankingCooldownCoversAllEndpoints(t *testing.T) {
var calls atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "300")
http.Error(w, "private provider response", http.StatusTooManyRequests)
})
_, err := p.Status(context.Background(), "session")
var initial *ratelimit.RateLimitError
if !errors.As(err, &initial) || !initial.RetryAt().After(time.Now()) || errors.Is(err, ErrReconnect) || strings.Contains(err.Error(), "private") {
t.Fatalf("unsafe or missing rate-limit error: %v", err)
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
for name, request := range map[string]func() error{
"status": func() error { _, err := p.Status(context.Background(), "other-session"); return err },
"balances": func() error { _, err := p.Balances(context.Background(), "uid"); return err },
"transactions": func() error { _, err := p.Transactions(context.Background(), account, "", ""); return err },
"exchange": func() error { _, err := p.Exchange(context.Background(), "once-only-code"); return err },
"authorize": func() error { _, err := p.Authorize(context.Background(), "N26", "DE", "state"); return err },
} {
t.Run(name, func(t *testing.T) {
err := request()
var limit *ratelimit.RateLimitError
if !errors.As(err, &limit) || !limit.RetryAt().Equal(initial.RetryAt()) || errors.Is(err, ErrReconnect) || strings.Contains(err.Error(), "private") {
t.Fatalf("cooldown was lost or unsafe: %v", err)
}
if calls.Load() != 1 {
t.Fatalf("provider contacted during cooldown: %d requests", calls.Load())
}
})
}
}
func TestEnableBankingNeverReplaysMutationAfterRateLimit(t *testing.T) {
for _, endpoint := range []string{"exchange", "authorize"} {
t.Run(endpoint, func(t *testing.T) {
var posts atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/aspsps" {
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
return
}
if r.Method != http.MethodPost {
t.Error("unexpected non-mutation request")
}
posts.Add(1)
w.Header().Set("Retry-After", "1")
http.Error(w, "private once-only exchange failure", http.StatusTooManyRequests)
})
var err error
if endpoint == "exchange" {
_, err = p.Exchange(context.Background(), "once-only-code")
} else {
_, err = p.Authorize(context.Background(), "N26", "DE", "state")
}
var limit *ratelimit.RateLimitError
if !errors.As(err, &limit) || strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
t.Fatalf("mutation rate limit was lost or unsafe: %v", err)
}
if posts.Load() != 1 {
t.Fatalf("mutation replayed %d times", posts.Load())
}
})
}
}
func TestEnableBankingCanceledRetryRetainsCooldown(t *testing.T) {
var calls, closed atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "60")
http.Error(w, "private provider response", http.StatusTooManyRequests)
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
transport := p.HTTPClient.Transport
p.HTTPClient.Transport = bankingRoundTripFunc(func(r *http.Request) (*http.Response, error) {
response, err := transport.RoundTrip(r)
if err == nil && response.StatusCode == http.StatusTooManyRequests {
response.Body = &bankingClosedBody{ReadCloser: response.Body, closed: &closed, onClose: cancel}
}
return response, err
})
_, err := p.Status(ctx, "session")
var original *ratelimit.RateLimitError
if !errors.Is(err, context.Canceled) || !errors.As(err, &original) || strings.Contains(err.Error(), "private") {
t.Fatalf("cancellation lost safe rate-limit evidence: %v", err)
}
_, err = p.Balances(context.Background(), "uid")
var retained *ratelimit.RateLimitError
if !errors.As(err, &retained) || !retained.RetryAt().Equal(original.RetryAt()) {
t.Fatalf("cancellation discarded provider cooldown: %v", err)
}
if calls.Load() != 1 || closed.Load() != 1 {
t.Fatalf("cancellation retried or leaked a response: calls=%d closed=%d", calls.Load(), closed.Load())
}
}
func TestEnableBankingValidatesUploadedCredentials(t *testing.T) {
_, key := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("credential validation must not call provider")