Files
nexarch/mail/internal/protoguard/protoguard.go
T
sysops 16c4ad0075 feat(mail): ING-07 einheitliche Fehlerbehandlung & Wiederverbindung IMAP/POP3
Neues Paket mail/internal/protoguard kapselt die für IMAP- und
POP3-Sessions gemeinsam benötigte Timeout- und Backoff-Logik einer
einzelnen Verbindung:

- Pro Protokollphase konfigurierbarer Idle-Read-Timeout (POP3:
  Authorization/Transaction, IMAP: NotAuthenticated/Selected), vor
  jedem Lesevorgang neu gesetzt.
- Sich verdoppelnder Backoff bei wiederholten Anmeldefehlversuchen
  einer Verbindung (BackoffBase bis BackoffMax), Verbindungstrennung
  nach konfigurierbarer Höchstzahl statt Dauerschleife.

Server.NewServer bleibt unverändert (Standardkonfiguration);
NewServerWithGuardConfig erlaubt abweichende Werte. Ressourcenaufräumung
bei Verbindungsabbruch war bereits durch defer conn.Close() strukturell
gegeben — der Timeout sorgt dafür, dass dieser Pfad auch bei hängenden
oder böswilligen Gegenstellen zuverlässig erreicht wird.

Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt:
Chaos-Test mit 30 hart gekappten Verbindungen während aktiver
Übertragung (kein Goroutine-Leck), Timeout-Auslösung in jeder
Protokollphase beider Server, steigender Backoff mit definierter
Verbindungstrennung nach Höchstzahl an Fehlversuchen.

go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~24 Pakete)
regressionsfrei getestet.
2026-09-01 00:51:52 +02:00

120 lines
3.6 KiB
Go

// Package protoguard bündelt die Fehlerbehandlungs- und
// Wiederverbindungslogik, die IMAP- und POP3-Sessions gemeinsam
// brauchen (ING-07): pro Protokollphase konfigurierbare Idle-Timeouts
// und Backoff statt Dauerschleife bei wiederholten Anmeldefehlern.
// Ressourcenaufräumung selbst passiert bereits strukturell durch
// defer conn.Close() in den Sessions — Guard sorgt dafür, dass dieser
// Pfad auch bei hängenden oder böswilligen Gegenstellen zuverlässig
// erreicht wird.
package protoguard
import (
"context"
"net"
"time"
)
// Phase identifiziert eine Protokollphase, für die ein eigener
// Idle-Timeout gilt.
type Phase string
// Config steuert Timeout- und Backoff-Verhalten einer Verbindung.
type Config struct {
// PhaseTimeout liefert den Idle-Timeout je Phase. Fehlt ein Eintrag,
// gilt DefaultTimeout.
PhaseTimeout map[Phase]time.Duration
// DefaultTimeout gilt, wenn für die aktuelle Phase kein eigener Wert
// gesetzt ist. 0 bedeutet: kein Timeout.
DefaultTimeout time.Duration
// MaxAuthFailures ist die Anzahl fehlgeschlagener Anmeldeversuche,
// nach der eine Verbindung getrennt wird. 0 bedeutet: unbegrenzt
// (kein Trennen, nur Backoff).
MaxAuthFailures int
// BackoffBase ist die Wartezeit vor der Antwort nach dem ersten
// Fehlversuch, verdoppelt sich je weiterem Fehlversuch bis
// BackoffMax.
BackoffBase time.Duration
BackoffMax time.Duration
}
// DefaultConfig liefert praxistaugliche Werte für Produktionsbetrieb.
func DefaultConfig() Config {
return Config{
DefaultTimeout: 5 * time.Minute,
MaxAuthFailures: 5,
BackoffBase: 200 * time.Millisecond,
BackoffMax: 5 * time.Second,
}
}
// Guard kapselt den Fehlerbehandlungszustand EINER Verbindung: aktuell
// angewandte Phase-Timeouts und Zahl der Anmeldefehlversuche.
type Guard struct {
cfg Config
authFailures int
}
// New erstellt einen Guard für eine einzelne Session.
func New(cfg Config) *Guard {
return &Guard{cfg: cfg}
}
// ApplyReadDeadline setzt die Lese-Deadline von conn passend zur
// angegebenen Protokollphase (Akzeptanzkriterium 2).
func (g *Guard) ApplyReadDeadline(conn net.Conn, phase Phase) error {
d := g.cfg.DefaultTimeout
if pd, ok := g.cfg.PhaseTimeout[phase]; ok {
d = pd
}
if d <= 0 {
return conn.SetReadDeadline(time.Time{})
}
return conn.SetReadDeadline(time.Now().Add(d))
}
// RecordAuthFailure zählt einen fehlgeschlagenen Anmeldeversuch dieser
// Verbindung und liefert die Backoff-Wartezeit vor der Fehlerantwort
// sowie ob die Verbindung danach getrennt werden muss (Akzeptanzkriterium
// 3: klar definierter Backoff statt Dauerschleife).
func (g *Guard) RecordAuthFailure() (backoff time.Duration, disconnect bool) {
g.authFailures++
backoff = g.backoffFor(g.authFailures)
disconnect = g.cfg.MaxAuthFailures > 0 && g.authFailures >= g.cfg.MaxAuthFailures
return backoff, disconnect
}
// ResetAuthFailures setzt den Fehlversuchszähler nach erfolgreicher
// Anmeldung zurück.
func (g *Guard) ResetAuthFailures() { g.authFailures = 0 }
func (g *Guard) backoffFor(failures int) time.Duration {
if g.cfg.BackoffBase <= 0 {
return 0
}
d := g.cfg.BackoffBase
for i := 1; i < failures; i++ {
d *= 2
if g.cfg.BackoffMax > 0 && d >= g.cfg.BackoffMax {
return g.cfg.BackoffMax
}
}
if g.cfg.BackoffMax > 0 && d > g.cfg.BackoffMax {
return g.cfg.BackoffMax
}
return d
}
// Wait wartet d, bricht aber bei ctx-Abbruch sofort ab, damit ein
// Server-Shutdown nicht auf eine laufende Backoff-Pause warten muss.
func (g *Guard) Wait(ctx context.Context, d time.Duration) {
if d <= 0 {
return
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}