Offline-Warteschlange: doppelt gesendete Aenderungen nach Zeitueberschreitung erkennen (Idempotenz-Schluessel) #236

Closed
opened 2026-10-02 17:48:06 +02:00 by lena · 3 comments
Collaborator

Hintergrund

Seit #228 werden vormerkbare Aenderungen (Einkaufsliste/Vorrat) auch dann vorgemerkt und spaeter wiederholt, wenn der Server mit 504 antwortet oder die Anfrage nach 20 s abgebrochen wird. Das ist noetig, weil der Browser einen toten Server nicht von einem langsamen unterscheiden kann - sonst schlagen Aenderungen waehrend eines echten Ausfalls fehl.

Problem

Bei 504 oder Zeitueberschreitung kann der Server die Anfrage trotzdem verarbeitet haben. Die spaetere Wiederholung wird dann ein zweites Mal ausgefuehrt. Harmlos bei absoluten Aenderungen (umbenennen, verschieben, abhaken, Zielmenge setzen), aber doppelt gezaehlt bei:

  • Vorrat einlagern / entnehmen / Entnahme rueckgaengig (CheckInPantryProductCommand, CheckOutPantryProductCommand, UndoPantryProductCheckOutCommand)
  • CreatePantryProductCommand (doppelter Eintrag bzw. Menge doppelt bei mergeWithExisting)
  • Mengen-Merge in AddOrActivateShoppingProductCommand

Bisher kein beobachteter Vorfall; Risiko aus dem Security-Review zu #228.

Akzeptanzkriterien

  • Jede vorgemerkte Aktion bekommt beim Vormerken einen eindeutigen, clientseitig erzeugten Schluessel, der bei jedem Sendeversuch gleich bleibt (auch schon beim ersten, direkten Versuch).
  • Das Backend fuehrt eine Aktion mit bereits bekanntem Schluessel nicht erneut aus, sondern antwortet wie beim ersten Mal (fuer die oben genannten Befehle).
  • Bekannte Schluessel werden nach einer begrenzten Zeit wieder geloescht (z. B. 7 Tage), damit die Tabelle nicht unbegrenzt waechst.
  • Schluessel sind pro Benutzer getrennt - ein fremder Schluessel kann keine Antwort eines anderen Benutzers liefern.
  • Tests: doppelt gesendete Entnahme zaehlt nur einmal; Backend-Unit-Tests und ein Frontend-Test fuer gleichbleibenden Schluessel ueber Wiederholungen.

Abgrenzung

  • Keine Aenderung am Retry-Verhalten aus #228.
## Hintergrund Seit #228 werden vormerkbare Aenderungen (Einkaufsliste/Vorrat) auch dann vorgemerkt und spaeter wiederholt, wenn der Server mit 504 antwortet oder die Anfrage nach 20 s abgebrochen wird. Das ist noetig, weil der Browser einen toten Server nicht von einem langsamen unterscheiden kann - sonst schlagen Aenderungen waehrend eines echten Ausfalls fehl. ## Problem Bei 504 oder Zeitueberschreitung kann der Server die Anfrage trotzdem verarbeitet haben. Die spaetere Wiederholung wird dann ein zweites Mal ausgefuehrt. Harmlos bei absoluten Aenderungen (umbenennen, verschieben, abhaken, Zielmenge setzen), aber doppelt gezaehlt bei: - Vorrat einlagern / entnehmen / Entnahme rueckgaengig (`CheckInPantryProductCommand`, `CheckOutPantryProductCommand`, `UndoPantryProductCheckOutCommand`) - `CreatePantryProductCommand` (doppelter Eintrag bzw. Menge doppelt bei `mergeWithExisting`) - Mengen-Merge in `AddOrActivateShoppingProductCommand` Bisher kein beobachteter Vorfall; Risiko aus dem Security-Review zu #228. ## Akzeptanzkriterien - [ ] Jede vorgemerkte Aktion bekommt beim Vormerken einen eindeutigen, clientseitig erzeugten Schluessel, der bei jedem Sendeversuch gleich bleibt (auch schon beim ersten, direkten Versuch). - [ ] Das Backend fuehrt eine Aktion mit bereits bekanntem Schluessel nicht erneut aus, sondern antwortet wie beim ersten Mal (fuer die oben genannten Befehle). - [ ] Bekannte Schluessel werden nach einer begrenzten Zeit wieder geloescht (z. B. 7 Tage), damit die Tabelle nicht unbegrenzt waechst. - [ ] Schluessel sind pro Benutzer getrennt - ein fremder Schluessel kann keine Antwort eines anderen Benutzers liefern. - [ ] Tests: doppelt gesendete Entnahme zaehlt nur einmal; Backend-Unit-Tests und ein Frontend-Test fuer gleichbleibenden Schluessel ueber Wiederholungen. ## Abgrenzung - Keine Aenderung am Retry-Verhalten aus #228.
lena self-assigned this 2026-10-02 23:59:08 +02:00
Author
Collaborator

Claimed by session "Offene Issues [08a09a]"

Starte mit Design: Idempotenz-Schluessel pro vorgemerkter Aktion, Deduplizierung im Backend fuer die Delta-Befehle.

Claimed by session "Offene Issues [08a09a]" Starte mit Design: Idempotenz-Schluessel pro vorgemerkter Aktion, Deduplizierung im Backend fuer die Delta-Befehle.
Author
Collaborator

Design (Architect) + Security-Vorpruefung, Session "Offene Issues [08a09a]"

Client: Fuer jeden offline-faehigen Befehl erzeugt callApi vor dem ersten Versuch einen zufaelligen Schluessel (128 Bit, crypto.getRandomValues - funktioniert auch ohne HTTPS) und schickt ihn als Header Idempotency-Key. Wird die Aktion vorgemerkt, wird der Schluessel mit ihr gespeichert und bei jeder Wiederholung unveraendert gesendet. Bereits vorgemerkte Aktionen ohne Schluessel (vor dem Update) laufen wie bisher.

Server: Neuer Decorator IdempotentCommandDecorator fuer die sechs Delta-Befehle (CheckIn/CheckOut/UndoCheckOut, CreatePantryProduct, AddOrActivateShoppingProduct - plus nichts sonst). Mit Schluessel und angemeldetem Benutzer:

  1. Gibt es schon einen Eintrag (Benutzer, Schluessel): gespeichertes Ergebnis zurueckgeben, Befehl nicht ausfuehren.
  2. Sonst: Befehl und Eintrag (Ergebnis als JSON) in einer Transaktion (bestehender request-weiter IDbTransaction) - der Eintrag existiert genau dann, wenn die Aenderung gespeichert ist (gleiches Prinzip wie RecipeIntegrationTransferEntity).
  3. Zwei gleichzeitige Anfragen mit demselben Schluessel: Unique-Index (UserId, Key) - die zweite wartet auf die erste, scheitert beim Insert, rollt komplett zurueck und liefert das Ergebnis der ersten.
    Die Berechtigungspruefung laeuft vor dem Decorator - wer keinen Zugriff mehr hat, bekommt auch keine gespeicherte Antwort. Verschachtelte Aufrufe (laufende Transaktion) und Anfragen, deren Pfad nicht zum Befehl passt, ignorieren den Schluessel.

Daten: Tabelle IdempotentRequests (UserId FK cascade, Key max. 100 Zeichen [A-Za-z0-9_-], RequestName, ResultJson, CreatedAtUtc). Eintraege aelter als 7 Tage werden beim naechsten Schreiben geloescht. Konto-Loeschung entfernt sie per Kaskade.

Security: Schluessel pro Benutzer getrennt (ein fremder Schluessel trifft nie den Eintrag eines anderen). Gespeichert wird nur die Antwort, die der Benutzer ohnehin bekommen hat (eigene Listendaten), max. 7 Tage. Gleicher Schluessel fuer einen anderen Befehl -> 400 statt fremder Antwort. Ungueltiger Schluessel -> 400.

Design (Architect) + Security-Vorpruefung, Session "Offene Issues [08a09a]" **Client**: Fuer jeden offline-faehigen Befehl erzeugt `callApi` vor dem ersten Versuch einen zufaelligen Schluessel (128 Bit, `crypto.getRandomValues` - funktioniert auch ohne HTTPS) und schickt ihn als Header `Idempotency-Key`. Wird die Aktion vorgemerkt, wird der Schluessel mit ihr gespeichert und bei jeder Wiederholung unveraendert gesendet. Bereits vorgemerkte Aktionen ohne Schluessel (vor dem Update) laufen wie bisher. **Server**: Neuer Decorator `IdempotentCommandDecorator` fuer die sechs Delta-Befehle (CheckIn/CheckOut/UndoCheckOut, CreatePantryProduct, AddOrActivateShoppingProduct - plus nichts sonst). Mit Schluessel und angemeldetem Benutzer: 1. Gibt es schon einen Eintrag (Benutzer, Schluessel): gespeichertes Ergebnis zurueckgeben, Befehl nicht ausfuehren. 2. Sonst: Befehl und Eintrag (Ergebnis als JSON) in **einer** Transaktion (bestehender request-weiter `IDbTransaction`) - der Eintrag existiert genau dann, wenn die Aenderung gespeichert ist (gleiches Prinzip wie `RecipeIntegrationTransferEntity`). 3. Zwei gleichzeitige Anfragen mit demselben Schluessel: Unique-Index (UserId, Key) - die zweite wartet auf die erste, scheitert beim Insert, rollt komplett zurueck und liefert das Ergebnis der ersten. Die Berechtigungspruefung laeuft **vor** dem Decorator - wer keinen Zugriff mehr hat, bekommt auch keine gespeicherte Antwort. Verschachtelte Aufrufe (laufende Transaktion) und Anfragen, deren Pfad nicht zum Befehl passt, ignorieren den Schluessel. **Daten**: Tabelle `IdempotentRequests` (UserId FK cascade, Key max. 100 Zeichen [A-Za-z0-9_-], RequestName, ResultJson, CreatedAtUtc). Eintraege aelter als 7 Tage werden beim naechsten Schreiben geloescht. Konto-Loeschung entfernt sie per Kaskade. **Security**: Schluessel pro Benutzer getrennt (ein fremder Schluessel trifft nie den Eintrag eines anderen). Gespeichert wird nur die Antwort, die der Benutzer ohnehin bekommen hat (eigene Listendaten), max. 7 Tage. Gleicher Schluessel fuer einen anderen Befehl -> 400 statt fremder Antwort. Ungueltiger Schluessel -> 400.
Author
Collaborator

Umgesetzt in 46bad319 (Backend), f49373d6 (Frontend), 1a493d89 (Team-Memory). CI gruen (Lauf zu b51cd2dd, der diese Commits enthaelt: Build, alle Tests, E2E, Docker).

Scope

  • Client: jeder offline-faehige Befehl bekommt beim ersten Versuch einen zufaelligen Schluessel (128 Bit, Header Idempotency-Key); wird er vorgemerkt, wird derselbe Schluessel bei jeder Wiederholung gesendet. Alte, schluessellose Eintraege laufen wie bisher.
  • Server: IdempotentCommandDecorator fuer CheckIn/CheckOut/UndoCheckOut, CreatePantryProduct, AddOrActivateShoppingProduct. Befehl und Eintrag (UserId, Key, Ergebnis-JSON) in einer Transaktion; Wiederholung liefert das gespeicherte Ergebnis ohne erneute Ausfuehrung. Gleichzeitige Duplikate per Primaerschluessel aufgeloest. Berechtigungspruefung laeuft vorher; verschachtelte Aufrufe ignorieren den Schluessel. Schluessel fuer anderen Befehl -> 400, ungueltiger Schluessel -> 400. Aufbewahrung 7 Tage, Kaskade mit dem Benutzerkonto. Neue Tabelle IdempotentRequestEntity (Migration AddIdempotentRequests).

Tests

  • Backend gegen echtes Postgres (9): Wiederholung zaehlt einmal + gleiches Ergebnis, ohne Schluessel wie bisher, verschiedene Schluessel, pro Benutzer getrennt, fehlgeschlagener Befehl hinterlaesst keinen Eintrag, Schluessel fuer anderen Befehl abgelehnt, zwei gleichzeitige Anfragen -> einmal angewendet, Aufraeumen nach 7 Tagen, verschachtelt ignoriert. Web-Schicht (6): Header nur fuer die eigene Route, ungueltige Schluessel abgelehnt, kein HTTP-Endpunkt fuer die interne Abfrage. Frontend (4): Schluessel beim ersten Versuch gesendet und unveraendert vorgemerkt, neuer Schluessel pro Aktion, keiner fuer andere Requests, Replay mit gespeichertem Schluessel.
  • Live im Review-Container: Vorrat +1 zweimal mit gleichem Schluessel -> nur +1; neuer Schluessel -> +1; ungueltiger -> 400.

Security-Review: keine Findings. Hinweis (kein Sicherheitsproblem): ein gespeichertes Ergebnis wird nur am Befehlstyp, nicht an den Parametern erkannt - der Client erzeugt pro Aktion einen neuen Schluessel, daher ohne praktische Auswirkung.

Umgesetzt in 46bad319 (Backend), f49373d6 (Frontend), 1a493d89 (Team-Memory). CI gruen (Lauf zu b51cd2dd, der diese Commits enthaelt: Build, alle Tests, E2E, Docker). **Scope** - Client: jeder offline-faehige Befehl bekommt beim ersten Versuch einen zufaelligen Schluessel (128 Bit, Header `Idempotency-Key`); wird er vorgemerkt, wird derselbe Schluessel bei jeder Wiederholung gesendet. Alte, schluessellose Eintraege laufen wie bisher. - Server: `IdempotentCommandDecorator` fuer CheckIn/CheckOut/UndoCheckOut, CreatePantryProduct, AddOrActivateShoppingProduct. Befehl und Eintrag (UserId, Key, Ergebnis-JSON) in einer Transaktion; Wiederholung liefert das gespeicherte Ergebnis ohne erneute Ausfuehrung. Gleichzeitige Duplikate per Primaerschluessel aufgeloest. Berechtigungspruefung laeuft vorher; verschachtelte Aufrufe ignorieren den Schluessel. Schluessel fuer anderen Befehl -> 400, ungueltiger Schluessel -> 400. Aufbewahrung 7 Tage, Kaskade mit dem Benutzerkonto. Neue Tabelle `IdempotentRequestEntity` (Migration AddIdempotentRequests). **Tests** - Backend gegen echtes Postgres (9): Wiederholung zaehlt einmal + gleiches Ergebnis, ohne Schluessel wie bisher, verschiedene Schluessel, pro Benutzer getrennt, fehlgeschlagener Befehl hinterlaesst keinen Eintrag, Schluessel fuer anderen Befehl abgelehnt, zwei gleichzeitige Anfragen -> einmal angewendet, Aufraeumen nach 7 Tagen, verschachtelt ignoriert. Web-Schicht (6): Header nur fuer die eigene Route, ungueltige Schluessel abgelehnt, kein HTTP-Endpunkt fuer die interne Abfrage. Frontend (4): Schluessel beim ersten Versuch gesendet und unveraendert vorgemerkt, neuer Schluessel pro Aktion, keiner fuer andere Requests, Replay mit gespeichertem Schluessel. - Live im Review-Container: Vorrat +1 zweimal mit gleichem Schluessel -> nur +1; neuer Schluessel -> +1; ungueltiger -> 400. **Security-Review**: keine Findings. Hinweis (kein Sicherheitsproblem): ein gespeichertes Ergebnis wird nur am Befehlstyp, nicht an den Parametern erkannt - der Client erzeugt pro Aktion einen neuen Schluessel, daher ohne praktische Auswirkung.
lena 2026-10-03 11:56:33 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#236
No description provided.