feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(sdk): rate-limit counters can live somewhere shared - #29

Merged
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store
Aug 22, 2026
Merged

feat(sdk): rate-limit counters can live somewhere shared#29
cport1 merged 1 commit into
mainfrom
feat/rate-limit-store

Conversation

@cport1

Copy link
Copy Markdown
Contributor

Closes #726. Stacked on #28 (typed decision) — the NOT_RUN state it reports comes from there.

The bug

rate-limit-rule.ts:19 hard-constructed new InMemoryRateLimiter() — a Map, with no seam to replace it. On any deployment with more than one process the limit is effectively max × instances, and on Vercel or Lambda it also resets on every cold start. rateLimit({ max: 100, window: 60 }) across eight replicas is an 800/min limit that an autoscaler can raise for you.

This was inconsistent with the rest of the SDK: detection/stores.ts and the captcha's challenge and token stores were already behind swappable interfaces with in-memory defaults. The one piece of state that actually has to be shared was the only one that could not be.

The sync/async problem

Rule.evaluate() is synchronous. Making it async would turn every rule evaluation into a promise for the sake of the one rule that might need it, and the in-memory path — which is most installs — would pay for a network store nobody configured.

So a store declares which it is:

  • Sync store → consumed inline during evaluate(). The default in-memory path, unchanged and allocation-free.
  • Async store → consumed in a new optional Rule.prepare(context), which protect() awaits before evaluation. This is the pre-fetch pattern the SDK already uses for IP enrichment and Web Bot Auth verdicts, so it fits the existing architecture rather than inventing a second one.

A rule whose async store was never prepared reports NOT_RUN, not ALLOW. The synchronous evaluateRules() cannot consume a networked store, and a rate limiter that has quietly stopped limiting is indistinguishable from one that is working — that is the failure mode worth being loud about.

The Upstash store

Ships in the core package, not a sixth workspace, and calls the REST API with fetch rather than depending on @upstash/redis. Two commands in a pipeline is not worth a dependency, a version to track, or a transitive node: import finding its way into an edge bundle. check:edge covers it automatically.

Upstash is the right first target because it speaks Redis over HTTP: Vercel Edge, Workers and Deno have no node:net, so an ordinary Redis client cannot open a socket in exactly the serverless deployments that need shared counters most.

Two details worth reviewing:

  • Fixed window puts the window id in the key (wd:rl:f:<key>:<windowStart>), so expiry is the only cleanup needed and two processes cannot disagree about which window they are in. EXPIRE … NX, or a long window gets extended into a sliding one by later requests.
  • Sliding window gives each request a random member suffix (<now>-<rand>). Two requests in the same millisecond would otherwise be one ZADD that overwrites rather than two that count — undercounting exactly when the limit matters.

Fails open by default when Redis is unreachable, because a rate limiter that takes the site down when its datastore has a bad minute has done more damage than the traffic it was shaping. onError: 'closed' for limits protecting something more expensive than availability. Either way the outcome lands in decision.results rather than looking like a normal evaluation.

Verification

14 new tests, 341 total. The one that matters: two WebDecoy instances pointed at one store enforce max: 2 as two, where before each had its own Map and it was four. Also asserts exactly one consume() per request — a second consume in evaluate() after prepare() would double-count and halve every limit. Build, lint and check:edge green.

@cport1
cport1 changed the base branch from feat/typed-decision to mainAugust 22, 2026 02:14
RateLimitRule hard-constructed an InMemoryRateLimiter -- a Map, with no
seam to replace it. On any deployment with more than one process the limit
was effectively max x instances, and on Vercel or Lambda it also reset on
every cold start. rateLimit({ max: 100, window: 60 }) across eight
replicas is an 800/min limit that an autoscaler can raise for you.
That was inconsistent with the rest of the SDK: the detection stores and
the captcha's challenge and token stores were already behind swappable
interfaces. The one piece of state that actually has to be shared was the
only one that could not be.
evaluate() is synchronous and making it async would turn every rule
evaluation into a promise for the sake of the one rule that needs it, so a
store declares which it is. A sync store is consumed inline -- the default
in-memory path, unchanged. An async store is consumed in a new optional
Rule.prepare(), which protect() awaits, the same pre-fetch already used
for IP enrichment and Web Bot Auth verdicts.
A rule whose async store was never prepared reports NOT_RUN rather than
allowing. A rate limiter that has quietly stopped limiting is
indistinguishable from one that works, which is the worst of the options.
The Upstash store lives in core and uses fetch, not @upstash/redis: two
commands in a pipeline is not worth a dependency, a version to track, or a
transitive node: import finding its way into an edge bundle. Fixed windows
put the window id in the key so expiry is the only cleanup and two
processes cannot disagree about which window they are in; sliding windows
give each request a random member suffix, because two requests in the same
millisecond would otherwise be one ZADD that overwrites rather than two
that count.
ClosesWebDecoy/app#726
@cport1
cport1force-pushed the feat/rate-limit-store branch 2 times, most recently from 014e46a to fba9c9aCompareAugust 22, 2026 02:14
@cport1
cport1 merged commit fd75118 into mainAug 22, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@cport1