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
+15 -10
View File
@@ -11,7 +11,7 @@ import {
Sparkles,
} from "lucide-react";
import type { Account, Institution, PreparedImport, State } from "./api";
import { money, request } from "./api";
import { localInstant, money, request } from "./api";
import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import type { Mutate } from "./ui";
interface Balance {
@@ -209,6 +209,8 @@ function backfillUnavailable(account: Account, state: State): string {
const connection = state.connections.find((c) => c.account_id === account.id);
if (connection?.status === "reconnect_required")
return "Reconnect this account before importing older history.";
if (connection?.status === "rate_limited")
return `The bank is rate limiting this account until ${localInstant(connection.retry_at ?? "")}; import older history after that.`;
if (
!account.id ||
!account.external_account_id ||
@@ -270,17 +272,19 @@ function AccountCard({
{account.iban && <small className="account-iban">{account.iban}</small>}
<div className="connection-status">
<span
className={`badge ${needsReconnect || connection?.status === "error" ? "connection-warning" : "neutral"}`}
className={`badge ${needsReconnect || connection?.status === "error" || connection?.status === "rate_limited" ? "connection-warning" : "neutral"}`}
>
{needsReconnect
? `${institution} needs reconnection`
: connection?.status === "connected"
? "Bank connected"
: connection?.status === "error"
? "Connection error"
: connection?.status === "local"
? "Local account"
: "Connection status unavailable"}
: connection?.status === "rate_limited"
? `${institution} rate limit until ${localInstant(connection.retry_at ?? "")}`
: connection?.status === "connected"
? "Bank connected"
: connection?.status === "error"
? "Connection error"
: connection?.status === "local"
? "Local account"
: "Connection status unavailable"}
</span>
{connection?.valid_until && (
<small>Authorization expires {connection.valid_until}</small>
@@ -291,7 +295,8 @@ function AccountCard({
{connection.history_months === 1 ? "month" : "months"}
</small>
)}
{connection?.error && (
{/* A rate limit is already stated, in local time, by the badge above. */}
{connection?.error && connection.status !== "rate_limited" && (
<small className="text-danger">{connection.error}</small>
)}
{connection && connection.status !== "local" && (
+29 -1
View File
@@ -70,9 +70,16 @@ export interface Connection {
country: string;
psu_type: string;
history_months: number;
status: "local" | "connected" | "reconnect_required" | "error";
status:
| "local"
| "connected"
| "reconnect_required"
| "rate_limited"
| "error";
valid_until: string;
error: string;
// retry_at is set while the bank rate limits this connection.
retry_at?: string;
}
export interface Institution {
name: string;
@@ -88,6 +95,9 @@ export interface State {
connections: Connection[];
status: {
sync_error: string;
// sync_retry_at is set only when every failure is a bank rate limit that
// clears by itself.
sync_retry_at?: string;
index_error: string;
last_sync: string;
banking_configured: boolean;
@@ -252,6 +262,24 @@ export function normalizeState(state: State): State {
for (const session of state.sessions) session.accounts ??= [];
return state;
}
// Retry deadlines are instants, not calendar days: show them in the viewer's
// own time zone rather than the server's UTC string.
export function localInstant(iso: string): string {
const at = new Date(iso);
if (!iso || Number.isNaN(at.getTime())) return iso;
const sameDay = at.toDateString() === new Date().toDateString();
return at.toLocaleString(
undefined,
sameDay
? { hour: "2-digit", minute: "2-digit" }
: {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
},
);
}
export function money(value: string, currency: string): string {
// Keep all financial values as decimal strings, including display formatting.
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value);
+10 -2
View File
@@ -17,7 +17,13 @@ import {
CircleHelp,
} from "lucide-react";
import type { State } from "./api";
import { APIError, emptyFilter, normalizeState, request } from "./api";
import {
APIError,
emptyFilter,
localInstant,
normalizeState,
request,
} from "./api";
import { Overview } from "./Overview";
import { Transactions } from "./Transactions";
import { Registry } from "./Registry";
@@ -300,7 +306,9 @@ function App() {
<span>
{state.status.index_error
? `Analytics needs attention: ${state.status.index_error}`
: `Bank sync needs attention: ${state.status.sync_error}`}
: state.status.sync_retry_at
? `Bank sync is waiting for your bank's rate limit and retries by itself after ${localInstant(state.status.sync_retry_at)}${state.status.sync_error}`
: `Bank sync needs attention: ${state.status.sync_error}`}
</span>
<button
className="button subtle"