Respect provider rate limits and preserve bank connections on throttling
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
"finance-duck/internal/domain"
|
||||
"finance-duck/internal/ratelimit"
|
||||
)
|
||||
|
||||
// ErrReconnect identifies inactive bank consent, not application authentication
|
||||
@@ -34,6 +35,13 @@ type Session struct {
|
||||
ValidUntil string `json:"valid_until"`
|
||||
Accounts []domain.Account `json:"accounts"`
|
||||
}
|
||||
|
||||
// SessionStatus contains only the current consent expiry and external account
|
||||
// membership. Full account metadata is captured once by Exchange.
|
||||
type SessionStatus struct {
|
||||
ValidUntil string
|
||||
AccountIDs []string
|
||||
}
|
||||
type Balance struct {
|
||||
Amount domain.Money `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
@@ -43,7 +51,7 @@ type Balance struct {
|
||||
type Provider interface {
|
||||
Authorize(context.Context, string, string, string) (string, error)
|
||||
Exchange(context.Context, string) (Session, error)
|
||||
Status(context.Context, string) (Session, error)
|
||||
Status(context.Context, string) (SessionStatus, error)
|
||||
Balances(context.Context, string) ([]Balance, error)
|
||||
Transactions(context.Context, domain.Account, string, string) ([]domain.Facts, error)
|
||||
}
|
||||
@@ -53,6 +61,7 @@ type EnableBanking struct {
|
||||
appID string
|
||||
key *rsa.PrivateKey
|
||||
redirectURL string
|
||||
requests ratelimit.Controller
|
||||
}
|
||||
|
||||
var _ Provider = (*EnableBanking)(nil)
|
||||
@@ -125,9 +134,10 @@ func (p *EnableBanking) jwt() (string, error) {
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
func (p *EnableBanking) request(ctx context.Context, method, path string, input, output any) error {
|
||||
// Enforce a deadline even when a caller injects a client without Timeout.
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := p.requests.Acquire(ctx); err != nil {
|
||||
return fmt.Errorf("Enable Banking: %w", err)
|
||||
}
|
||||
defer p.requests.Release()
|
||||
token, err := p.jwt()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,17 +167,27 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
|
||||
if p.HTTPClient != nil {
|
||||
client = *p.HTTPClient
|
||||
}
|
||||
// Cap each attempt, including response reads, without timing out retry waits.
|
||||
if client.Timeout <= 0 || client.Timeout > 30*time.Second {
|
||||
client.Timeout = 30 * time.Second
|
||||
}
|
||||
// Never forward signed credentials or financial requests through redirects.
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
response, err := client.Do(req)
|
||||
response, err := p.requests.Do(ctx, func(ctx context.Context) (*http.Response, error) {
|
||||
response, err := client.Do(req.Clone(ctx))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("request timed out: %w", context.DeadlineExceeded)
|
||||
}
|
||||
return nil, errors.New("connection failed")
|
||||
}
|
||||
return response, nil
|
||||
}, method == http.MethodGet)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return context.Canceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("Enable Banking request timed out")
|
||||
}
|
||||
return fmt.Errorf("Enable Banking connection failed")
|
||||
return fmt.Errorf("Enable Banking: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
@@ -176,6 +196,12 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
|
||||
const limit = 16 << 20
|
||||
b, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return context.Canceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("Enable Banking response timed out: %w", context.DeadlineExceeded)
|
||||
}
|
||||
return fmt.Errorf("read Enable Banking response")
|
||||
}
|
||||
if len(b) > limit {
|
||||
@@ -311,54 +337,34 @@ func (p *EnableBanking) Exchange(ctx context.Context, code string) (Session, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (p *EnableBanking) Status(ctx context.Context, sessionID string) (Session, error) {
|
||||
func (p *EnableBanking) Status(ctx context.Context, sessionID string) (SessionStatus, error) {
|
||||
if sessionID == "" {
|
||||
return Session{}, fmt.Errorf("session ID is required")
|
||||
return SessionStatus{}, fmt.Errorf("session ID is required")
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Accounts []string `json:"accounts"`
|
||||
AccountsData []accountDTO `json:"accounts_data"`
|
||||
Access accessDTO `json:"access"`
|
||||
ASPSP institutionDTO `json:"aspsp"`
|
||||
Status string `json:"status"`
|
||||
Accounts []string `json:"accounts"`
|
||||
Access accessDTO `json:"access"`
|
||||
}
|
||||
if err := p.request(ctx, http.MethodGet, "/sessions/"+url.PathEscape(sessionID), nil, &response); err != nil {
|
||||
return Session{}, err
|
||||
return SessionStatus{}, err
|
||||
}
|
||||
if response.Status != "AUTHORIZED" {
|
||||
return Session{}, fmt.Errorf("Enable Banking session is not authorized: %w", ErrReconnect)
|
||||
return SessionStatus{}, fmt.Errorf("Enable Banking session is not authorized: %w", ErrReconnect)
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, response.Access.ValidUntil)
|
||||
if err != nil {
|
||||
return Session{}, fmt.Errorf("Enable Banking returned invalid session expiry")
|
||||
return SessionStatus{}, fmt.Errorf("Enable Banking returned invalid session expiry")
|
||||
}
|
||||
if !expires.After(time.Now()) {
|
||||
return Session{}, fmt.Errorf("Enable Banking session expired: %w", ErrReconnect)
|
||||
}
|
||||
result := Session{ID: sessionID, ValidUntil: response.Access.ValidUntil, Accounts: []domain.Account{}}
|
||||
hashes := map[string]string{}
|
||||
for _, a := range response.AccountsData {
|
||||
hashes[a.UID] = a.IdentificationHash
|
||||
return SessionStatus{}, fmt.Errorf("Enable Banking session expired: %w", ErrReconnect)
|
||||
}
|
||||
for _, id := range response.Accounts {
|
||||
if id == "" {
|
||||
return Session{}, fmt.Errorf("Enable Banking returned empty account identifier")
|
||||
return SessionStatus{}, fmt.Errorf("Enable Banking returned empty account identifier")
|
||||
}
|
||||
var details accountDTO
|
||||
if err := p.request(ctx, http.MethodGet, "/accounts/"+url.PathEscape(id)+"/details", nil, &details); err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
details.UID = id
|
||||
if details.IdentificationHash == "" {
|
||||
details.IdentificationHash = hashes[id]
|
||||
}
|
||||
a, err := details.account(response.ASPSP.Name)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
result.Accounts = append(result.Accounts, a)
|
||||
}
|
||||
return result, nil
|
||||
return SessionStatus{ValidUntil: response.Access.ValidUntil, AccountIDs: response.Accounts}, nil
|
||||
}
|
||||
|
||||
type amountDTO struct {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user