Repository navigation
Expand file tree
/
Copy pathbypass.go
More file actions
91 lines (81 loc) · 3.48 KB
/
Copy pathbypass.go
File metadata and controls
91 lines (81 loc) · 3.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package gatekit
import "time"
// valveLogEntry is one line in bypass.log. Kind distinguishes a granted use
// from a denial, and *why* it was denied -- a denied attempt is logged
// under its own kind specifically so it never counts against the limit it
// just tripped (the exact bug found and fixed in reply-gate's own bypass
// this session).
type valveLogEntry struct {
At time.Time `json:"at"`
Kind string `json:"kind"` // "bypass" | "snooze" | "<kind>-denied-cooldown" | "<kind>-denied-ratelimit"
QueueLen int `json:"queue_len"`
}
// BypassResult is what RequestBypass returns.
type BypassResult struct {
Granted bool
Reason string // "cooldown" or "rate-limit", set only when denied
RetryAfter time.Duration
}
// ValveDeniedError is returned by Snooze when the shared escape-valve rate
// limit denies it. Bypass callers use BypassResult instead of an error,
// since "denied" is an expected, non-exceptional outcome there too --
// ValveDeniedError exists only because Snooze's signature otherwise has no
// room to carry the denial reason back to its caller.
type ValveDeniedError struct {
Reason string
RetryAfter time.Duration
}
func (e ValveDeniedError) Error() string { return "gatekit: escape valve denied: " + e.Reason }
// requestValve is the rate-limit/cooldown check shared by RequestBypass and
// Snooze: both are "let me not deal with this right now" actions, and both
// draw from the same budget in the same log, distinguished only by kind so
// the log stays a legible single timeline of every escape-valve use. This is
// deliberately much stricter than, and entirely separate from, the kill
// switch in killswitch.go -- see that file for why the two must never share
// a rate limit.
func requestValve(cfg Config, now time.Time, kind string) BypassResult {
window := cfg.BypassWindow.D()
recent, _ := readJSONLReverse(bypassLogPath(cfg), func(e valveLogEntry) bool {
return now.Sub(e.At) <= window
})
var grantedRecent []valveLogEntry
for _, e := range recent {
if e.Kind == kind {
grantedRecent = append(grantedRecent, e)
}
}
if len(grantedRecent) > 0 {
lastAt := grantedRecent[0].At // readJSONLReverse returns newest-first
if since := now.Sub(lastAt); since < cfg.BypassCooldown.D() {
appendJSONL(bypassLogPath(cfg), valveLogEntry{At: now, Kind: kind + "-denied-cooldown"})
return BypassResult{Reason: "cooldown", RetryAfter: cfg.BypassCooldown.D() - since}
}
}
if len(grantedRecent) >= cfg.BypassRateLimit {
oldest := grantedRecent[len(grantedRecent)-1].At
retryAfter := window - now.Sub(oldest)
appendJSONL(bypassLogPath(cfg), valveLogEntry{At: now, Kind: kind + "-denied-ratelimit"})
return BypassResult{Reason: "rate-limit", RetryAfter: retryAfter}
}
return BypassResult{Granted: true}
}
// RequestBypass is the immediate one-shot escape valve: waves through
// exactly the current prompt, subject to the same rate limit as Snooze.
// Callers are expected to log the grant themselves via the queueLen they
// already have on hand from the same Decision -- gatekit does that here so
// bypass.log carries an accurate queue_len at the moment of use.
func RequestBypass(cfg Config, now time.Time) (BypassResult, error) {
dec, err := Evaluate(cfg, now)
if err != nil {
return BypassResult{}, err
}
r := requestValve(cfg, now, "bypass")
if r.Granted {
if err := appendJSONL(bypassLogPath(cfg), valveLogEntry{
At: now, Kind: "bypass", QueueLen: len(dec.Overdue) + len(dec.Held),
}); err != nil {
return r, err
}
}
return r, nil
}