FDN-28: Rueckstau-Drosselung vor dem DB-Verbindungspool #253

Merged
patrick merged 4 commits from feature/fdn-28-rueckstau-drosselung-db-verbindungspool into main 2026-10-13 13:29:25 +02:00
7 changed files with 480 additions and 5 deletions

No files matched your search

+18 -1
View File
@@ -15,6 +15,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/dms/internal/apiwiring"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/cmis"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/dbadmission"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folderapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
@@ -122,6 +123,18 @@ func envInt(name string, def int) int {
return def
}
// admissionGate baut das Zulassungs-Gate (FDN-28) aus der Umgebung.
// NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT=0 schaltet die Kontrolle ab
// (Deps.Admission bleibt nil, Verhalten wie vor diesem Ticket).
func admissionGate() *dbadmission.Gate {
max := envInt("NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT", 40)
if os.Getenv("NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT") == "0" {
return nil
}
wait := envSeconds("NEXARCH_DMS_APP_ADMISSION_MAX_WAIT_SECONDS", 2*time.Second)
return dbadmission.NewGate(max, wait)
}
func envBool(name string, def bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(name)))
if v == "" {
@@ -244,7 +257,11 @@ func identityChain(next http.Handler, prefix string) (http.Handler, error) {
if jwksURL == "" && !allowCaller {
return nil, fmt.Errorf("keine identitaetsquelle: NEXARCH_DMS_APP_CORE_JWKS_URL setzen (oder uebergangsweise NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true)")
}
cfg := apiwiring.ChainConfig{Prefix: prefix, AllowCaller: allowCaller, HSTSMaxAge: envSeconds("NEXARCH_DMS_APP_HSTS_MAX_AGE_SECONDS", 0)}
cfg := apiwiring.ChainConfig{
Prefix: prefix, AllowCaller: allowCaller, HSTSMaxAge: envSeconds("NEXARCH_DMS_APP_HSTS_MAX_AGE_SECONDS", 0),
Admission: admissionGate(),
AdmissionRetryAfter: envSeconds("NEXARCH_DMS_APP_ADMISSION_RETRY_AFTER_SECONDS", 5*time.Second),
}
if policyURL := os.Getenv("NEXARCH_DMS_APP_POLICY_URL"); policyURL != "" {
token := os.Getenv("NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN")
if token == "" {
+17 -2
View File
@@ -18,6 +18,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/dbadmission"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/errortracking"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpharden"
@@ -34,8 +35,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/dms/internal/shared"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/upload"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/workflowevent"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/workerruntime"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/workflowevent"
)
func main() {
@@ -262,14 +263,28 @@ func startJobWorkers(ctx context.Context, pool *pgxpool.Pool, queue *jobqueue.Qu
log.Print("dms-worker: keine Workflow-Anbindung konfiguriert (NEXARCH_DMS_WORKER_WORKFLOW_URL), document.* wird nicht registriert/publiziert")
}
rt := workerruntime.New(deps)
admissionMax := 40
if v, err := strconv.Atoi(os.Getenv("NEXARCH_DMS_WORKER_ADMISSION_MAX_CONCURRENT")); err == nil && v > 0 {
admissionMax = v
}
var gate *dbadmission.Gate
if os.Getenv("NEXARCH_DMS_WORKER_ADMISSION_MAX_CONCURRENT") != "0" {
admissionWait := 2 * time.Second
if v, err := strconv.Atoi(os.Getenv("NEXARCH_DMS_WORKER_ADMISSION_MAX_WAIT_SECONDS")); err == nil && v > 0 {
admissionWait = time.Duration(v) * time.Second
}
gate = dbadmission.NewGate(admissionMax, admissionWait)
}
admit := dbadmission.JobMiddleware(gate)
rt.Middleware = func(next jobqueue.Handler) jobqueue.Handler {
return func(ctx context.Context, job *jobqueue.Job) error {
tracked := func(ctx context.Context, job *jobqueue.Job) error {
err := next(ctx, job)
if err != nil {
tracker.Capture(ctx, "worker."+job.JobType, err)
}
return err
}
return admit(tracked)
}
for _, n := range rt.Notices {
log.Print("dms-worker: ", n)
+132
View File
@@ -0,0 +1,132 @@
# FDN-28 – Prüfprotokoll: Rückstau/Drosselung vor dem DB-Verbindungspool
Nachtrag zu QA-10. QA-10 (`feature/qa-10-last-leistungstest`, Commit
`da34550`, noch nicht auf `main` gemerged) hatte bei 150 gleichzeitigen
Anfragen einen echten Engpass gefunden: Postgres meldete `too many clients
already` (SQLSTATE 53300), sowohl für HTTP-Anfragen als auch für
Hintergrundjobs (Malware-Scan, Such-Sync) — als rohe Verbindungsfehler statt
einer definierten `503`-Antwort. Dieses Ticket fügt die fehlende
Zulassungskontrolle (Admission Control) zwischen Anfragevolumen und
DB-Zugriff ein.
**Hinweis zur Ausgangslage:** Der Ticket-Text nennt `dms/docs/QA-10-PRUEFPROTOKOLL.md`
als "jetzt auf main gemerged". Das stimmt zum Zeitpunkt dieser Arbeit nicht
— QA-10 liegt noch unverändert auf seinem eigenen Feature-Branch
(`feature/qa-10-last-leistungstest`), nicht auf `main`. Das Dokument wurde
trotzdem direkt aus diesem Branch gelesen (Abschnitt 3 dort beschreibt genau
den hier behobenen Engpass); die Implementierung dieses Tickets ist davon
unabhängig und baut auf dem aktuellen `main` auf.
## 1. Lösung
Neues Paket `dms/internal/dbadmission`:
- `Gate`: Zulassungssemaphor (gepuffertes Kanal-Semaphor) mit konfigurierbarer
maximaler Nebenläufigkeit und maximaler Wartezeit auf einen freien
Durchlass.
- `Middleware(g, retryAfter)`: HTTP-Middleware — wird das Gate innerhalb der
Wartezeit nicht frei, antwortet sie mit `503` (Core-Fehlerformat,
`apierror.Write`, Code `unavailable`) und `Retry-After`-Header statt den
Request an den DB-zugreifenden Handler durchzulassen.
- `JobMiddleware(g)`: Job-Queue-Middleware — bei erschöpftem Gate wird ein
gewöhnlicher (transienter) Fehler zurückgegeben, kein
`jobqueue.LimitExceededError`. Das sorgt dafür, dass `Queue.Fail` den Job
mit exponentiellem Backoff erneut einplant (verzögerte Verarbeitung),
statt ihn in die Dead-Letter-Queue zu verschieben oder Daten zu verlieren.
- Ein `nil`-Gate lässt jede Anfrage/jeden Job unverändert durch (Admission
deaktiviert, z. B. für Bestandsinstallationen ohne explizite
Konfiguration).
Verdrahtung:
- `dms/internal/apiwiring.ChainConfig` bekommt `Admission *dbadmission.Gate`
und `AdmissionRetryAfter time.Duration`; `Chain()` schaltet die Middleware
vor Authentifizierung/Rechteentscheidung (frühestmöglicher Ausstieg bei
Überlast, bevor unnötige Arbeit anfällt).
- `dms/cmd/app/api.go`: `identityChain` baut das Gate aus der Umgebung
(`NEXARCH_DMS_APP_ADMISSION_MAX_CONCURRENT`, Default 40;
`NEXARCH_DMS_APP_ADMISSION_MAX_WAIT_SECONDS`, Default 2;
`NEXARCH_DMS_APP_ADMISSION_RETRY_AFTER_SECONDS`, Default 5;
`...MAX_CONCURRENT=0` schaltet ab).
- `dms/cmd/worker/main.go`: `rt.Middleware` wird um `dbadmission.JobMiddleware`
erweitert (eigene Env-Variablen `NEXARCH_DMS_WORKER_ADMISSION_MAX_CONCURRENT`/
`..._MAX_WAIT_SECONDS`, gleiche Defaults/Abschaltlogik). Das Gate sitzt
außerhalb der bestehenden Tracker-Middleware, sodass Gate-Erschöpfung
selbst nicht als Fehler im Error-Tracking auftaucht (erwartetes
Rückstau-Verhalten, kein Bug).
## 2. Automatisierte Tests
```
cd dms && go build ./... # OK
cd dms && go vet ./... # OK
cd dms && gofmt -l internal/dbadmission internal/apiwiring cmd/app cmd/worker # keine neuen Abweichungen
cd dms && TEST_TENANT_DSN=... TEST_MANTICORE_ADDR=127.0.0.1:9306 \
go test ./internal/apiwiring/... ./internal/dbadmission/... \
./internal/jobqueue/... ./internal/workerruntime/... -p 1 -v
```
Ergebnis: alle Pakete **PASS**, keine Regression in bestehenden Tests.
Neue Tests je Akzeptanzkriterium:
| Test | Akzeptanzkriterium | Ergebnis |
|---|---|---|
| `dbadmission.TestMiddleware_UeberGrenze_503MitRetryAfter` | 1 | **bestanden** — 503 + `Retry-After` statt rohem Fehler |
| `apiwiring.TestChain_Admission_UeberGrenze_503MitRetryAfter` | 1 (reale Verdrahtung über `Chain()`, nicht nur isoliertes Paket) | **bestanden** |
| `dbadmission.TestJobMiddleware_ErschoepftesGate_VerzoegertStattDatenverlust` | 2 | **bestanden** — transienter Fehler statt `LimitExceededError`; Job a (vorher gestartet) läuft unverändert zu Ende durch |
| `dbadmission.TestMiddleware_UnterGrenze_Unveraendert` | 3 | **bestanden** — 5 Anfragen unterhalb der Grenze, alle 200 |
| `apiwiring.TestChain_OhneAdmission_Unveraendert` | 3 | **bestanden** — ohne konfiguriertes Gate (`ChainConfig{}`) unverändertes Verhalten |
| `dbadmission.TestGate_NilGate_LaesstAllesDurch` | 3 (Regression bei deaktivierter Kontrolle) | **bestanden** |
## 3. Prüfungen vor der Abnahme
**1. Simulierte Überlast:** `TestChain_Admission_UeberGrenze_503MitRetryAfter`
fährt die tatsächliche Produktions-`Chain()`-Funktion (nicht nur das
isolierte `dbadmission`-Paket) mit einem Gate der Kapazität 1: eine
blockierende Anfrage hält den einzigen Durchlass, eine zweite, gleichzeitige
Anfrage erhält `503` mit `Retry-After: 9` statt eines rohen
Verbindungsfehlers. **Bestanden.**
Bewusst NICHT real gegen den geteilten Postgres-Dienst von `dms_dev`
reproduziert: Das würde (wie in QA-10 dokumentiert) echte
`max_connections`-Erschöpfung auf einem von mehreren Sitzungen gemeinsam
genutzten Testhost erfordern und andere parallele Arbeit auf 204 gefährden.
Die Zulassungskontrolle selbst ist DB-agnostisch (sie begrenzt Nebenläufigkeit
vor dem Handler-Aufruf, unabhängig davon, was der Handler tut) — der
HTTP-Ebenen-Test deckt den Mechanismus vollständig ab, ohne dieses Risiko
einzugehen.
**2. Hintergrundjobs unter Überlast:**
`TestJobMiddleware_ErschoepftesGate_VerzoegertStattDatenverlust` zeigt: bei
erschöpftem Gate kommt ein transienter Fehler zurück (kein
`LimitExceededError`), der in der echten Job-Verarbeitung
(`jobqueue.Worker.processOne`) zu `Queue.Fail` (exponentielles Backoff, bis
zu `MaxAttempts` Versuche) statt zu einem sofortigen Fehlschlag/Datenverlust
führt. Der zuerst gestartete Job läuft unverändert bis zum Erfolg durch —
kein Job geht verloren. **Bestanden.**
**3. Bestehende QA-10-Prüfungen laufen unverändert grün:** QA-10 ist nicht
auf `main` gemerged (siehe Hinweis oben) und daher auf diesem Branch nicht
vorhanden/ausführbar. Stattdessen wurde die volle bestehende Testsuite der
betroffenen Pakete (`apiwiring`, `jobqueue`, `workerruntime`) gegen die
reale `dms_dev`-Datenbank auf Host 204 gefahren — alle **PASS**, keine
Regression durch die neue Zulassungskontrolle.
## 4. Gesamtergebnis
| Akzeptanzkriterium | Status |
|---|---|
| 1. 503 + Retry-After statt rohem Verbindungsfehler bei Überlast | **erfüllt** |
| 2. Hintergrundjobs verzögert statt fehlzuschlagen | **erfüllt** |
| 3. Verhalten unterhalb der Grenze unverändert | **erfüllt** |
| Prüfung vor der Abnahme | Status |
|---|---|
| 1. Simulierte Überlast: definierte 503-Antworten | **erfüllt** (HTTP-Ebene real gegen `Chain()`; keine reale Postgres-Verbindungserschöpfung auf geteiltem Testhost provoziert, siehe Begründung oben) |
| 2. Hintergrundjobs unter Überlast: verzögert, kein Datenverlust | **erfüllt** |
| 3. Bestehende QA-10-Prüfungen laufen unverändert grün | **nicht anwendbar** (QA-10 nicht auf `main`), stattdessen volle betroffene Testsuite real gegen `dms_dev` gefahren, grün |
Alle drei Akzeptanzkriterien sind erfüllt. Prüfung 3 der Vorgabe war wegen
des nicht gemergten QA-10-Standes nicht wörtlich ausführbar; als Ersatz
wurde die reale, betroffene Testsuite gegen die Live-Datenbank gefahren.
@@ -0,0 +1,62 @@
package apiwiring
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/dbadmission"
)
// TestChain_Admission_UeberGrenze_503MitRetryAfter deckt FDN-28
// Akzeptanzkriterium 1 auf Ebene von Chain (die tatsaechliche Verdrahtung
// vor den HTTP-Handlern, nicht nur das dbadmission-Paket isoliert).
func TestChain_Admission_UeberGrenze_503MitRetryAfter(t *testing.T) {
gate := dbadmission.NewGate(1, 20*time.Millisecond)
cfg := ChainConfig{Admission: gate, AdmissionRetryAfter: 9 * time.Second}
release := make(chan struct{})
started := make(chan struct{})
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
w.WriteHeader(http.StatusOK)
}), cfg)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
}()
<-started
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, erwartet 503", rec.Code)
}
if got := rec.Header().Get("Retry-After"); got != "9" {
t.Fatalf("Retry-After=%q, erwartet 9", got)
}
close(release)
wg.Wait()
}
// TestChain_OhneAdmission_Unveraendert deckt FDN-28 Akzeptanzkriterium 3:
// ohne konfiguriertes Gate (wie vor diesem Ticket) bleibt das Verhalten
// unveraendert.
func TestChain_OhneAdmission_Unveraendert(t *testing.T) {
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}), ChainConfig{})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, erwartet 200", rec.Code)
}
}
+13 -2
View File
@@ -15,6 +15,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/dms/internal/bulkedit"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/bulkeditapi"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/dbadmission"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/documentapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/downloadapi"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice"
@@ -123,10 +124,17 @@ type ChainConfig struct {
Policy *policyclient.EnforceConfig
// HSTSMaxAge > 0 setzt Strict-Transport-Security (nur hinter TLS sinnvoll).
HSTSMaxAge time.Duration
// Admission begrenzt gleichzeitige Anfragen vor dem DB-Pool (FDN-28);
// nil = keine Zulassungskontrolle (Verhalten wie vor diesem Ticket).
Admission *dbadmission.Gate
// AdmissionRetryAfter ist der Retry-After-Wert bei 503 durch Admission
// (<=0 = dbadmission.DefaultRetryAfter).
AdmissionRetryAfter time.Duration
}
// Chain schaltet vor next: einheitliches Fehlerformat, Authentifizierung
// (Core-JWT) und, wenn konfiguriert, die zentrale Rechteentscheidung.
// Chain schaltet vor next: Zulassungskontrolle vor dem DB-Pool, einheitliches
// Fehlerformat, Authentifizierung (Core-JWT) und, wenn konfiguriert, die
// zentrale Rechteentscheidung.
func Chain(next http.Handler, c ChainConfig) http.Handler {
handler := next
if c.Policy != nil {
@@ -137,5 +145,8 @@ func Chain(next http.Handler, c ChainConfig) http.Handler {
Verifier: c.Verifier, Prefix: c.Prefix, AllowCaller: c.AllowCaller, Public: openapi.PublicPaths(),
}, handler)
}
if c.Admission != nil {
handler = dbadmission.Middleware(c.Admission, c.AdmissionRetryAfter)(handler)
}
return SecurityHeaders(apierror.Normalize(handler), c.HSTSMaxAge)
}
+110
View File
@@ -0,0 +1,110 @@
// Package dbadmission begrenzt gleichzeitige Anfragen/Jobs vor dem
// Datenbank-Verbindungspool (FDN-28, Nachtrag zu QA-10): statt roher
// Postgres-Verbindungsfehler (SQLSTATE 53300) bei Ueberlast liefert HTTP
// eine definierte 503-Antwort mit Retry-After, Hintergrundjobs werden
// verzoegert (Backoff ueber jobqueue.Queue.Fail) statt mit Verbindungsfehler
// abzubrechen.
package dbadmission
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/apierror"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
)
// Gate ist ein Zulassungssemaphor mit begrenzter Wartezeit vor dem
// eigentlichen DB-Zugriff. Ein nil-*Gate laesst jede Anfrage/jeden Job
// unveraendert durch (Admission deaktiviert).
type Gate struct {
slots chan struct{}
maxWait time.Duration
}
// NewGate baut ein Gate mit maxConcurrent gleichzeitigen Durchlaessen;
// maxWait ist die Zeit, die eine Anfrage auf einen freien Durchlass wartet,
// bevor sie als ausgeschoepft gilt.
func NewGate(maxConcurrent int, maxWait time.Duration) *Gate {
if maxConcurrent <= 0 {
maxConcurrent = 1
}
return &Gate{slots: make(chan struct{}, maxConcurrent), maxWait: maxWait}
}
func (g *Gate) acquire(ctx context.Context) (release func(), ok bool) {
if g == nil {
return func() {}, true
}
select {
case g.slots <- struct{}{}:
return func() { <-g.slots }, true
default:
}
timer := time.NewTimer(g.maxWait)
defer timer.Stop()
select {
case g.slots <- struct{}{}:
return func() { <-g.slots }, true
case <-ctx.Done():
return nil, false
case <-timer.C:
return nil, false
}
}
// DefaultRetryAfter ist der Fallback-Wert fuer den Retry-After-Header, wenn
// RetryAfter nicht gesetzt (<=0) ist.
const DefaultRetryAfter = 5 * time.Second
// Middleware schaltet vor next eine Zulassungskontrolle: wird das Gate
// innerhalb von maxWait nicht frei, antwortet die API mit 503 und
// Retry-After statt den rohen DB-Verbindungsfehler zu erreichen.
func Middleware(g *Gate, retryAfter time.Duration) func(http.Handler) http.Handler {
if retryAfter <= 0 {
retryAfter = DefaultRetryAfter
}
return func(next http.Handler) http.Handler {
if g == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
release, ok := g.acquire(r.Context())
if !ok {
w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())))
apierror.Write(w, http.StatusServiceUnavailable, "unavailable", "dienst ueberlastet, bitte spaeter erneut versuchen")
return
}
defer release()
next.ServeHTTP(w, r)
})
}
}
// errGateExhausted ist bewusst ein gewoehnlicher (transienter) Fehler statt
// jobqueue.LimitExceededError: so greift Queue.Fail mit Backoff/Wiederholung
// statt den Job sofort in die Dead-Letter-Queue zu verschieben (FDN-28
// Akzeptanzkriterium 2 – Rueckstau statt Datenverlust).
var errGateExhausted = errors.New("dbadmission: zulassungsgrenze erreicht, job wird spaeter erneut versucht")
// JobMiddleware verzoegert Hintergrundjobs (Malware-Scan, Such-Sync, ...) bei
// erschoepftem Gate statt sie mit einem rohen DB-Verbindungsfehler scheitern
// zu lassen.
func JobMiddleware(g *Gate) func(jobqueue.Handler) jobqueue.Handler {
return func(next jobqueue.Handler) jobqueue.Handler {
if g == nil {
return next
}
return func(ctx context.Context, job *jobqueue.Job) error {
release, ok := g.acquire(ctx)
if !ok {
return errGateExhausted
}
defer release()
return next(ctx, job)
}
}
}
@@ -0,0 +1,128 @@
package dbadmission
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
)
// TestMiddleware_UnterGrenze_Unveraendert deckt Akzeptanzkriterium 3: unterhalb
// der Zulassungsgrenze bleibt das Verhalten unveraendert (kein 503, keine
// Verzoegerung).
func TestMiddleware_UnterGrenze_Unveraendert(t *testing.T) {
g := NewGate(2, 50*time.Millisecond)
handler := Middleware(g, time.Second)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusOK {
t.Fatalf("anfrage %d: status=%d, erwartet 200", i, rec.Code)
}
}
}
// TestMiddleware_UeberGrenze_503MitRetryAfter deckt Akzeptanzkriterium 1: bei
// Ueberschreiten der Zulassungsgrenze antwortet die API mit 503 und
// Retry-After statt eines rohen Verbindungsfehlers.
func TestMiddleware_UeberGrenze_503MitRetryAfter(t *testing.T) {
g := NewGate(1, 30*time.Millisecond)
release := make(chan struct{})
started := make(chan struct{})
slow := Middleware(g, 7*time.Second)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
w.WriteHeader(http.StatusOK)
}))
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
rec := httptest.NewRecorder()
slow.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
}()
<-started
rec := httptest.NewRecorder()
slow.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d, erwartet 503", rec.Code)
}
if got := rec.Header().Get("Retry-After"); got != "7" {
t.Fatalf("Retry-After=%q, erwartet 7", got)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type=%q, erwartet application/json (Core-Fehlerformat)", ct)
}
close(release)
wg.Wait()
}
// TestJobMiddleware_ErschoepftesGate_VerzoegertStattDatenverlust deckt
// Akzeptanzkriterium 2: Hintergrundjobs laufen bei Ueberlast verzoegert
// (transienter Fehler -> Queue.Fail-Backoff) statt mit einem rohen
// Verbindungsfehler zu scheitern; LimitExceededError (-> Dead-Letter) wird
// dabei nicht ausgeloest.
func TestJobMiddleware_ErschoepftesGate_VerzoegertStattDatenverlust(t *testing.T) {
g := NewGate(1, 20*time.Millisecond)
release := make(chan struct{})
started := make(chan struct{})
slow := JobMiddleware(g)(func(ctx context.Context, job *jobqueue.Job) error {
close(started)
<-release
return nil
})
done := make(chan error, 1)
go func() { done <- slow(context.Background(), &jobqueue.Job{ID: "a"}) }()
<-started
err := slow(context.Background(), &jobqueue.Job{ID: "b"})
if err == nil {
t.Fatal("erwarteter Fehler bei erschoepftem Gate blieb aus")
}
var limitErr *jobqueue.LimitExceededError
if errors.As(err, &limitErr) {
t.Fatal("erschoepftes Gate darf keinen LimitExceededError (-> Dead-Letter) ausloesen, sondern einen transienten Fehler (-> Backoff)")
}
close(release)
if err := <-done; err != nil {
t.Fatalf("job a: unerwarteter fehler: %v", err)
}
}
// TestGate_NilGate_LaesstAllesDurch stellt sicher, dass ein abgeschaltetes
// Gate (nil) weder HTTP noch Jobs beeinflusst.
func TestGate_NilGate_LaesstAllesDurch(t *testing.T) {
handler := Middleware(nil, time.Second)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, erwartet 200", rec.Code)
}
called := false
wrapped := JobMiddleware(nil)(func(ctx context.Context, job *jobqueue.Job) error {
called = true
return nil
})
if err := wrapped(context.Background(), &jobqueue.Job{}); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("job-handler wurde bei nil-gate nicht aufgerufen")
}
}