Skip to content

Commit 89b03c5

Browse files
committed
fix(fixedwindow): add WindowExpiry method to expose window end time
Support X-Rate-Limit-Reset header by exposing the time when a key's current window expires. Returns zero time if the key has no active window.
1 parent 68e2a84 commit 89b03c5

2 files changed

Lines changed: 77 additions & 0 deletions

File tree

fixedwindow/fixedwindow.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,25 @@ func (c *Counter) IsLockedOut(key string) bool {
175175
return e.lockedOut
176176
}
177177

178+
// WindowExpiry returns the time when the current window for the given key
179+
// expires. Returns zero time if the key has no active window.
180+
func (c *Counter) WindowExpiry(key string) time.Time {
181+
c.mu.Lock()
182+
defer c.mu.Unlock()
183+
184+
e, ok := c.entries[key]
185+
if !ok {
186+
return time.Time{}
187+
}
188+
189+
if time.Now().After(e.windowEnd) {
190+
delete(c.entries, key)
191+
return time.Time{}
192+
}
193+
194+
return e.windowEnd
195+
}
196+
178197
// Reset clears all tracked keys and their counters.
179198
func (c *Counter) Reset() {
180199
c.mu.Lock()

fixedwindow/fixedwindow_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,64 @@ func TestIsLockedOut(t *testing.T) {
316316
})
317317
}
318318

319+
func TestWindowExpiry(t *testing.T) {
320+
t.Run("non-existent key", func(t *testing.T) {
321+
c := New(time.Minute, 100, 0)
322+
defer c.Stop()
323+
324+
assert.True(t, c.WindowExpiry("unknown").IsZero())
325+
})
326+
327+
t.Run("active key returns future time", func(t *testing.T) {
328+
c := New(time.Minute, 100, 0)
329+
defer c.Stop()
330+
331+
before := time.Now()
332+
c.Add("key1", 1)
333+
expiry := c.WindowExpiry("key1")
334+
335+
assert.False(t, expiry.IsZero())
336+
assert.True(t, expiry.After(before))
337+
assert.True(t, expiry.Before(before.Add(2*time.Minute)))
338+
})
339+
340+
t.Run("expired key returns zero time", func(t *testing.T) {
341+
c := New(50*time.Millisecond, 100, 0)
342+
defer c.Stop()
343+
344+
c.Add("key1", 1)
345+
time.Sleep(80 * time.Millisecond)
346+
347+
assert.True(t, c.WindowExpiry("key1").IsZero())
348+
})
349+
350+
t.Run("expired key is removed", func(t *testing.T) {
351+
c := New(50*time.Millisecond, 100, 0)
352+
defer c.Stop()
353+
354+
c.Add("key1", 1)
355+
assert.Equal(t, 1, c.Len())
356+
357+
time.Sleep(80 * time.Millisecond)
358+
c.WindowExpiry("key1")
359+
360+
assert.Equal(t, 0, c.Len())
361+
})
362+
363+
t.Run("consistent with window duration", func(t *testing.T) {
364+
window := 200 * time.Millisecond
365+
c := New(window, 100, 0)
366+
defer c.Stop()
367+
368+
before := time.Now()
369+
c.Add("key1", 1)
370+
expiry := c.WindowExpiry("key1")
371+
372+
expected := before.Add(window)
373+
assert.InDelta(t, expected.UnixMilli(), expiry.UnixMilli(), 50)
374+
})
375+
}
376+
319377
func TestReset(t *testing.T) {
320378
t.Run("clears all entries", func(t *testing.T) {
321379
c := New(time.Minute, 100, 0)

0 commit comments

Comments
 (0)