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
+47 -19
View File
@@ -12,6 +12,7 @@ import (
"finance-duck/internal/banking"
"finance-duck/internal/classification"
"finance-duck/internal/domain"
"finance-duck/internal/ratelimit"
)
type ImportResult struct {
@@ -136,27 +137,19 @@ func (a *App) Backfill(ctx context.Context, rev, accountID string, historyMonths
}
current, err := a.bank.Status(ctx, session.ID)
if err != nil {
if errors.Is(err, banking.ErrReconnect) {
return ImportResult{}, banking.ErrReconnect
}
return ImportResult{}, errors.New("bank connection unavailable; retry importing history")
return ImportResult{}, bankFailure(err, "bank connection unavailable; retry importing history")
}
expiry, err = time.Parse(time.RFC3339, current.ValidUntil)
if err != nil || !expiry.After(time.Now()) {
return ImportResult{}, banking.ErrReconnect
}
if !slices.ContainsFunc(current.Accounts, func(linked domain.Account) bool {
return linked.ExternalAccountID == account.ExternalAccountID
}) {
if !slices.Contains(current.AccountIDs, account.ExternalAccountID) {
return ImportResult{}, banking.ErrReconnect
}
now := time.Now().UTC()
facts, err := a.bank.Transactions(ctx, account, now.AddDate(0, -historyMonths, 0).Format("2006-01-02"), now.Format("2006-01-02"))
if err != nil {
if errors.Is(err, banking.ErrReconnect) {
return ImportResult{}, banking.ErrReconnect
}
return ImportResult{}, errors.New("transaction retrieval failed; retry importing history")
return ImportResult{}, bankFailure(err, "transaction retrieval failed; retry importing history")
}
// Use normal import processing without changing sync cursors or saved consent
// settings, including when the requested range adds no transactions.
@@ -302,6 +295,20 @@ func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error
}
return nil, errors.New("account is not connected")
}
// Only typed, locally generated errors are safe to expose; provider errors may
// wrap private response data even when their underlying cause is recognizable.
func bankFailure(err error, fallback string) error {
var limited *ratelimit.RateLimitError
if errors.As(err, &limited) {
return fmt.Errorf("Enable Banking: %w", limited)
}
if errors.Is(err, banking.ErrReconnect) {
return banking.ErrReconnect
}
return errors.New(fallback)
}
func (a *App) Sync(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -343,25 +350,35 @@ func (a *App) Sync(ctx context.Context) (State, error) {
}
validAccounts := map[string]bool{}
accountSession := map[string]string{}
failedSessions := map[string]bool{}
for i, session := range a.ops.Sessions {
for _, account := range session.Accounts {
accountSession[account.ID] = session.ID
}
meta := a.ops.Consents[session.ID]
current, e := a.bank.Status(ctx, session.ID)
if e == nil {
expiry, parseErr := time.Parse(time.RFC3339, current.ValidUntil)
if parseErr != nil || !expiry.After(time.Now()) {
e = banking.ErrReconnect
}
}
if e != nil {
meta.Error = e.Error()
meta.Error = bankFailure(e, "bank connection unavailable; retry synchronization").Error()
meta.NeedsReconnect = errors.Is(e, banking.ErrReconnect)
a.ops.Consents[session.ID] = meta
failures = append(failures, meta.Institution+": "+meta.Error)
failedSessions[session.ID] = true
continue
}
meta.Error = ""
meta.NeedsReconnect = false
a.ops.Consents[session.ID] = meta
a.ops.Sessions[i].ValidUntil = current.ValidUntil
for _, account := range current.Accounts {
validAccounts[account.ExternalAccountID] = true
for _, account := range session.Accounts {
if account.ExternalAccountID != "" && slices.Contains(current.AccountIDs, account.ExternalAccountID) {
validAccounts[account.ID] = true
}
}
}
now := time.Now().UTC()
@@ -370,8 +387,18 @@ func (a *App) Sync(ctx context.Context) (State, error) {
if !account.Active || account.ExternalAccountID == "" {
continue
}
if !validAccounts[account.ExternalAccountID] {
sessionID := accountSession[account.ID]
if failedSessions[sessionID] {
continue
}
if !validAccounts[account.ID] {
failures = append(failures, account.DisplayName+": bank connection unavailable")
if sessionID != "" {
meta := a.ops.Consents[sessionID]
meta.Error = banking.ErrReconnect.Error()
meta.NeedsReconnect = true
a.ops.Consents[sessionID] = meta
}
continue
}
var from string
@@ -383,10 +410,11 @@ func (a *App) Sync(ctx context.Context) (State, error) {
}
facts, e := a.bank.Transactions(ctx, account, from, to)
if e != nil {
meta := a.ops.Consents[accountSession[account.ID]]
meta.Error = "Transaction retrieval failed; retry synchronization"
a.ops.Consents[accountSession[account.ID]] = meta
failures = append(failures, account.DisplayName+": transaction retrieval failed")
meta := a.ops.Consents[sessionID]
meta.Error = bankFailure(e, "transaction retrieval failed; retry synchronization").Error()
meta.NeedsReconnect = meta.NeedsReconnect || errors.Is(e, banking.ErrReconnect)
a.ops.Consents[sessionID] = meta
failures = append(failures, account.DisplayName+": "+meta.Error)
continue
}
result, e := a.importFacts(ctx, s, facts)