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
+49 -43
View File
@@ -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 {