5.4 KiB
5.4 KiB
# 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
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:<token>
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:<token>:<yyyy‑mm‑dd> |
string | expires at UTC midnight | Counter starting at 1 |
### Typical value progression
{
"quota:abc123:2025-07-18": "198",
"quota:abc123:2025-07-18-RESET": "2025-07-19T00:00:00Z"
}
## 3 QuotaVerdict DTO
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 return429
- RetryAfter
> 0
⇒ populateRetry‑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):
{
"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+)
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:<token>
(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). |