Report a rate-limited bank sync as a wait, and name real failures

Two of three banks were only pacing us, yet the dashboard demanded attention,
printed four nested wrappers and a nanosecond UTC deadline, and the scheduler
retried hourly into a refusal whose end time the bank had already given.

A rate limit now carries its retry time as data: Status.SyncRetryAt is set when
every failure is self-clearing, the connection reports rate_limited with that
deadline, the dashboard says synchronization resumes by itself and renders the
time in the browser's zone, and the scheduler sleeps until the deadline instead
of spending hourly session checks. Sync now still tries immediately.

The third bank's "transaction retrieval failed" hid its cause. Provider
failures Finance Duck determines itself are typed as banking.ProviderError,
so an unreachable provider, a timeout or an unusable response, such as a booked
transaction without a booking date, is reported instead of the opaque fallback.
Provider response text still never reaches the message.
This commit is contained in:
Lars Nolden
2026-09-11 18:41:35 +02:00
parent dece0d5b79
commit b3e1c65a82
12 changed files with 305 additions and 52 deletions
+85 -14
View File
@@ -605,10 +605,12 @@ func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error
// Only typed, locally generated errors are safe to expose; provider errors may
// wrap private response data even when their underlying cause is recognizable.
// The fallback is a last resort: an unnamed cause leaves an operator with
// nothing to act on.
func bankFailure(err error, fallback string) error {
var background *banking.BackgroundQuotaError
if errors.As(err, &background) {
return fmt.Errorf("Enable Banking: %w", background)
return background
}
var limited *ratelimit.RateLimitError
if errors.As(err, &limited) {
@@ -625,9 +627,25 @@ func bankFailure(err error, fallback string) error {
if errors.As(err, &consent) {
return consent
}
var provider *banking.ProviderError
if errors.As(err, &provider) {
return provider
}
return errors.New(fallback)
}
// syncRetryAt reports the bank's own retry time for a failure that clears
// itself. A rate limit without a usable deadline is not treated as waiting:
// nothing would ever announce that it had expired.
func syncRetryAt(err error) (time.Time, bool) {
var limited *ratelimit.RateLimitError
if !errors.As(err, &limited) {
return time.Time{}, false
}
at := limited.RetryAt()
return at, !at.IsZero()
}
func (a *App) Sync(ctx context.Context) (State, error) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -639,6 +657,10 @@ func (a *App) Sync(ctx context.Context) (State, error) {
return State{}, err
}
var failures []string
// waitUntil is the earliest time the banks themselves allow a retry, used
// only while every failure is such a self-clearing rate limit.
var waitUntil time.Time
waiting := true
for i := range a.ops.Sessions {
connectAccounts(&s.Data, &a.ops.Sessions[i], false)
}
@@ -684,6 +706,15 @@ func (a *App) Sync(ctx context.Context) (State, error) {
}
if e != nil {
meta.Error = bankFailure(e, "bank connection unavailable; retry synchronization").Error()
meta.RetryAt = ""
if at, ok := syncRetryAt(e); ok {
meta.RetryAt = at.UTC().Format(time.RFC3339)
if waitUntil.IsZero() || at.Before(waitUntil) {
waitUntil = at
}
} else {
waiting = false
}
meta.NeedsReconnect = errors.Is(e, banking.ErrReconnect)
a.ops.Consents[session.ID] = meta
failures = append(failures, meta.Institution+": "+meta.Error)
@@ -691,6 +722,7 @@ func (a *App) Sync(ctx context.Context) (State, error) {
continue
}
meta.Error = ""
meta.RetryAt = ""
meta.NeedsReconnect = false
a.ops.Consents[session.ID] = meta
a.ops.Sessions[i].ValidUntil = current.ValidUntil
@@ -712,9 +744,11 @@ func (a *App) Sync(ctx context.Context) (State, error) {
}
if !validAccounts[account.ID] {
failures = append(failures, account.DisplayName+": bank connection unavailable")
waiting = false
if sessionID != "" {
meta := a.ops.Consents[sessionID]
meta.Error = banking.ErrReconnect.Error()
meta.RetryAt = ""
meta.NeedsReconnect = true
a.ops.Consents[sessionID] = meta
}
@@ -731,6 +765,15 @@ func (a *App) Sync(ctx context.Context) (State, error) {
if e != nil {
meta := a.ops.Consents[sessionID]
meta.Error = bankFailure(e, "transaction retrieval failed; retry synchronization").Error()
meta.RetryAt = ""
if at, ok := syncRetryAt(e); ok {
meta.RetryAt = at.UTC().Format(time.RFC3339)
if waitUntil.IsZero() || at.Before(waitUntil) {
waitUntil = at
}
} else {
waiting = false
}
meta.NeedsReconnect = meta.NeedsReconnect || errors.Is(e, banking.ErrReconnect)
a.ops.Consents[sessionID] = meta
failures = append(failures, account.DisplayName+": "+meta.Error)
@@ -739,6 +782,7 @@ func (a *App) Sync(ctx context.Context) (State, error) {
result, e := a.importFacts(ctx, s, facts)
if e != nil {
failures = append(failures, account.DisplayName+": "+e.Error())
waiting = false
s, err = a.snapshot(ctx)
if err != nil {
return State{}, err
@@ -749,14 +793,47 @@ func (a *App) Sync(ctx context.Context) (State, error) {
a.ops.AccountSync[account.ID] = now.Format(time.RFC3339)
}
a.ops.SyncError = strings.Join(failures, "; ")
a.ops.SyncRetryAt = ""
if len(failures) == 0 {
a.ops.LastSync = now.Format(time.RFC3339)
} else if waiting && !waitUntil.IsZero() {
// Every bank named its own retry time: this is a wait, not a fault.
a.ops.SyncRetryAt = waitUntil.UTC().Format(time.RFC3339)
}
if err = a.saveOps(); err != nil {
return State{}, err
}
return a.snapshot(ctx)
}
// syncSchedule decides whether an automatic sync may run now, and how long to
// wait otherwise. While a bank has named its own retry time, waiting is the
// only useful action: retrying earlier spends session-status calls on a refusal
// that is already known. A manual request always proceeds.
func syncSchedule(now time.Time, ops operational, force bool) (time.Duration, bool) {
if retry, err := time.Parse(time.RFC3339, ops.SyncRetryAt); err == nil && !force && now.Before(retry) {
return min(retry.Sub(now)+time.Minute, time.Hour), false
}
last, err := time.Parse(time.RFC3339, ops.LastSync)
if force || err != nil || ops.SyncError != "" || now.Sub(last) >= 24*time.Hour {
return 0, true
}
return time.Minute, false
}
// syncBackoff spaces the next attempt after a sync. A failure with a known bank
// retry time waits for it; other failures retry hourly so transient provider
// problems clear without waiting a day, while bounding unattended traffic.
func syncBackoff(now time.Time, ops operational) time.Duration {
if ops.SyncError == "" {
return 24 * time.Hour
}
if retry, err := time.Parse(time.RFC3339, ops.SyncRetryAt); err == nil && now.Before(retry) {
return min(retry.Sub(now)+time.Minute, 24*time.Hour)
}
return time.Hour
}
func (a *App) RunScheduler(ctx context.Context) {
timer := time.NewTimer(time.Minute)
defer timer.Stop()
@@ -771,25 +848,19 @@ func (a *App) RunScheduler(ctx context.Context) {
}
a.mu.Lock()
configured := a.bank != nil
last, err := time.Parse(time.RFC3339, a.ops.LastSync)
failed := a.ops.SyncError != ""
wait, due := syncSchedule(time.Now(), a.ops, force)
a.mu.Unlock()
due := force || err != nil || failed || time.Since(last) >= 24*time.Hour
if !configured || !due {
timer.Reset(time.Minute)
if wait <= 0 {
wait = time.Minute
}
timer.Reset(wait)
continue
}
a.Sync(ctx)
a.mu.Lock()
failed = a.ops.SyncError != ""
wait = syncBackoff(time.Now(), a.ops)
a.mu.Unlock()
if failed {
// A failed sync leaves its persisted error banner behind. Retry
// hourly so transient provider failures clear without waiting a
// day, while bounding unattended traffic toward the provider.
timer.Reset(time.Hour)
} else {
timer.Reset(24 * time.Hour)
}
timer.Reset(wait)
}
}