You're reading docs for Nextly Alpha. APIs may change between releases.

Configuration

Limit store

Where limits keep their counts, how to share them between instances, and the contract for an app's own store.

Limits count attempts: sign-ins, password resets, codes sent. The limit store is where those counts are kept. rateLimit.store selects it, and every limit built on the limit store contract counts there.

Core's own sign-in and API limits do not count in it yet. They keep counting as before: in each process, or in an app's store of the earlier RateLimitStore contract.

Experimental. The setting's two shipped choices, "memory" and "database", and singleInstance are stable. The contract for an app's own store, and maxEntries, may still change.

Choosing a store

nextly.config.ts
import { defineConfig } from "nextly";

export default defineConfig({
  rateLimit: {
    store: "database",
  },
});
rateLimit.storeWhere counts liveShared between instances
"memory" (default)In each server process.No: on several instances, every budget is multiplied by their number.
"database"In the site's database, in the nextly_limit_store table.Yes.
Your own storeWherever your store keeps them.As your store says.

A site that runs one instance loses nothing with "memory". Say so with singleInstance: true:

nextly.config.ts
rateLimit: {
  singleInstance: true,
}

An app that already passes a store of the earlier RateLimitStore contract (increment, reset) keeps it serving core's own sign-in and API limits, as before. Limits built on the newer contract then count in memory.

The bound

Each store holds a limited number of live entries: 100,000 for memory and 1,000,000 for the database by default. rateLimit.maxEntries changes that:

nextly.config.ts
rateLimit: {
  store: "database",
  maxEntries: 200_000,
}

A store never removes a live count to make room. Removing one would let anyone able to send new keys free the count of an attacker they are covering for. Instead:

  • At 80% of the bound, the store logs a warning (kind: "limit-store-filling").
  • When full, it first removes expired entries. If there is still no room, it answers the new key as unavailable and logs an error (kind: "limit-store-full"), at most once a minute. Keys it already holds keep counting.

Each limit decides what "unavailable" means for its own request. Watch for both log lines.

The database store

  • Atomic across instances. Every count runs in a short transaction of its own, with the rows of its keys locked. Concurrent attempts on several instances count as if they ran one at a time, a key's first attempt included. A count never joins a transaction the calling code has open: calling a limit inside your own transaction is refused with an error, so a rollback can never undo a count.
  • On the database's clock. Expiry is judged on the database server's clock, so instances whose clocks differ still agree on when a window ends.
  • Swept. Each instance removes expired entries when it starts and every 5 minutes after, in the background. An expired entry is gone within 5 minutes, plus the time to work through a backlog.
  • Keys are hashed. Each row is stored under the SHA-256 of its key, never the key itself.
  • Secret rotation. Entries record which NEXTLY_SECRET they were written under. After a rotation the new instances can't read them, and they never count against the new instances' bound. They are removed once they expire, not sooner: during a rolling deployment, instances still on the earlier secret keep counting with them.
  • SQLite. SQLite writes one transaction at a time on the instance, so every count waits for any transaction open on the instance at that moment. A long-running transaction delays sign-in for its whole length.
  • Cost. One short transaction per counted request. The table reaches existing databases through nextly migrate, like every core table.

When a store does not answer

Every store call, to any store, is answered within 2 seconds. A call that takes longer, or a store that fails, is answered as unavailable, as when the store cannot be reached. A call past its deadline may still be running in the store, so at most 64 calls run at once: past that, a call is answered as unavailable at once, until the store catches up. With a store of your own, monitor it: while it is down, the limits built on it cannot count.

Your own store

An app can supply its own store, for example over Redis, by implementing the LimitStore contract (@experimental):

import type { LimitStore } from "nextly";

const store: LimitStore = {
  // True when every instance of the site sees the same entries.
  shared: true,
  // Atomically: read the live entries for `keys`, call `apply` with them and
  // the store's clock (milliseconds since the epoch), and write what it
  // returns (`null` deletes), with no other update to these keys in between.
  update(keys, apply) {
    throw new Error("Implement against your store");
  },
  // The live entries for `keys`, judged on the store's own clock.
  read(keys) {
    throw new Error("Implement against your store");
  },
  // Remove expired entries; answer how many went.
  sweep() {
    throw new Error("Implement against your store");
  },
};

Each entry is { value, expiresAt }. value is JSON of at most 4 KB, and expiresAt is in milliseconds on the store's clock. apply is synchronous, and may run more than once if your store retries.

Run the contract's cases against your store, with two handles on it as two instances would hold them:

import type { LimitStore } from "nextly";
import { limitStoreContract } from "nextly/testing";
import { it } from "vitest";

// Your store, as two instances of the site each hold it.
declare const storeOnInstanceA: LimitStore;
declare const storeOnInstanceB: LimitStore;

for (const contractCase of limitStoreContract) {
  it(contractCase.name, () =>
    contractCase.run({ first: storeOnInstanceA, second: storeOnInstanceB })
  );
}

A case that fails throws LimitStoreContractViolation saying what broke.