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
+87
View File
@@ -4,11 +4,14 @@ import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"finance-duck/internal/analytics"
"finance-duck/internal/classification"
@@ -86,6 +89,90 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
}
}
func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
before := domain.Clone(s.Data)
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "300")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, model := range []string{"test/model", "test/another-model"} {
preview, err := a.Preview(ctx, PreviewRequest{
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true},
})
if err != nil {
t.Fatal(err)
}
if preview.Analysed != 2 || len(preview.Errors) != 2 || len(preview.Changes) != 0 {
t.Fatalf("rate-limited preview did not preserve both records: %+v", preview)
}
}
unchanged, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, unchanged.Data) {
t.Fatal("rate-limited previews changed canonical data")
}
a.mu.Lock()
result, err := a.importFacts(ctx, unchanged, []domain.Facts{sampleFacts("ALDI", "2026-09-10", "-12.34")})
a.mu.Unlock()
if err != nil {
t.Fatal(err)
}
if result.Imported != 1 || len(result.State.Data.Transactions) != 3 {
t.Fatal("provider cooldown lost the newly imported record")
}
for _, tx := range result.State.Data.Transactions {
if tx.Facts.ExternalID == hex.EncodeToString([]byte("ALDI")) {
if tx.Enrichment.Classification.Error == "" || tx.Enrichment.CategoryID != domain.ExpenseFallback {
t.Fatal("cooldown did not leave imported facts editable and unclassified")
}
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("previews and imports bypassed shared provider cooldown: %d requests", got)
}
}
func TestCancelledLastClassificationDoesNotProducePreview(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
cancel()
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
p, err := a.Preview(ctx, PreviewRequest{
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true},
})
if !errors.Is(err, context.Canceled) || p.ID != "" {
t.Fatalf("cancelled final record produced a preview: id=%q, error=%v", p.ID, err)
}
after, err := a.Snapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(s.Data, after.Data) {
t.Fatal("cancelled preview changed canonical data")
}
}
func mockClassifier(t *testing.T, a *App, inspect ...func(*http.Request)) {
t.Helper()
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {