@open-mercato/shared 0.6.8-develop.6963.1.04a6d320e9 → 0.6.8-develop.6964.1.36b364cfd8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,11 +3,19 @@ import { z } from "zod";
3
3
  const RATE_LIMIT_ERROR_KEY = "api.errors.rateLimit";
4
4
  const RATE_LIMIT_ERROR_FALLBACK = "Too many requests. Please try again later.";
5
5
  const RATE_LIMIT_FALLBACK_KEY = "global";
6
+ const RATE_LIMIT_UNAVAILABLE_KEY = "api.errors.rateLimitUnavailable";
7
+ const RATE_LIMIT_UNAVAILABLE_FALLBACK = "Service temporarily unavailable. Please try again later.";
6
8
  const rateLimitErrorSchema = z.object({
7
9
  error: z.string().describe("Rate limit exceeded message")
8
10
  });
9
- async function checkRateLimit(rateLimiterService, config, key, errorMessage) {
11
+ async function checkRateLimit(rateLimiterService, config, key, errorMessage, options = {}) {
10
12
  const result = await rateLimiterService.consume(key, config);
13
+ if (result.degraded && options.failClosed) {
14
+ return NextResponse.json(
15
+ { error: options.unavailableMessage ?? RATE_LIMIT_UNAVAILABLE_FALLBACK },
16
+ { status: 503 }
17
+ );
18
+ }
11
19
  if (!result.allowed) {
12
20
  const retryAfterSec = Math.ceil(result.msBeforeNext / 1e3);
13
21
  return NextResponse.json(
@@ -41,6 +49,8 @@ export {
41
49
  RATE_LIMIT_ERROR_FALLBACK,
42
50
  RATE_LIMIT_ERROR_KEY,
43
51
  RATE_LIMIT_FALLBACK_KEY,
52
+ RATE_LIMIT_UNAVAILABLE_FALLBACK,
53
+ RATE_LIMIT_UNAVAILABLE_KEY,
44
54
  checkRateLimit,
45
55
  getClientIp,
46
56
  rateLimitErrorSchema
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/helpers.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\nexport const RATE_LIMIT_FALLBACK_KEY = 'global'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP;\n * X-Real-IP is accepted only as a single-proxy fallback when X-Forwarded-For is absent.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP. If the\n * forwarded chain is shorter than N, return null rather than trusting an\n * attacker-controlled entry.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] || null : null\n }\n return trustProxyDepth === 1 ? req.headers.get('x-real-ip')?.trim() || null : null\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAClC,MAAM,0BAA0B;AAEhC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,CAAC,OAAO,UAAU,eAAe,KAAK,mBAAmB,GAAG;AAC9D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,KAAK,OAAO;AAAA,EACvD;AACA,SAAO,oBAAoB,IAAI,IAAI,QAAQ,IAAI,WAAW,GAAG,KAAK,KAAK,OAAO;AAChF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\nexport const RATE_LIMIT_FALLBACK_KEY = 'global'\nexport const RATE_LIMIT_UNAVAILABLE_KEY = 'api.errors.rateLimitUnavailable'\nexport const RATE_LIMIT_UNAVAILABLE_FALLBACK = 'Service temporarily unavailable. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\nexport type CheckRateLimitOptions = {\n /**\n * Reject with 503 when the limiter could not reach its backing store, instead of\n * letting the request through uncounted. Use on state-mutating endpoints, where an\n * unenforced limit is worse than a rejected request.\n */\n failClosed?: boolean\n /** Body message for the 503 emitted by `failClosed`. */\n unavailableMessage?: string\n}\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n *\n * With `options.failClosed`, a degraded limiter (backing store unreachable) yields a 503\n * instead of an allowed request. Without it the call behaves exactly as before and fails open.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n options: CheckRateLimitOptions = {},\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (result.degraded && options.failClosed) {\n return NextResponse.json(\n { error: options.unavailableMessage ?? RATE_LIMIT_UNAVAILABLE_FALLBACK },\n { status: 503 },\n )\n }\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP;\n * X-Real-IP is accepted only as a single-proxy fallback when X-Forwarded-For is absent.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP. If the\n * forwarded chain is shorter than N, return null rather than trusting an\n * attacker-controlled entry.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] || null : null\n }\n return trustProxyDepth === 1 ? req.headers.get('x-real-ip')?.trim() || null : null\n}\n"],
5
+ "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAClC,MAAM,0BAA0B;AAChC,MAAM,6BAA6B;AACnC,MAAM,kCAAkC;AAExC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAoBD,eAAsB,eACpB,oBACA,QACA,KACA,cACA,UAAiC,CAAC,GACJ;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,OAAO,YAAY,QAAQ,YAAY;AACzC,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,QAAQ,sBAAsB,gCAAgC;AAAA,MACvE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,CAAC,OAAO,UAAU,eAAe,KAAK,mBAAmB,GAAG;AAC9D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,KAAK,OAAO;AAAA,EACvD;AACA,SAAO,oBAAoB,IAAI,IAAI,QAAQ,IAAI,WAAW,GAAG,KAAK,KAAK,OAAO;AAChF;",
6
6
  "names": []
7
7
  }
@@ -1,10 +1,12 @@
1
1
  import { RateLimiterService } from "./service.js";
2
2
  import { readRateLimitConfig, readEndpointRateLimitConfig } from "./config.js";
3
- import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from "./helpers.js";
3
+ import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, RATE_LIMIT_UNAVAILABLE_KEY, RATE_LIMIT_UNAVAILABLE_FALLBACK, rateLimitErrorSchema } from "./helpers.js";
4
4
  export {
5
5
  RATE_LIMIT_ERROR_FALLBACK,
6
6
  RATE_LIMIT_ERROR_KEY,
7
7
  RATE_LIMIT_FALLBACK_KEY,
8
+ RATE_LIMIT_UNAVAILABLE_FALLBACK,
9
+ RATE_LIMIT_UNAVAILABLE_KEY,
8
10
  RateLimiterService,
9
11
  checkRateLimit,
10
12
  getClientIp,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/index.ts"],
4
- "sourcesContent": ["export { RateLimiterService } from './service'\nexport { readRateLimitConfig, readEndpointRateLimitConfig } from './config'\nexport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from './helpers'\nexport type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'\n"],
5
- "mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,qBAAqB,mCAAmC;AACjE,SAAS,gBAAgB,aAAa,sBAAsB,2BAA2B,yBAAyB,4BAA4B;",
4
+ "sourcesContent": ["export { RateLimiterService } from './service'\nexport { readRateLimitConfig, readEndpointRateLimitConfig } from './config'\nexport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, RATE_LIMIT_UNAVAILABLE_KEY, RATE_LIMIT_UNAVAILABLE_FALLBACK, rateLimitErrorSchema } from './helpers'\nexport type { CheckRateLimitOptions } from './helpers'\nexport type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'\n"],
5
+ "mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,qBAAqB,mCAAmC;AACjE,SAAS,gBAAgB,aAAa,sBAAsB,2BAA2B,yBAAyB,4BAA4B,iCAAiC,4BAA4B;",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,7 @@
1
1
  import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
2
+ import { createLogger } from "../logger/index.js";
2
3
  import { parseRedisUrl } from "../redis/connection.js";
4
+ const logger = createLogger("ratelimit");
3
5
  class RateLimiterService {
4
6
  constructor(globalConfig) {
5
7
  this.limiters = /* @__PURE__ */ new Map();
@@ -29,7 +31,12 @@ class RateLimiterService {
29
31
  if (error instanceof RateLimiterRes) {
30
32
  return this.toResult(error, false);
31
33
  }
32
- return this.disabledResult(config);
34
+ logger.error("Rate limiter unavailable, request was not counted", {
35
+ err: error,
36
+ keyPrefix: config.keyPrefix,
37
+ strategy: this.globalConfig.strategy
38
+ });
39
+ return this.degradedResult(config);
33
40
  }
34
41
  }
35
42
  async get(key, config) {
@@ -73,6 +80,9 @@ class RateLimiterService {
73
80
  disabledResult(config) {
74
81
  return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 };
75
82
  }
83
+ degradedResult(config) {
84
+ return { ...this.disabledResult(config), degraded: true };
85
+ }
76
86
  getOrCreateLimiter(config) {
77
87
  const cacheKey = `${config.keyPrefix ?? "default"}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`;
78
88
  let limiter = this.limiters.get(cacheKey);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/ratelimit/service.ts"],
4
- "sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport { parseRedisUrl } from '../redis/connection'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis({\n ...parseRedisUrl(this.globalConfig.redisUrl),\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n return this.disabledResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport { createLogger } from '../logger'\nimport { parseRedisUrl } from '../redis/connection'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\nconst logger = createLogger('ratelimit')\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis({\n ...parseRedisUrl(this.globalConfig.redisUrl),\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n logger.error('Rate limiter unavailable, request was not counted', {\n err: error,\n keyPrefix: config.keyPrefix,\n strategy: this.globalConfig.strategy,\n })\n return this.degradedResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private degradedResult(config: RateLimitConfig): RateLimitResult {\n return { ...this.disabledResult(config), degraded: true }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.8-develop.6963.1.04a6d320e9";
1
+ const APP_VERSION = "0.6.8-develop.6964.1.36b364cfd8";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6963.1.04a6d320e9';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6964.1.36b364cfd8';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.8-develop.6963.1.04a6d320e9",
3
+ "version": "0.6.8-develop.6964.1.36b364cfd8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -105,7 +105,7 @@
105
105
  "@mikro-orm/core": "^7.1.8",
106
106
  "@mikro-orm/decorators": "^7.1.8",
107
107
  "@mikro-orm/postgresql": "^7.1.8",
108
- "@open-mercato/cache": "0.6.8-develop.6963.1.04a6d320e9",
108
+ "@open-mercato/cache": "0.6.8-develop.6964.1.36b364cfd8",
109
109
  "@types/sanitize-html": "^2.16.1",
110
110
  "dotenv": "^17.4.2",
111
111
  "pino": "^10.3.1",
@@ -1,5 +1,11 @@
1
1
  import { RateLimiterService } from '../service'
2
- import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK } from '../helpers'
2
+ import {
3
+ checkRateLimit,
4
+ getClientIp,
5
+ RATE_LIMIT_ERROR_KEY,
6
+ RATE_LIMIT_ERROR_FALLBACK,
7
+ RATE_LIMIT_UNAVAILABLE_FALLBACK,
8
+ } from '../helpers'
3
9
  import { readEndpointRateLimitConfig } from '../config'
4
10
  import type { RateLimitConfig, RateLimitGlobalConfig } from '../types'
5
11
 
@@ -84,6 +90,45 @@ describe('checkRateLimit', () => {
84
90
  expect(result).toBeNull()
85
91
  }
86
92
  })
93
+
94
+ describe('degraded limiter', () => {
95
+ function createDegradedService(): RateLimiterService {
96
+ const degraded = createService()
97
+ jest.spyOn(degraded, 'consume').mockResolvedValue({
98
+ allowed: true,
99
+ remainingPoints: limitConfig.points,
100
+ msBeforeNext: 0,
101
+ consumedPoints: 0,
102
+ degraded: true,
103
+ })
104
+ return degraded
105
+ }
106
+
107
+ it('returns 503 when failClosed is set', async () => {
108
+ service = createDegradedService()
109
+ const response = await checkRateLimit(service, limitConfig, 'ip7', 'Rate limited', {
110
+ failClosed: true,
111
+ })
112
+ expect(response).not.toBeNull()
113
+ expect(response!.status).toBe(503)
114
+ expect(await response!.json()).toEqual({ error: RATE_LIMIT_UNAVAILABLE_FALLBACK })
115
+ })
116
+
117
+ it('uses the supplied unavailableMessage in the 503 body', async () => {
118
+ service = createDegradedService()
119
+ const response = await checkRateLimit(service, limitConfig, 'ip8', 'Rate limited', {
120
+ failClosed: true,
121
+ unavailableMessage: 'Payments are briefly unavailable.',
122
+ })
123
+ expect(await response!.json()).toEqual({ error: 'Payments are briefly unavailable.' })
124
+ })
125
+
126
+ it('keeps failing open when failClosed is not set', async () => {
127
+ service = createDegradedService()
128
+ const response = await checkRateLimit(service, limitConfig, 'ip9', 'Rate limited')
129
+ expect(response).toBeNull()
130
+ })
131
+ })
87
132
  })
88
133
 
89
134
  describe('getClientIp', () => {
@@ -1,6 +1,17 @@
1
+ import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'
2
+ import {
3
+ registerLoggerExtension,
4
+ resetLoggerExtension,
5
+ type LoggerExtensionRecord,
6
+ } from '../../logger'
1
7
  import { RateLimiterService } from '../service'
2
8
  import type { RateLimitConfig, RateLimitGlobalConfig } from '../types'
3
9
 
10
+ jest.mock('ioredis', () => ({
11
+ __esModule: true,
12
+ default: jest.fn().mockImplementation(() => ({ disconnect: jest.fn() })),
13
+ }))
14
+
4
15
  function createConfig(overrides: Partial<RateLimitGlobalConfig> = {}): RateLimitGlobalConfig {
5
16
  return {
6
17
  enabled: true,
@@ -148,6 +159,108 @@ describe('RateLimiterService', () => {
148
159
  })
149
160
  })
150
161
 
162
+ describe('degraded limiter', () => {
163
+ const logRecords: LoggerExtensionRecord[] = []
164
+
165
+ beforeEach(() => {
166
+ logRecords.length = 0
167
+ registerLoggerExtension({ emit: (record) => logRecords.push(record) })
168
+ jest.spyOn(console, 'error').mockImplementation(() => {})
169
+ })
170
+
171
+ afterEach(() => {
172
+ resetLoggerExtension()
173
+ jest.restoreAllMocks()
174
+ })
175
+
176
+ it('flags the result as degraded when the backing store fails', async () => {
177
+ jest.spyOn(RateLimiterMemory.prototype, 'consume').mockRejectedValue(new Error('store unreachable'))
178
+ service = new RateLimiterService(createConfig())
179
+
180
+ const result = await service.consume('key1', defaultLimitConfig)
181
+ expect(result.degraded).toBe(true)
182
+ expect(result.allowed).toBe(true)
183
+ expect(logRecords).toEqual([
184
+ expect.objectContaining({
185
+ level: 'error',
186
+ namespace: 'ratelimit',
187
+ message: 'Rate limiter unavailable, request was not counted',
188
+ }),
189
+ ])
190
+ })
191
+
192
+ it('does not flag a real rejection as degraded', async () => {
193
+ service = new RateLimiterService(createConfig())
194
+ for (let i = 0; i < defaultLimitConfig.points; i++) {
195
+ await service.consume('key2', defaultLimitConfig)
196
+ }
197
+
198
+ const result = await service.consume('key2', defaultLimitConfig)
199
+ expect(result.allowed).toBe(false)
200
+ expect(result.degraded).toBeFalsy()
201
+ })
202
+
203
+ it('does not flag a config-disabled result as degraded', async () => {
204
+ service = new RateLimiterService(createConfig({ enabled: false }))
205
+
206
+ const result = await service.consume('key3', defaultLimitConfig)
207
+ expect(result.allowed).toBe(true)
208
+ expect(result.degraded).toBeFalsy()
209
+ })
210
+
211
+ // Which failure modes actually reach `degraded` under the redis strategy: the store
212
+ // abstraction absorbs a plain outage through the in-memory insurance limiter, so only
213
+ // an error it re-throws gets there.
214
+ describe('redis strategy', () => {
215
+ async function createRedisService(): Promise<RateLimiterService> {
216
+ const redisService = new RateLimiterService(createConfig({
217
+ strategy: 'redis',
218
+ redisUrl: 'redis://localhost:6379',
219
+ }))
220
+ await redisService.initialize()
221
+ return redisService
222
+ }
223
+
224
+ function buildLimiter(redisService: RateLimiterService): unknown {
225
+ const internals = redisService as unknown as {
226
+ getOrCreateLimiter: (config: RateLimitConfig) => unknown
227
+ }
228
+ return internals.getOrCreateLimiter(defaultLimitConfig)
229
+ }
230
+
231
+ it('flags an error the redis limiter re-throws as degraded', async () => {
232
+ service = await createRedisService()
233
+ expect(buildLimiter(service)).toBeInstanceOf(RateLimiterRedis)
234
+ jest.spyOn(RateLimiterRedis.prototype, 'consume').mockRejectedValue(new Error('redis down'))
235
+
236
+ const result = await service.consume('redis-key', defaultLimitConfig)
237
+
238
+ expect(result.degraded).toBe(true)
239
+ expect(result.allowed).toBe(true)
240
+ expect(logRecords).toEqual([
241
+ expect.objectContaining({
242
+ level: 'error',
243
+ namespace: 'ratelimit',
244
+ message: 'Rate limiter unavailable, request was not counted',
245
+ }),
246
+ ])
247
+ })
248
+
249
+ it('does not flag a resolved redis decision as degraded, including one the insurance limiter produced', async () => {
250
+ service = await createRedisService()
251
+ expect(buildLimiter(service)).toBeInstanceOf(RateLimiterRedis)
252
+ jest.spyOn(RateLimiterRedis.prototype, 'consume')
253
+ .mockResolvedValue(new RateLimiterRes(2, 0, 1, undefined))
254
+
255
+ const result = await service.consume('redis-key', defaultLimitConfig)
256
+
257
+ expect(result.allowed).toBe(true)
258
+ expect(result.degraded).toBeFalsy()
259
+ expect(logRecords).toEqual([])
260
+ })
261
+ })
262
+ })
263
+
151
264
  describe('limiter caching', () => {
152
265
  it('reuses the same limiter for the same config', async () => {
153
266
  service = new RateLimiterService(createConfig())
@@ -6,23 +6,47 @@ import type { RateLimiterService } from './service'
6
6
  export const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'
7
7
  export const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'
8
8
  export const RATE_LIMIT_FALLBACK_KEY = 'global'
9
+ export const RATE_LIMIT_UNAVAILABLE_KEY = 'api.errors.rateLimitUnavailable'
10
+ export const RATE_LIMIT_UNAVAILABLE_FALLBACK = 'Service temporarily unavailable. Please try again later.'
9
11
 
10
12
  export const rateLimitErrorSchema = z.object({
11
13
  error: z.string().describe('Rate limit exceeded message'),
12
14
  })
13
15
 
16
+ export type CheckRateLimitOptions = {
17
+ /**
18
+ * Reject with 503 when the limiter could not reach its backing store, instead of
19
+ * letting the request through uncounted. Use on state-mutating endpoints, where an
20
+ * unenforced limit is worse than a rejected request.
21
+ */
22
+ failClosed?: boolean
23
+ /** Body message for the 503 emitted by `failClosed`. */
24
+ unavailableMessage?: string
25
+ }
26
+
14
27
  /**
15
28
  * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.
16
29
  * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.
30
+ *
31
+ * With `options.failClosed`, a degraded limiter (backing store unreachable) yields a 503
32
+ * instead of an allowed request. Without it the call behaves exactly as before and fails open.
17
33
  */
18
34
  export async function checkRateLimit(
19
35
  rateLimiterService: RateLimiterService,
20
36
  config: RateLimitConfig,
21
37
  key: string,
22
38
  errorMessage: string,
39
+ options: CheckRateLimitOptions = {},
23
40
  ): Promise<NextResponse | null> {
24
41
  const result = await rateLimiterService.consume(key, config)
25
42
 
43
+ if (result.degraded && options.failClosed) {
44
+ return NextResponse.json(
45
+ { error: options.unavailableMessage ?? RATE_LIMIT_UNAVAILABLE_FALLBACK },
46
+ { status: 503 },
47
+ )
48
+ }
49
+
26
50
  if (!result.allowed) {
27
51
  const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)
28
52
  return NextResponse.json(
@@ -1,4 +1,5 @@
1
1
  export { RateLimiterService } from './service'
2
2
  export { readRateLimitConfig, readEndpointRateLimitConfig } from './config'
3
- export { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from './helpers'
3
+ export { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, RATE_LIMIT_UNAVAILABLE_KEY, RATE_LIMIT_UNAVAILABLE_FALLBACK, rateLimitErrorSchema } from './helpers'
4
+ export type { CheckRateLimitOptions } from './helpers'
4
5
  export type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'
@@ -1,7 +1,10 @@
1
1
  import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'
2
+ import { createLogger } from '../logger'
2
3
  import { parseRedisUrl } from '../redis/connection'
3
4
  import type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'
4
5
 
6
+ const logger = createLogger('ratelimit')
7
+
5
8
  /** Narrow interface for the ioredis client — only the methods we actually use. */
6
9
  interface RedisClient {
7
10
  disconnect(): void
@@ -44,7 +47,12 @@ export class RateLimiterService {
44
47
  if (error instanceof RateLimiterRes) {
45
48
  return this.toResult(error, false)
46
49
  }
47
- return this.disabledResult(config)
50
+ logger.error('Rate limiter unavailable, request was not counted', {
51
+ err: error,
52
+ keyPrefix: config.keyPrefix,
53
+ strategy: this.globalConfig.strategy,
54
+ })
55
+ return this.degradedResult(config)
48
56
  }
49
57
  }
50
58
 
@@ -97,6 +105,10 @@ export class RateLimiterService {
97
105
  return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }
98
106
  }
99
107
 
108
+ private degradedResult(config: RateLimitConfig): RateLimitResult {
109
+ return { ...this.disabledResult(config), degraded: true }
110
+ }
111
+
100
112
  private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {
101
113
  const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`
102
114
 
@@ -18,6 +18,20 @@ export interface RateLimitResult {
18
18
  msBeforeNext: number
19
19
  /** Total points consumed in the current window */
20
20
  consumedPoints: number
21
+ /**
22
+ * True when the limiter could not produce a real decision, so `allowed` is a
23
+ * fallback. Callers guarding state-mutating endpoints SHOULD fail closed on this
24
+ * instead of letting the request through. Absent (falsy) when rate limiting is
25
+ * switched off by configuration.
26
+ *
27
+ * Set for every error that escapes the limiter itself: building the limiter fails,
28
+ * or `consume()` rejects with anything other than a `RateLimiterRes` quota
29
+ * rejection. With the `redis` strategy an unreachable store is normally absorbed
30
+ * one level lower by the in-memory insurance limiter — requests stay counted
31
+ * per process and the result is a real decision, so `degraded` stays falsy; it is
32
+ * set when that fallback layer fails too.
33
+ */
34
+ degraded?: boolean
21
35
  }
22
36
 
23
37
  export type RateLimitStrategy = 'memory' | 'redis'