forked from bai/curriculum-project-hub
chore: release v0.0.28
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
||||||
|
*
|
||||||
|
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
||||||
|
* Static SPA assets and the org-admin HTML shell are exempt so a single page
|
||||||
|
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Paths that must not consume the silo HTTP request-rate budget. */
|
||||||
|
export function isSiloHttpRateLimitExempt(url: string): boolean {
|
||||||
|
const path = (url.split("?", 1)[0] ?? url) || "/";
|
||||||
|
|
||||||
|
if (path === "/api/healthz") return true;
|
||||||
|
|
||||||
|
// SvelteKit build output and top-level static files (see admin/static.ts).
|
||||||
|
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
||||||
|
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
||||||
|
|
||||||
|
// SPA index shell for client-side routes (not an API).
|
||||||
|
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export class SiloFixedWindowRateLimiter {
|
export class SiloFixedWindowRateLimiter {
|
||||||
private windowStartedAt: number;
|
private windowStartedAt: number;
|
||||||
private used = 0;
|
private used = 0;
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl
|
|||||||
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
|
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
|
||||||
import { readServerBinding } from "./settings/server.js";
|
import { readServerBinding } from "./settings/server.js";
|
||||||
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
|
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
|
||||||
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
@@ -42,7 +42,9 @@ export async function startHub(): Promise<void> {
|
|||||||
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
|
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
|
||||||
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
|
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
|
||||||
app.addHook("onRequest", async (request, reply) => {
|
app.addHook("onRequest", async (request, reply) => {
|
||||||
if (request.url === "/api/healthz") return;
|
// Static SPA assets / admin HTML shell / healthz do not count toward the
|
||||||
|
// silo requestRate budget (a full admin load can pull dozens of chunks).
|
||||||
|
if (isSiloHttpRateLimitExempt(request.url)) return;
|
||||||
const decision = requestLimiter.consume();
|
const decision = requestLimiter.consume();
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
await reply
|
await reply
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js";
|
import {
|
||||||
|
isSiloHttpRateLimitExempt,
|
||||||
|
SiloFixedWindowRateLimiter,
|
||||||
|
} from "../../src/deployment/siloRateLimit.js";
|
||||||
|
|
||||||
describe("Silo fixed-window rate limiter", () => {
|
describe("Silo fixed-window rate limiter", () => {
|
||||||
it("returns an explicit retry interval and resets at the next window", () => {
|
it("returns an explicit retry interval and resets at the next window", () => {
|
||||||
@@ -10,3 +13,24 @@ describe("Silo fixed-window rate limiter", () => {
|
|||||||
expect(limiter.consume(61_000)).toEqual({ allowed: true });
|
expect(limiter.consume(61_000)).toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isSiloHttpRateLimitExempt", () => {
|
||||||
|
it("exempts healthz, SPA static assets, and the admin HTML shell", () => {
|
||||||
|
expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/admin")).toBe(true);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rate-limits APIs and auth", () => {
|
||||||
|
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false);
|
||||||
|
expect(isSiloHttpRateLimitExempt("/")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user