Synchronize connected accounts twice a day

One named syncInterval replaces the two 24-hour literals in the scheduler: a
successful run now waits 12 hours, and an account becomes due again 12 hours
after its last successful sync. Two background fetches a day stay inside Enable
Banking's documented allowance of roughly four per account per day, which a
failing sync's hourly retries also draw from, so the bank rate limits that
prompted this are no more likely than before.
This commit is contained in:
Lars Nolden
2026-09-11 18:57:52 +02:00
parent b3e1c65a82
commit 673cbf917b
4 changed files with 21 additions and 8 deletions
+11 -4
View File
@@ -806,6 +806,12 @@ func (a *App) Sync(ctx context.Context) (State, error) {
return a.snapshot(ctx)
}
// syncInterval is how often connected accounts synchronize on their own. Twice
// a day halves how long a booking can sit unseen while staying inside Enable
// Banking's documented background allowance of roughly four fetches per day per
// account, which a failing sync's hourly retries also draw from.
const syncInterval = 12 * time.Hour
// 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
@@ -815,7 +821,7 @@ func syncSchedule(now time.Time, ops operational, force bool) (time.Duration, bo
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 {
if force || err != nil || ops.SyncError != "" || now.Sub(last) >= syncInterval {
return 0, true
}
return time.Minute, false
@@ -823,13 +829,14 @@ func syncSchedule(now time.Time, ops operational, force bool) (time.Duration, bo
// 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.
// problems clear without waiting for the next scheduled run, while bounding
// unattended traffic.
func syncBackoff(now time.Time, ops operational) time.Duration {
if ops.SyncError == "" {
return 24 * time.Hour
return syncInterval
}
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 min(retry.Sub(now)+time.Minute, syncInterval)
}
return time.Hour
}
+7 -2
View File
@@ -371,6 +371,9 @@ func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) {
waiting := operational{LastSync: stale, SyncError: "N26: rate limited", SyncRetryAt: now.Add(3 * time.Hour).Format(time.RFC3339)}
broken := operational{LastSync: stale, SyncError: "Trade Republic: retrieval failed"}
healthy := operational{LastSync: now.Format(time.RFC3339)}
// Twice daily: still fresh at six hours, due again after thirteen.
fresh := operational{LastSync: now.Add(-6 * time.Hour).Format(time.RFC3339)}
overdue := operational{LastSync: now.Add(-13 * time.Hour).Format(time.RFC3339)}
elapsed := operational{LastSync: stale, SyncError: waiting.SyncError, SyncRetryAt: now.Add(-time.Minute).Format(time.RFC3339)}
cases := []struct {
name string
@@ -384,6 +387,8 @@ func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) {
{"deadline elapsed", elapsed, false, 0, true},
{"failure without a deadline", broken, false, 0, true},
{"recent success", healthy, false, time.Minute, false},
{"halfway through the interval", fresh, false, time.Minute, false},
{"interval elapsed", overdue, false, 0, true},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
@@ -399,7 +404,7 @@ func TestSyncSchedulingRespectsTheBanksOwnRetryTime(t *testing.T) {
if backoff := syncBackoff(now, broken); backoff != time.Hour {
t.Fatalf("failure backoff = %s, want hourly retries", backoff)
}
if backoff := syncBackoff(now, healthy); backoff != 24*time.Hour {
t.Fatalf("successful backoff = %s, want daily synchronization", backoff)
if backoff := syncBackoff(now, healthy); backoff != 12*time.Hour {
t.Fatalf("successful backoff = %s, want twice-daily synchronization", backoff)
}
}