Graham Thinks?

Sliding Window Rate Limiting with Deno KV

I'm building out an ingestion API for Obsidian called Fissure, and since I want to offer it to free to a small extend, I was looking into adding a rate limiter to reduce abuse. I'm deploying on Deno Deploy, and my storage backend is Deno KV, so I felt that a sliding window rate limiter was a good choice.

There are more detailed articles on the subject, but to my understanding the algorithm works something like this:

  1. Define a fixed interval of time as the window period—e.g. 1 hour, 1 day
  2. When a request comes in, get the number of requests made in the current period and the previous period

If the time is 3:45pm and your window period is 1 hour, get the number of requests made between 2–3pm and 3–4pm.

  1. Calculate the percentage overlap of the previous period with the current sliding window

If, again, the time is 3:45pm and your window period is 1 hour, your current window is 2:45pm–3:45pm—from an hour ago until the current moment. Therefore, the percentage overlap of the previous period with the current window is 0.25, or 25%.

  1. With all that information, use the following equation to get the number of requests n with the following values:
n = x + y * z

If n is greater than or equal to your maximum number of periodic requests, drop it. Here's roughly the implementation that I settled on:

import { eachHourOfInterval, subHours } from 'npm:date-fns'

const kv = await Deno.openKv()
const MAXIMUM_HOURLY_REQUESTS = 50
const HOUR_IN_MILLISECONDS = 3_600_000

async function shouldDropRequest(userId: string): Promise<boolean> {
  const windowEnd = new Date();
  const windowStart = subHours(windowEnd, 1);
  const [lastHour, currentHour] = eachHourOfInterval({
    start: windowStart,
    end: windowEnd,
  });

  const [lastPeriod, currentPeriod] = await kv.getMany<
    [Deno.KvU64, Deno.KvU64]
  >([
    ["user", userId, "period", lastHour.getTime().toString()],
    ["user", userId, "period", currentHour.getTime().toString()],
  ]);

  const currentRequests = Number(currentPeriod.value?.value ?? 0n);
  const previousRequests = Number(lastPeriod.value?.value ?? 0n);
  const previousWeight = (currentHour.getTime() - windowStart.getTime()) /
    3_600_000;

  if (
    Math.floor(currentRequests + (previousRequests * previousWeight)) >=
      MAXIMUM_HOURLY_REQUESTS
  ) {
    return true
  }

  if (currentPeriod.value === null) {
    await kv.atomic()
      .set(currentPeriod.key, new Deno.KvU64(1n), {
        expireIn: HOUR_IN_MILLISECONDS * 2,
      })
      .commit();
  } else {
    await kv.atomic()
      .sum(currentPeriod.key, 1n)
      .commit();
  }

  return false
}