# 26 · Quota‑Enforcement Flow (333 Scans/Day) > **Audience:** backend contributors, UI devs, CI‑plugin authors, third‑party > tool builders > **Version:** 1.0 (18 Jul 2025) — aligns with Quota rev 2.0 > **Scope:** runtime data‑flow, key formats, timing budget, error model --- ## 0 Why another doc? * **SRS** lists the *requirements* (FR‑4, FR‑4a…). * **Module Specs** describe the *code surface* (`IQuotaService`, `QuotaVerdict`). * **This file** stitches everything together so future maintainers can reason about race conditions, UX timing and API behaviour in one place. --- ## 1 High‑Level Sequence ```mermaid sequenceDiagram participant CLI as SanTech CLI participant API as API Gateway participant QU as Quota Service participant RD as Redis participant SCN as Scan Runner CLI->>API: POST /scan API->>QU: CheckAsync(token) QU->>RD: LUA INCR quota: alt Remaining > 0 RD-->>QU: counter + reset ts QU-->>API: verdict {IsAllowed=true} API->>SCN: enqueue scan SCN-->>API: result API-->>CLI: 200 OK (headers: remaining, reset) else Remaining == 0 → soft wall RD-->>QU: counter==limit QU-->>API: verdict {RetryAfter=5 s} API-->>CLI: 429 Too Many (Retry‑After: 5) else Hard Wall (≥ limit+30) RD-->>QU: counter≥limit+30 QU-->>API: verdict {RetryAfter=60 s} API-->>CLI: 429 Too Many (Retry‑After: 60) end ``` * **Soft wait‑wall** applies to the *first 30* blocked scans. * **Hard wait‑wall** (60 s) kicks in afterwards until the counter resets at **00:00 UTC**. --- ## 2 Key Redis Structures | Key pattern | Type | TTL | Purpose | |-------------|------|-----|---------| | `quota::` | *string* | *expires at UTC midnight* | Counter starting at `1` | ### Typical value progression ```json { "quota:abc123:2025-07-18": "198", "quota:abc123:2025-07-18-RESET": "2025-07-19T00:00:00Z" } ``` --- ## 3 QuotaVerdict DTO ```csharp public readonly record struct QuotaVerdict( bool IsAllowed, int Remaining, DateTimeOffset ResetUtc, TimeSpan RetryAfter) { public static QuotaVerdict Allowed(int remaining, DateTimeOffset resetUtc) => new(true, remaining, resetUtc, TimeSpan.Zero); public static QuotaVerdict Blocked(int remaining, DateTimeOffset resetUtc, TimeSpan retryAfter) => new(false, remaining, resetUtc, retryAfter); } ``` * **IsAllowed** `false` ⇒ API must return `429` * **RetryAfter** `> 0` ⇒ populate `Retry‑After` header (seconds) --- ## 4 HTTP Headers & Error Model | Header | Example | Meaning | |--------|---------|---------| | `X‑Stella‑Quota‑Remaining` | `117` | Scans left for the token today | | `X‑Stella‑Reset` | `2025‑07‑18T23:59:59Z` | When the counter resets | | `Retry‑After` | `5` or `60` | Seconds the client should back‑off | Error body (RFC 7807): ```json { "type": "https://stella-ops.org/probs/quota", "title": "Daily quota exceeded", "status": 429, "detail": "This token exhausted its 333 scans for 2025‑07‑18.", "quota": { "remaining": 0, "reset": "2025‑07‑18T23:59:59Z" }, "traceId": "00-1db39d4f3ab16fa1" } ``` --- ## 5 UI Behaviour | Threshold | Visual cue | |-----------|------------| | **≤ 133 scans remaining** | Yellow banner “*60 % of free tier used*” | | **0 remaining** (soft) | Red toast “*Back‑off 5 s*”; spinner shows countdown | | **0 remaining** (hard) | Modal blocks *New Scan* button; shows **HH:MM:SS** until reset | *WebSocket `quota/events` pushes live counters to avoid polling.* --- ## 6 Timing Budget | Operation | Budget (P95) | Impl detail | |-----------|--------------|-------------| | Redis LUA INCR | ≤ 0.8 ms | SHA script cached | | Verdict calc | ≤ 0.2 ms | branchless math | | Header injection | ≤ 0.1 ms | middleware sets `OnStarting` callback | | Soft delay | **exactly 5 000 ms** ± 50 ms | `Task.Delay` in controller | | Hard delay | **exactly 60 000 ms** ± 100 ms | same | --- ## 7 CLI Auto‑Retry Logic (SanTech v0.8+) ```csharp var resp = await http.PostAsync("/scan", payload, ct); if (resp.StatusCode == (HttpStatusCode)429 && autoRetry && retryCount < 1) { var after = int.Parse(resp.Headers.RetryAfter.Delta!.Value.TotalSeconds); Console.Error.WriteLine($"Quota hit – waiting {after}s…"); await Task.Delay(TimeSpan.FromSeconds(after), ct); retryCount++; goto Retry; } ``` *Retrieves `Retry‑After`; retries **once** if ≤ 60 s.* --- ## 8 Edge Cases & Tests | Case | Expected behaviour | Covered by | |------|--------------------|-----------| | Redis restart (counter key lost) | API treats missing key as `0`; safe default | IT‑QUOTA‑REDIS | | Two parallel scans exceed limit simultaneously | Only one passes; second gets `429` | RaceCondTests.cs | | Token with custom limit = 100 | Soft wall at ≥ 100; headers reflect custom limit | QuotaCustomLimitTests.cs | --- ## 9 Migration Notes (v0.7 → v0.8) * Old key `quota:` (no date) **deprecated** — migration script copies value to the new *dated* key and sets TTL to next UTC midnight. --- ## 10 Change Log | Ver | Date | Note | |-----|------|------| | 1.0 | 18 Jul 2025 | Initial end‑to‑end flow doc (Quota rev 2.0). |