fix: Automatischer Abruf lief gegen die Abfragegrenze der Banken

"Weniger als 15 Minuten lassen die Banken nicht zu" stand als Hinweis im
Formular. Das war erfunden. PSD2 verpflichtet die Banken zu VIER
unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie ablehnen,
und N26 tut es: "Too many requests", und der Abruf scheitert, bis das
Zeitfenster weiterrueckt.

Bei zehn angebundenen Konten und einem Takt von 15 Minuten waren es tausende
Anfragen am Tag. Der Takt betraegt jetzt mindestens sechs Stunden, also genau
die zugesicherten vier je Tag. Bestehende, zu knappe Einstellungen werden beim
Faelligkeitspruefen mitgebremst – sonst liefe eine gespeicherte 15 weiter
dagegen.

Von Hand abrufen bleibt unbegrenzt: Dabei sitzt der Nutzer davor, und fuer
diesen Fall gilt die Grenze nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 13:11:35 +02:00
co-authored by Claude Opus 5
parent 6ed91ddd58
commit e0d8882851
6 changed files with 48 additions and 15 deletions
+9 -4
View File
@@ -158,14 +158,19 @@ def set_poll(connection_id: int, minutes: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)) -> dict:
"""Takt des automatischen Abrufs. 0 schaltet ihn ab.
Untergrenze 15 Minuten: Banken begrenzen unter PSD2 die Zahl der Abrufe je
Tag, und häufiger bringt nichts – Umsätze erscheinen nicht im Minutentakt.
Untergrenze sechs Stunden: PSD2 verpflichtet die Banken zu VIER
unbeaufsichtigten Abfragen je Konto und Tag. Häufiger dürfen sie ablehnen,
und sie tun es – N26 antwortet dann mit „Too many requests", und der Abruf
scheitert, bis das Zeitfenster weiterrückt. Von Hand abrufen darf man
weiterhin so oft man will: Dabei sitzt der Nutzer davor.
"""
verbindung = get_owned(db, BankConnection, connection_id, user)
if minutes != 0 and not 15 <= minutes <= 1440:
if minutes != 0 and not bank_sync.MIN_TAKT_MINUTEN <= minutes <= 10080:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Der Takt muss 0 (aus) oder zwischen 15 und 1440 Minuten liegen.",
detail=(f"Der Takt muss 0 (aus) oder mindestens "
f"{bank_sync.MIN_TAKT_MINUTEN} Minuten sein. Die Banken lassen "
f"nur vier unbeaufsichtigte Abrufe je Konto und Tag zu."),
)
verbindung.poll_minutes = minutes
db.commit()
+10 -1
View File
@@ -45,6 +45,12 @@ log = logging.getLogger(__name__)
ZUSTIMMUNG_TAGE = 180
# Wie weit jeder weitere Abruf hinter den letzten zurueckgreift.
RUECKGRIFF_TAGE = 7
# Kuerzester Takt fuer den automatischen Abruf. PSD2 verpflichtet die Banken zu
# VIER unbeaufsichtigten Abfragen je Konto und Tag – haeufiger duerfen sie
# ablehnen, und N26 tut das ("Too many requests"). Sechs Stunden sind genau
# diese vier. Von Hand abrufen darf man weiterhin so oft man will: Dabei sitzt
# der Nutzer davor, und dafuer gilt die Grenze nicht.
MIN_TAKT_MINUTEN = 360
# Ab wann gewarnt wird, dass neu autorisiert werden muss. Läuft die Zustimmung
# ab, hört der Abruf sonst stumm auf – dieselbe Falle wie „0 neu" ohne Grund.
WARNUNG_TAGE = 14
@@ -777,10 +783,13 @@ def faellig(verbindung: BankConnection, jetzt: datetime) -> bool:
"""
if not verbindung.poll_minutes:
return False
# Auch aeltere, zu knapp eingestellte Werte werden hier gebremst – sonst
# liefe eine bestehende Einstellung von 15 Minuten weiter gegen die Grenze.
takt = max(verbindung.poll_minutes, MIN_TAKT_MINUTEN)
zuletzt = _mit_zeitzone(verbindung.last_sync_at)
if zuletzt is None:
return True
return zuletzt + timedelta(minutes=verbindung.poll_minutes) <= jetzt
return zuletzt + timedelta(minutes=takt) <= jetzt
def abruf_mit_fehlerbehandlung(db: Session, user: User,
+25 -6
View File
@@ -812,29 +812,29 @@ def test_takt_null_laeuft_nie_und_sonst_erst_wenn_faellig(admin):
assert bank_sync.faellig(aus, jetzt) is False
nie_gelaufen = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
poll_minutes=60)
poll_minutes=720)
assert bank_sync.faellig(nie_gelaufen, jetzt) is True
frisch = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
poll_minutes=60,
poll_minutes=720,
last_sync_at=jetzt - timedelta(minutes=30))
assert bank_sync.faellig(frisch, jetzt) is False
# SQLite gibt Zeitstempel OHNE Zeitzone zurueck – das darf nicht werfen.
ohne_zone = BankConnection(owner_id=1, aspsp_name="X", aspsp_country="DE",
poll_minutes=60,
last_sync_at=(jetzt - timedelta(hours=2)).replace(tzinfo=None))
poll_minutes=720,
last_sync_at=(jetzt - timedelta(hours=13)).replace(tzinfo=None))
assert bank_sync.faellig(ohne_zone, jetzt) is True
def test_takt_wird_geprueft(admin, fake_bank):
verbindung, _, _ = _verbunden(admin, fake_bank)
assert admin.put(f"/api/bank/connections/{verbindung}/takt",
params={"minutes": 5}).status_code == 400
params={"minutes": 15}).status_code == 400, "zu haeufig"
assert admin.put(f"/api/bank/connections/{verbindung}/takt",
params={"minutes": 0}).json()["poll_minutes"] == 0
assert admin.put(f"/api/bank/connections/{verbindung}/takt",
params={"minutes": 240}).json()["poll_minutes"] == 240
params={"minutes": 720}).json()["poll_minutes"] == 720
def test_spaeter_gelieferte_gegenseite_wird_nicht_noch_einmal_gebucht(admin, fake_bank):
@@ -1288,3 +1288,22 @@ def test_saldo_kommt_erst_nach_den_umsaetzen(admin, fake_bank, monkeypatch):
aus_liste = [v for v in admin.get("/api/bank/links").json()
if v["remote_uid"] == uid][0]
assert aus_liste["balance_cents"] is None, "Saldo ohne Umsaetze geschrieben"
def test_zu_knapper_takt_wird_gebremst():
"""Eine bestehende Einstellung von 15 Minuten liefe sonst weiter gegen die
Grenze der Bank – und jeder Abruf scheiterte mit 'Too many requests'."""
from datetime import datetime, timezone
from app.models import BankConnection
from app.services import bank_sync
jetzt = datetime.now(timezone.utc)
knapp = BankConnection(owner_id=1, aspsp_name="N26", aspsp_country="DE",
poll_minutes=15,
last_sync_at=jetzt - timedelta(minutes=20))
assert bank_sync.faellig(knapp, jetzt) is False, "15 Minuten duerfen nicht greifen"
# Nach sechs Stunden schon.
knapp.last_sync_at = jetzt - timedelta(hours=7)
assert bank_sync.faellig(knapp, jetzt) is True