187 lines
5.4 KiB
Markdown
187 lines
5.4 KiB
Markdown
# 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:<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
|
||
|
||
```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:<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). |
|