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:
@@ -204,14 +204,43 @@ type BackgroundQuotaError struct {
|
||||
*ratelimit.RateLimitError
|
||||
}
|
||||
|
||||
// Error describes the pause in one line: operators need the bank's own retry
|
||||
// time, not a stack of nested rate-limit wrappers.
|
||||
func (e *BackgroundQuotaError) Error() string {
|
||||
return "background bank retrieval quota (normally six hours): " + e.RateLimitError.Error()
|
||||
if e.RetryAt().IsZero() {
|
||||
return "the bank is rate limiting background retrieval (HTTP 429); automatic retry is disabled, synchronize manually later"
|
||||
}
|
||||
return "the bank is rate limiting background retrieval (HTTP 429, normally six hours); automatic retry at " + e.RetryAt().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (e *BackgroundQuotaError) Unwrap() error {
|
||||
return e.RateLimitError
|
||||
}
|
||||
|
||||
// ProviderError reports a provider interaction that failed for a reason Finance
|
||||
// Duck determined itself: the provider could not be reached, or its response
|
||||
// could not be used. Detail is written here and never taken from provider
|
||||
// response text, so callers may show the whole message to the user.
|
||||
type ProviderError struct {
|
||||
Unreachable bool
|
||||
Detail string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
message := "the bank's provider returned a response Finance Duck cannot use"
|
||||
if e.Unreachable {
|
||||
message = "the bank's provider could not be reached"
|
||||
}
|
||||
if e.Detail == "" {
|
||||
return message
|
||||
}
|
||||
return message + ": " + e.Detail
|
||||
}
|
||||
|
||||
// Unwrap keeps cancellation and deadline identity for callers that retry.
|
||||
func (e *ProviderError) Unwrap() error { return e.cause }
|
||||
|
||||
// APIError reports a failed Enable Banking call. Its message is built only
|
||||
// from the HTTP status and, when the response envelope's error code exactly
|
||||
// matches the documented enumeration, that code with a locally written hint.
|
||||
@@ -365,9 +394,9 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
|
||||
return nil, context.Canceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("request timed out: %w", context.DeadlineExceeded)
|
||||
return nil, &ProviderError{Unreachable: true, Detail: "the request timed out", cause: context.DeadlineExceeded}
|
||||
}
|
||||
return nil, errors.New("connection failed")
|
||||
return nil, &ProviderError{Unreachable: true, Detail: "the connection failed"}
|
||||
}
|
||||
if response.StatusCode == http.StatusTooManyRequests && scope != "" && !foreground {
|
||||
// Inspect only a small structured error envelope, never exposing its
|
||||
@@ -404,15 +433,15 @@ func (p *EnableBanking) request(ctx context.Context, method, path string, input,
|
||||
return context.Canceled
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return fmt.Errorf("Enable Banking response timed out: %w", context.DeadlineExceeded)
|
||||
return &ProviderError{Unreachable: true, Detail: "reading its response timed out", cause: context.DeadlineExceeded}
|
||||
}
|
||||
return fmt.Errorf("read Enable Banking response")
|
||||
return &ProviderError{Detail: "its response could not be read"}
|
||||
}
|
||||
if len(b) > limit {
|
||||
return fmt.Errorf("Enable Banking response exceeded size limit")
|
||||
return &ProviderError{Detail: "its response exceeded the size limit"}
|
||||
}
|
||||
if err = json.Unmarshal(b, output); err != nil {
|
||||
return fmt.Errorf("invalid Enable Banking response")
|
||||
return &ProviderError{Detail: "its response was not the expected JSON"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -633,10 +662,10 @@ func (p *EnableBanking) Exchange(ctx context.Context, code string) (Session, err
|
||||
return Session{}, err
|
||||
}
|
||||
if response.ID == "" {
|
||||
return Session{}, fmt.Errorf("Enable Banking returned no session ID")
|
||||
return Session{}, &ProviderError{Detail: "it returned no session identifier"}
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, response.Access.ValidUntil); err != nil {
|
||||
return Session{}, fmt.Errorf("Enable Banking returned invalid session expiry")
|
||||
return Session{}, &ProviderError{Detail: "it returned an invalid session expiry"}
|
||||
}
|
||||
result := Session{ID: response.ID, ValidUntil: response.Access.ValidUntil, Accounts: []domain.Account{}}
|
||||
// One account the journal cannot represent (securities or card entries
|
||||
@@ -669,7 +698,7 @@ func (p *EnableBanking) Status(ctx context.Context, sessionID string) (SessionSt
|
||||
}
|
||||
expires, err := time.Parse(time.RFC3339, response.Access.ValidUntil)
|
||||
if err != nil {
|
||||
return SessionStatus{}, fmt.Errorf("Enable Banking returned invalid session expiry")
|
||||
return SessionStatus{}, &ProviderError{Detail: "it returned an invalid session expiry"}
|
||||
}
|
||||
if !expires.After(time.Now()) {
|
||||
return SessionStatus{}, fmt.Errorf("Enable Banking session expired: %w", ErrReconnect)
|
||||
@@ -705,7 +734,7 @@ func (p *EnableBanking) Balances(ctx context.Context, externalAccountID string)
|
||||
for _, b := range response.Balances {
|
||||
amount, err := domain.ParseMoney(b.Amount.Amount)
|
||||
if err != nil || !validCurrency(b.Amount.Currency) {
|
||||
return nil, fmt.Errorf("Enable Banking returned invalid balance amount")
|
||||
return nil, &ProviderError{Detail: "it returned an invalid balance amount"}
|
||||
}
|
||||
result = append(result, Balance{Amount: amount, Currency: b.Amount.Currency, Type: b.Type, ReferenceDate: b.ReferenceDate})
|
||||
}
|
||||
@@ -776,30 +805,30 @@ func (p *EnableBanking) Transactions(ctx context.Context, account domain.Account
|
||||
}
|
||||
amount, err := domain.ParseMoney(t.Amount.Amount)
|
||||
if err != nil || strings.HasPrefix(amount.String(), "-") || !validCurrency(t.Amount.Currency) {
|
||||
return nil, fmt.Errorf("Enable Banking returned invalid transaction amount")
|
||||
return nil, &ProviderError{Detail: "a booked transaction has no usable amount or currency"}
|
||||
}
|
||||
party, iban := t.Debtor.Name, t.DebtorAccount.IBAN
|
||||
switch t.Indicator {
|
||||
case "DBIT":
|
||||
amount, err = domain.ParseMoney("-" + amount.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid debit amount")
|
||||
return nil, &ProviderError{Detail: "a booked debit has no usable amount"}
|
||||
}
|
||||
party, iban = t.Creditor.Name, t.CreditorAccount.IBAN
|
||||
case "CRDT":
|
||||
default:
|
||||
return nil, fmt.Errorf("Enable Banking returned invalid credit/debit indicator")
|
||||
return nil, &ProviderError{Detail: "a booked transaction has no credit/debit indicator"}
|
||||
}
|
||||
// Booked records without a booking date cannot be placed truthfully in the journal.
|
||||
if _, err := time.Parse("2006-01-02", t.BookingDate); err != nil {
|
||||
return nil, fmt.Errorf("Enable Banking booked transaction has no valid booking date")
|
||||
return nil, &ProviderError{Detail: "a booked transaction has no valid booking date"}
|
||||
}
|
||||
if (from != "" && t.BookingDate < from) || (to != "" && t.BookingDate > to) {
|
||||
continue
|
||||
}
|
||||
if t.ValueDate != "" {
|
||||
if _, err := time.Parse("2006-01-02", t.ValueDate); err != nil {
|
||||
return nil, fmt.Errorf("Enable Banking returned invalid value date")
|
||||
return nil, &ProviderError{Detail: "a booked transaction has an invalid value date"}
|
||||
}
|
||||
}
|
||||
description := strings.Join(t.Remittance, "\n")
|
||||
@@ -812,10 +841,10 @@ func (p *EnableBanking) Transactions(ctx context.Context, account domain.Account
|
||||
return result, nil
|
||||
}
|
||||
if seen[response.ContinuationKey] {
|
||||
return nil, fmt.Errorf("Enable Banking repeated a pagination key")
|
||||
return nil, &ProviderError{Detail: "it repeated a transaction pagination key"}
|
||||
}
|
||||
seen[response.ContinuationKey] = true
|
||||
query.Set("continuation_key", response.ContinuationKey)
|
||||
}
|
||||
return nil, fmt.Errorf("Enable Banking transaction pagination exceeded limit")
|
||||
return nil, &ProviderError{Detail: "its transaction pagination exceeded the supported page count"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user