Respect provider rate limits and preserve bank connections on throttling
This commit is contained in:
+47
-19
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user