diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js
index 452045bc3ecb155d6ba6ee65f5501385fd5c122a..7ee9953478b4e60df3fcdb60075fde5a8e48d370 100644
--- a/packages/backend/eslint.config.js
+++ b/packages/backend/eslint.config.js
@@ -42,6 +42,13 @@ export default [
 				name: '__filename',
 				message: 'Not in ESModule. Use `import.meta.url` instead.',
 			}],
+			// https://typescript-eslint.io/rules/prefer-nullish-coalescing/
+			'@typescript-eslint/prefer-nullish-coalescing': ['warn', {
+				ignorePrimitives: {
+					// Without this, the rule breaks for nullable booleans
+					boolean: true,
+				},
+			}],
 		},
 	},
 	{
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index c083068392a98ae76ec0bdc0b096b1c56963ffbd..b18db7f366b3b30b48248e028da2749dc384b32c 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -14,6 +14,8 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe
 import { SystemWebhookService } from '@/core/SystemWebhookService.js';
 import { UserSearchService } from '@/core/UserSearchService.js';
 import { WebhookTestService } from '@/core/WebhookTestService.js';
+import { TimeService } from '@/core/TimeService.js';
+import { EnvService } from '@/core/EnvService.js';
 import { AccountMoveService } from './AccountMoveService.js';
 import { AccountUpdateService } from './AccountUpdateService.js';
 import { AnnouncementService } from './AnnouncementService.js';
@@ -381,6 +383,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
 		ChannelFollowingService,
 		RegistryApiService,
 		ReversiService,
+		TimeService,
+		EnvService,
 
 		ChartLoggerService,
 		FederationChart,
@@ -680,6 +684,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
 		ChannelFollowingService,
 		RegistryApiService,
 		ReversiService,
+		TimeService,
+		EnvService,
 
 		FederationChart,
 		NotesChart,
diff --git a/packages/backend/src/core/EnvService.ts b/packages/backend/src/core/EnvService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8cc3b95735fa92ef4520c65ab8c7ed5074ca7764
--- /dev/null
+++ b/packages/backend/src/core/EnvService.ts
@@ -0,0 +1,20 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+
+/**
+ * Provides access to the process environment variables.
+ * This exists for testing purposes, so that a test can mock the environment without corrupting state for other tests.
+ */
+@Injectable()
+export class EnvService {
+	/**
+	 * Passthrough to process.env
+	 */
+	public get env() {
+		return process.env;
+	}
+}
diff --git a/packages/backend/src/core/TimeService.ts b/packages/backend/src/core/TimeService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..59c3d4c12b83d8222daed9044ad235070fcca65a
--- /dev/null
+++ b/packages/backend/src/core/TimeService.ts
@@ -0,0 +1,27 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+
+/**
+ * Provides abstractions to access the current time.
+ * Exists for unit testing purposes, so that tests can "simulate" any given time for consistency.
+ */
+@Injectable()
+export class TimeService {
+	/**
+	 * Returns Date.now()
+	 */
+	public get now() {
+		return Date.now();
+	}
+
+	/**
+	 * Returns a new Date instance.
+	 */
+	public get date() {
+		return new Date();
+	}
+}
diff --git a/packages/backend/src/misc/rate-limit-utils.ts b/packages/backend/src/misc/rate-limit-utils.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9909bb97fa18325ee041702d37b4fb366dd7d29d
--- /dev/null
+++ b/packages/backend/src/misc/rate-limit-utils.ts
@@ -0,0 +1,146 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { FastifyReply } from 'fastify';
+
+export type RateLimit = BucketRateLimit | LegacyRateLimit;
+export type Keyed<T> = T & { key: string };
+
+/**
+ * Rate limit based on "leaky bucket" logic.
+ * The bucket count increases with each call, and decreases gradually at a given rate.
+ * The subject is blocked until the bucket count drops below the limit.
+ */
+export interface BucketRateLimit {
+	/**
+	 * Unique key identifying the particular resource (or resource group) being limited.
+	 */
+	key?: string;
+
+	/**
+	 * Constant value identifying the type of rate limit.
+	 */
+	type: 'bucket';
+
+	/**
+	 * Size of the bucket, in number of requests.
+	 * The subject will be blocked when the number of calls exceeds this size.
+	 */
+	size: number;
+
+	/**
+	 * How often the bucket should "drip" and reduce the counter, measured in milliseconds.
+	 * Defaults to 1000 (1 second).
+	 */
+	dripRate?: number;
+
+	/**
+	 * Amount to reduce the counter on each drip.
+	 * Defaults to 1.
+	 */
+	dripSize?: number;
+}
+
+/**
+ * Legacy rate limit based on a "request window" with a maximum number of requests within a given time box.
+ * These will be translated into a bucket with linear drip rate.
+ */
+export interface LegacyRateLimit {
+	/**
+	 * Unique key identifying the particular resource (or resource group) being limited.
+	 */
+	key?: string;
+
+	/**
+	 * Constant value identifying the type of rate limit.
+	 * Must be excluded or explicitly set to undefined
+	 */
+	type?: undefined;
+
+	/**
+	 * Duration of the request window, in milliseconds.
+	 * If present, then "max" must also be included.
+	 */
+	duration?: number;
+
+	/**
+	 * Maximum number of requests allowed in the request window.
+	 * If present, then "duration" must also be included.
+	 */
+	max?: number;
+
+	/**
+	 * Optional minimum interval between consecutive requests.
+	 * Will apply in addition to the primary rate limit.
+	 */
+	minInterval?: number;
+}
+
+/**
+ * Metadata about the current status of a rate limiter
+ */
+export interface LimitInfo {
+	/**
+	 * True if the limit has been reached, and the call should be blocked.
+	 */
+	blocked: boolean;
+
+	/**
+	 * Number of calls that can be made before the limit is triggered.
+	 */
+	remaining: number;
+
+	/**
+	 * Time in seconds until the next call can be made, or zero if the next call can be made immediately.
+	 * Rounded up to the nearest second.
+	 */
+	resetSec: number;
+
+	/**
+	 * Time in milliseconds until the next call can be made, or zero if the next call can be made immediately.
+	 * Rounded up to the nearest milliseconds.
+	 */
+	resetMs: number;
+
+	/**
+	 * Time in seconds until the limit has fully reset.
+	 * Rounded up to the nearest second.
+	 */
+	fullResetSec: number;
+
+	/**
+	 * Time in milliseconds until the limit has fully reset.
+	 * Rounded up to the nearest millisecond.
+	 */
+	fullResetMs: number;
+}
+
+export function isLegacyRateLimit(limit: RateLimit): limit is LegacyRateLimit {
+	return limit.type === undefined;
+}
+
+export function hasMinLimit(limit: LegacyRateLimit): limit is LegacyRateLimit & { minInterval: number } {
+	return !!limit.minInterval;
+}
+
+export function sendRateLimitHeaders(reply: FastifyReply, info: LimitInfo): void {
+	// Number of seconds until the limit has fully reset.
+	const clear = (info.fullResetMs / 1000).toFixed(3);
+	reply.header('X-RateLimit-Clear', clear);
+
+	// Number of calls that can be made before being limited.
+	const remaining = info.remaining.toString();
+	reply.header('X-RateLimit-Remaining', remaining);
+
+	if (info.blocked) {
+		// Number of seconds to wait before trying again. Left for backwards compatibility.
+		const retry = info.resetSec.toString();
+		reply.header('Retry-After', retry);
+
+		// Number of milliseconds to wait before trying again.
+		const reset = (info.resetMs / 1000).toFixed(3);
+		reply.header('X-RateLimit-Reset', reset);
+	}
+}
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index 18d313db06992026480fa7b6ef981a6fdae7e6b8..5293d529addf83759cc52d484603f085f6f0b0ba 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -28,12 +28,12 @@ import { bindThis } from '@/decorators.js';
 import { isMimeImage } from '@/misc/is-mime-image.js';
 import { correctFilename } from '@/misc/correct-filename.js';
 import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
-import { RateLimiterService } from '@/server/api/RateLimiterService.js';
 import { getIpHash } from '@/misc/get-ip-hash.js';
 import { AuthenticateService } from '@/server/api/AuthenticateService.js';
-import type { IEndpointMeta } from '@/server/api/endpoints.js';
+import { RoleService } from '@/core/RoleService.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
 import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
-import type Limiter from 'ratelimiter';
 
 const _filename = fileURLToPath(import.meta.url);
 const _dirname = dirname(_filename);
@@ -58,7 +58,8 @@ export class FileServerService {
 		private internalStorageService: InternalStorageService,
 		private loggerService: LoggerService,
 		private authenticateService: AuthenticateService,
-		private rateLimiterService: RateLimiterService,
+		private rateLimiterService: SkRateLimiterService,
+		private roleService: RoleService,
 	) {
 		this.logger = this.loggerService.getLogger('server', 'gray');
 
@@ -625,48 +626,44 @@ export class FileServerService {
 		// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
 		const [user] = await this.authenticateService.authenticate(token);
 		const actor = user?.id ?? getIpHash(request.ip);
+		const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
 
 		// Call both limits: the per-resource limit and the shared cross-resource limit
-		return await this.checkResourceLimit(reply, actor, group, resource) && await this.checkSharedLimit(reply, actor, group);
+		return await this.checkResourceLimit(reply, actor, group, resource, factor) && await this.checkSharedLimit(reply, actor, group, factor);
 	}
 
-	private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string): Promise<boolean> {
-		const limit = {
+	private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string, factor = 1): Promise<boolean> {
+		const limit: Keyed<RateLimit> = {
 			// Group by resource
 			key: `${group}${resource}`,
+			type: 'bucket',
 
-			// Maximum of 10 requests / 10 minutes
-			max: 10,
-			duration: 1000 * 60 * 10,
+			// Maximum of 10 requests, average rate of 1 per minute
+			size: 10,
+			dripRate: 1000 * 60,
 		};
 
-		return await this.checkLimit(reply, actor, limit);
+		return await this.checkLimit(reply, actor, limit, factor);
 	}
 
-	private async checkSharedLimit(reply: FastifyReply, actor: string, group: string): Promise<boolean> {
-		const limit = {
+	private async checkSharedLimit(reply: FastifyReply, actor: string, group: string, factor = 1): Promise<boolean> {
+		const limit: Keyed<RateLimit> = {
 			key: group,
+			type: 'bucket',
 
-			// Maximum of 3600 requests per hour, which is an average of 1 per second.
-			max: 3600,
-			duration: 1000 * 60 * 60,
+			// Maximum of 3600 requests, average rate of 1 per second.
+			size: 3600,
 		};
 
-		return await this.checkLimit(reply, actor, limit);
+		return await this.checkLimit(reply, actor, limit, factor);
 	}
 
-	private async checkLimit(reply: FastifyReply, actor: string, limit: IEndpointMeta['limit'] & { key: NonNullable<string> }): Promise<boolean> {
-		try {
-			await this.rateLimiterService.limit(limit, actor);
-			return true;
-		} catch (err) {
-			// errはLimiter.LimiterInfoであることが期待される
-			if (hasRateLimitInfo(err)) {
-				const cooldownInSeconds = Math.ceil((err.info.resetMs - Date.now()) / 1000);
-				// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
-				reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
-			}
+	private async checkLimit(reply: FastifyReply, actor: string, limit: Keyed<RateLimit>, factor = 1): Promise<boolean> {
+		const info = await this.rateLimiterService.limit(limit, actor, factor);
+
+		sendRateLimitHeaders(reply, info);
 
+		if (info.blocked) {
 			reply.code(429);
 			reply.send({
 				message: 'Rate limit exceeded. Please try again later.',
@@ -676,9 +673,8 @@ export class FileServerService {
 
 			return false;
 		}
+
+		return true;
 	}
 }
 
-function hasRateLimitInfo(err: unknown): err is { info: Limiter.LimiterInfo } {
-	return err != null && typeof(err) === 'object' && 'info' in err;
-}
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index 216e6b4fb81d217f2d8440d745266508ad2e813f..c1d7c088f1e30c1185e80ba4121cc0035c51189d 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -6,6 +6,7 @@
 import { Module } from '@nestjs/common';
 import { EndpointsModule } from '@/server/api/EndpointsModule.js';
 import { CoreModule } from '@/core/CoreModule.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
 import { ApiCallService } from './api/ApiCallService.js';
 import { FileServerService } from './FileServerService.js';
 import { HealthServerService } from './HealthServerService.js';
@@ -73,6 +74,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
 		ApiLoggerService,
 		ApiServerService,
 		AuthenticateService,
+		SkRateLimiterService,
+		// No longer used, but kept for backwards compatibility
 		RateLimiterService,
 		SigninApiService,
 		SigninWithPasskeyApiService,
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 6f51825494e6728cbe36d5886c0e24375de630c9..c6c33f73032933fff46ae3b8fcdd7ada585d1a15 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -18,8 +18,9 @@ import { createTemp } from '@/misc/create-temp.js';
 import { bindThis } from '@/decorators.js';
 import { RoleService } from '@/core/RoleService.js';
 import type { Config } from '@/config.js';
+import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
 import { ApiError } from './error.js';
-import { RateLimiterService } from './RateLimiterService.js';
 import { ApiLoggerService } from './ApiLoggerService.js';
 import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
 import type { FastifyRequest, FastifyReply } from 'fastify';
@@ -49,7 +50,7 @@ export class ApiCallService implements OnApplicationShutdown {
 		private userIpsRepository: UserIpsRepository,
 
 		private authenticateService: AuthenticateService,
-		private rateLimiterService: RateLimiterService,
+		private rateLimiterService: SkRateLimiterService,
 		private roleService: RoleService,
 		private apiLoggerService: ApiLoggerService,
 	) {
@@ -65,16 +66,6 @@ export class ApiCallService implements OnApplicationShutdown {
 		let statusCode = err.httpStatusCode;
 		if (err.httpStatusCode === 401) {
 			reply.header('WWW-Authenticate', 'Bearer realm="Misskey"');
-		} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
-			const info: unknown = err.info;
-			const unixEpochInSeconds = Date.now();
-			if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') {
-				const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000);
-				// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
-				reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
-			} else {
-				this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`);
-			}
 		} else if (err.kind === 'client') {
 			reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
 			statusCode = statusCode ?? 400;
@@ -168,7 +159,7 @@ export class ApiCallService implements OnApplicationShutdown {
 			return;
 		}
 		this.authenticateService.authenticate(token).then(([user, app]) => {
-			this.call(endpoint, user, app, body, null, request).then((res) => {
+			this.call(endpoint, user, app, body, null, request, reply).then((res) => {
 				if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) {
 					reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
 				}
@@ -229,7 +220,7 @@ export class ApiCallService implements OnApplicationShutdown {
 			this.call(endpoint, user, app, fields, {
 				name: multipartData.filename,
 				path: path,
-			}, request).then((res) => {
+			}, request, reply).then((res) => {
 				this.send(reply, res);
 			}).catch((err: ApiError) => {
 				this.#sendApiError(reply, err);
@@ -304,6 +295,7 @@ export class ApiCallService implements OnApplicationShutdown {
 			path: string;
 		} | null,
 		request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
+		reply: FastifyReply,
 	) {
 		const isSecure = user != null && token == null;
 
@@ -312,7 +304,7 @@ export class ApiCallService implements OnApplicationShutdown {
 		}
 
 		// For endpoints without a limit, the default is 10 calls per second
-		const endpointLimit: IEndpointMeta['limit'] = ep.meta.limit ?? {
+		const endpointLimit = ep.meta.limit ?? {
 			duration: 1000,
 			max: 10,
 		};
@@ -328,30 +320,28 @@ export class ApiCallService implements OnApplicationShutdown {
 				limitActor = getIpHash(request.ip);
 			}
 
-			const limit = Object.assign({}, endpointLimit);
-
-			if (limit.key == null) {
-				(limit as any).key = ep.name;
-			}
-
 			// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
 			const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
 
 			if (factor > 0) {
+				const limit = {
+					key: ep.name,
+					...endpointLimit,
+				};
+
 				// Rate limit
-				await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
-					if ('info' in err) {
-						// errはLimiter.LimiterInfoであることが期待される
-						throw new ApiError({
-							message: 'Rate limit exceeded. Please try again later.',
-							code: 'RATE_LIMIT_EXCEEDED',
-							id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
-							httpStatusCode: 429,
-						}, err.info);
-					} else {
-						throw new TypeError('information must be a rate-limiter information.');
-					}
-				});
+				const info = await this.rateLimiterService.limit(limit, limitActor, factor);
+
+				sendRateLimitHeaders(reply, info);
+
+				if (info.blocked) {
+					throw new ApiError({
+						message: 'Rate limit exceeded. Please try again later.',
+						code: 'RATE_LIMIT_EXCEEDED',
+						id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
+						httpStatusCode: 429,
+					});
+				}
 			}
 		}
 
diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts
index e9afb9d05a82f300baeeb5873eb39ac21de2bccb..879529090ff75cdb85d75d268e3fbf1fb8042f24 100644
--- a/packages/backend/src/server/api/RateLimiterService.ts
+++ b/packages/backend/src/server/api/RateLimiterService.ts
@@ -10,8 +10,10 @@ import { DI } from '@/di-symbols.js';
 import type Logger from '@/logger.js';
 import { LoggerService } from '@/core/LoggerService.js';
 import { bindThis } from '@/decorators.js';
+import { LegacyRateLimit } from '@/misc/rate-limit-utils.js';
 import type { IEndpointMeta } from './endpoints.js';
 
+/** @deprecated Use SkRateLimiterService instead */
 @Injectable()
 export class RateLimiterService {
 	private logger: Logger;
@@ -31,7 +33,7 @@ export class RateLimiterService {
 	}
 
 	@bindThis
-	public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
+	public limit(limitation: LegacyRateLimit & { key: NonNullable<string> }, actor: string, factor = 1) {
 		return new Promise<void>((ok, reject) => {
 			if (this.disabled) ok();
 
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 64af7da7a61b18ce9d49d1f26aa6bd302f300c02..1a4ce0a54cd3b437674188d248d1b3039fabd56d 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -21,12 +21,13 @@ import { IdService } from '@/core/IdService.js';
 import { bindThis } from '@/decorators.js';
 import { WebAuthnService } from '@/core/WebAuthnService.js';
 import { UserAuthService } from '@/core/UserAuthService.js';
-import { RateLimiterService } from './RateLimiterService.js';
+import { isSystemAccount } from '@/misc/is-system-account.js';
+import type { MiMeta } from '@/models/_.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
 import { SigninService } from './SigninService.js';
 import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
 import type { FastifyReply, FastifyRequest } from 'fastify';
-import { isSystemAccount } from '@/misc/is-system-account.js';
-import type { MiMeta } from '@/models/_.js';
 
 @Injectable()
 export class SigninApiService {
@@ -47,7 +48,7 @@ export class SigninApiService {
 		private signinsRepository: SigninsRepository,
 
 		private idService: IdService,
-		private rateLimiterService: RateLimiterService,
+		private rateLimiterService: SkRateLimiterService,
 		private signinService: SigninService,
 		private userAuthService: UserAuthService,
 		private webAuthnService: WebAuthnService,
@@ -79,10 +80,12 @@ export class SigninApiService {
 			return { error };
 		}
 
-		try {
 		// not more than 1 attempt per second and not more than 10 attempts per hour
-			await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
-		} catch (err) {
+		const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
+
+		sendRateLimitHeaders(reply, rateLimit);
+
+		if (rateLimit.blocked) {
 			reply.code(429);
 			return {
 				error: {
diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts
index 9ba23c54e22656c80f5d848eb3073eb29b959b8e..e94d2b6b681e85ae9653b9c82476f752efb6135b 100644
--- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts
+++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts
@@ -21,7 +21,8 @@ import { WebAuthnService } from '@/core/WebAuthnService.js';
 import Logger from '@/logger.js';
 import { LoggerService } from '@/core/LoggerService.js';
 import type { IdentifiableError } from '@/misc/identifiable-error.js';
-import { RateLimiterService } from './RateLimiterService.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
 import { SigninService } from './SigninService.js';
 import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
 import type { FastifyReply, FastifyRequest } from 'fastify';
@@ -43,7 +44,7 @@ export class SigninWithPasskeyApiService {
 		private signinsRepository: SigninsRepository,
 
 		private idService: IdService,
-		private rateLimiterService: RateLimiterService,
+		private rateLimiterService: SkRateLimiterService,
 		private signinService: SigninService,
 		private webAuthnService: WebAuthnService,
 		private loggerService: LoggerService,
@@ -84,11 +85,13 @@ export class SigninWithPasskeyApiService {
 			return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
 		};
 
-		try {
-			// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
-			// NOTE: 1 Sign-in require 2 API calls
-			await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
-		} catch (err) {
+		// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
+		// NOTE: 1 Sign-in require 2 API calls
+		const rateLimit = await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
+
+		sendRateLimitHeaders(reply, rateLimit);
+
+		if (rateLimit.blocked) {
 			reply.code(429);
 			return {
 				error: {
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6415ee905c91a2c7125a8e43c57a960a797695f2
--- /dev/null
+++ b/packages/backend/src/server/api/SkRateLimiterService.ts
@@ -0,0 +1,198 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import Redis from 'ioredis';
+import { LoggerService } from '@/core/LoggerService.js';
+import { TimeService } from '@/core/TimeService.js';
+import { EnvService } from '@/core/EnvService.js';
+import { DI } from '@/di-symbols.js';
+import type Logger from '@/logger.js';
+import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed } from '@/misc/rate-limit-utils.js';
+
+@Injectable()
+export class SkRateLimiterService {
+	private readonly logger: Logger;
+	private readonly disabled: boolean;
+
+	constructor(
+		@Inject(TimeService)
+		private readonly timeService: TimeService,
+
+		@Inject(DI.redis)
+		private readonly redisClient: Redis.Redis,
+
+		@Inject(LoggerService)
+		loggerService: LoggerService,
+
+		@Inject(EnvService)
+		envService: EnvService,
+	) {
+		this.logger = loggerService.getLogger('limiter');
+		this.disabled = envService.env.NODE_ENV !== 'production'; // TODO disable in TEST *only*
+	}
+
+	public async limit(limit: Keyed<RateLimit>, actor: string, factor = 1): Promise<LimitInfo> {
+		if (this.disabled || factor === 0) {
+			return {
+				blocked: false,
+				remaining: Number.MAX_SAFE_INTEGER,
+				resetSec: 0,
+				resetMs: 0,
+				fullResetSec: 0,
+				fullResetMs: 0,
+			};
+		}
+
+		if (factor < 0) {
+			throw new Error(`Rate limit factor is zero or negative: ${factor}`);
+		}
+
+		if (isLegacyRateLimit(limit)) {
+			return await this.limitLegacy(limit, actor, factor);
+		} else {
+			return await this.limitBucket(limit, actor, factor);
+		}
+	}
+
+	private async limitLegacy(limit: Keyed<LegacyRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
+		const promises: Promise<LimitInfo | null>[] = [];
+
+		// The "min" limit - if present - is handled directly.
+		if (hasMinLimit(limit)) {
+			promises.push(
+				this.limitMin(limit, actor, factor),
+			);
+		}
+
+		// Convert the "max" limit into a leaky bucket with 1 drip / second rate.
+		if (limit.max != null && limit.duration != null) {
+			promises.push(
+				this.limitBucket({
+					type: 'bucket',
+					key: limit.key,
+					size: limit.max,
+					dripRate: Math.max(Math.round(limit.duration / limit.max), 1),
+				}, actor, factor),
+			);
+		}
+
+		const [lim1, lim2] = await Promise.all(promises);
+		return {
+			blocked: (lim1?.blocked || lim2?.blocked) ?? false,
+			remaining: Math.min(lim1?.remaining ?? Number.MAX_SAFE_INTEGER, lim2?.remaining ?? Number.MAX_SAFE_INTEGER),
+			resetSec: Math.max(lim1?.resetSec ?? 0, lim2?.resetSec ?? 0),
+			resetMs: Math.max(lim1?.resetMs ?? 0, lim2?.resetMs ?? 0),
+			fullResetSec: Math.max(lim1?.fullResetSec ?? 0, lim2?.fullResetSec ?? 0),
+			fullResetMs: Math.max(lim1?.fullResetMs ?? 0, lim2?.fullResetMs ?? 0),
+		};
+	}
+
+	private async limitMin(limit: Keyed<LegacyRateLimit> & { minInterval: number }, actor: string, factor: number): Promise<LimitInfo | null> {
+		if (limit.minInterval === 0) return null;
+		if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
+
+		const counter = await this.getLimitCounter(limit, actor, 'min');
+		const minInterval = Math.max(Math.ceil(limit.minInterval * factor), 0);
+
+		// Update expiration
+		if (counter.c > 0) {
+			const isCleared = this.timeService.now - counter.t >= minInterval;
+			if (isCleared) {
+				counter.c = 0;
+			}
+		}
+
+		const blocked = counter.c > 0;
+		if (!blocked) {
+			counter.c++;
+			counter.t = this.timeService.now;
+		}
+
+		// Calculate limit status
+		const resetMs = Math.max(Math.ceil(minInterval - (this.timeService.now - counter.t)), 0);
+		const resetSec = Math.ceil(resetMs / 1000);
+		const limitInfo: LimitInfo = { blocked, remaining: 0, resetSec, resetMs, fullResetSec: resetSec, fullResetMs: resetMs };
+
+		// Update the limit counter, but not if blocked
+		if (!blocked) {
+			// Don't await, or we will slow down the API.
+			this.setLimitCounter(limit, actor, counter, resetSec, 'min')
+				.catch(err => this.logger.error(`Failed to update limit ${limit.key}:min for ${actor}:`, err));
+		}
+
+		return limitInfo;
+	}
+
+	private async limitBucket(limit: Keyed<BucketRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
+		if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`);
+		if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`);
+		if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`);
+
+		const counter = await this.getLimitCounter(limit, actor, 'bucket');
+		const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
+		const dripRate = Math.ceil(limit.dripRate ?? 1000);
+		const dripSize = Math.ceil(limit.dripSize ?? 1);
+
+		// Update drips
+		if (counter.c > 0) {
+			const dripsSinceLastTick = Math.floor((this.timeService.now - counter.t) / dripRate) * dripSize;
+			counter.c = Math.max(counter.c - dripsSinceLastTick, 0);
+		}
+
+		const blocked = counter.c >= bucketSize;
+		if (!blocked) {
+			counter.c++;
+			counter.t = this.timeService.now;
+		}
+
+		// Calculate limit status
+		const remaining = Math.max(bucketSize - counter.c, 0);
+		const resetMs = remaining > 0 ? 0 : Math.max(dripRate - (this.timeService.now - counter.t), 0);
+		const resetSec = Math.ceil(resetMs / 1000);
+		const fullResetMs = Math.ceil(counter.c / dripSize) * dripRate;
+		const fullResetSec = Math.ceil(fullResetMs / 1000);
+		const limitInfo: LimitInfo = { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs };
+
+		// Update the limit counter, but not if blocked
+		if (!blocked) {
+			// Don't await, or we will slow down the API.
+			this.setLimitCounter(limit, actor, counter, fullResetSec, 'bucket')
+				.catch(err => this.logger.error(`Failed to update limit ${limit.key} for ${actor}:`, err));
+		}
+
+		return limitInfo;
+	}
+
+	private async getLimitCounter(limit: Keyed<RateLimit>, actor: string, subject: string): Promise<LimitCounter> {
+		const key = createLimitKey(limit, actor, subject);
+
+		const value = await this.redisClient.get(key);
+		if (value == null) {
+			return { t: 0, c: 0 };
+		}
+
+		return JSON.parse(value);
+	}
+
+	private async setLimitCounter(limit: Keyed<RateLimit>, actor: string, counter: LimitCounter, expiration: number, subject: string): Promise<void> {
+		const key = createLimitKey(limit, actor, subject);
+		const value = JSON.stringify(counter);
+		const expirationSec = Math.max(expiration, 1);
+		await this.redisClient.set(key, value, 'EX', expirationSec);
+	}
+}
+
+function createLimitKey(limit: Keyed<RateLimit>, actor: string, subject: string): string {
+	return `rl_${actor}_${limit.key}_${subject}`;
+}
+
+export interface LimitCounter {
+	/** Timestamp */
+	t: number;
+
+	/** Counter */
+	c: number;
+}
diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts
index 9b8464f7050dce71b6e50376ee0bf96747386612..e3fd1312ae30eec392efcca4f02a8d2dacb0b083 100644
--- a/packages/backend/src/server/api/StreamingApiServerService.ts
+++ b/packages/backend/src/server/api/StreamingApiServerService.ts
@@ -7,6 +7,8 @@ import { EventEmitter } from 'events';
 import { Inject, Injectable } from '@nestjs/common';
 import * as Redis from 'ioredis';
 import * as WebSocket from 'ws';
+import proxyAddr from 'proxy-addr';
+import ms from 'ms';
 import { DI } from '@/di-symbols.js';
 import type { UsersRepository, MiAccessToken } from '@/models/_.js';
 import { NoteReadService } from '@/core/NoteReadService.js';
@@ -16,18 +18,15 @@ import { CacheService } from '@/core/CacheService.js';
 import { MiLocalUser } from '@/models/User.js';
 import { UserService } from '@/core/UserService.js';
 import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { getIpHash } from '@/misc/get-ip-hash.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
 import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
 import MainStreamConnection from './stream/Connection.js';
 import { ChannelsService } from './stream/ChannelsService.js';
-import { RateLimiterService } from './RateLimiterService.js';
-import { RoleService } from '@/core/RoleService.js';
-import { getIpHash } from '@/misc/get-ip-hash.js';
-import proxyAddr from 'proxy-addr';
-import ms from 'ms';
 import type * as http from 'node:http';
 import type { IEndpointMeta } from './endpoints.js';
-import { LoggerService } from '@/core/LoggerService.js';
-import type Logger from '@/logger.js';
 
 @Injectable()
 export class StreamingApiServerService {
@@ -49,7 +48,7 @@ export class StreamingApiServerService {
 		private notificationService: NotificationService,
 		private usersService: UserService,
 		private channelFollowingService: ChannelFollowingService,
-		private rateLimiterService: RateLimiterService,
+		private rateLimiterService: SkRateLimiterService,
 		private roleService: RoleService,
 		private loggerService: LoggerService,
 	) {
@@ -73,9 +72,8 @@ export class StreamingApiServerService {
 		if (factor <= 0) return false;
 
 		// Rate limit
-		return await this.rateLimiterService.limit(limit, limitActor, factor)
-			.then(() => { return false; })
-			.catch(err => { return true; });
+		const rateLimit = await this.rateLimiterService.limit(limit, limitActor, factor);
+		return rateLimit.blocked;
 	}
 
 	@bindThis
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 14e002929a41cc287f9f53849e354e9517508af9..7eb18fbfe2c0a2c3d0ab3213f941254ae1eea4a9 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -5,6 +5,7 @@
 
 import { permissions } from 'misskey-js';
 import type { KeyOf, Schema } from '@/misc/json-schema.js';
+import type { RateLimit } from '@/misc/rate-limit-utils.js';
 
 import * as ep___admin_abuseReport_notificationRecipient_list
 	from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js';
@@ -855,30 +856,7 @@ interface IEndpointMetaBase {
 	 * エンドポイントのリミテーションに関するやつ
 	 * 省略した場合はリミテーションは無いものとして解釈されます。
 	 */
-	readonly limit?: {
-
-		/**
-		 * 複数のエンドポイントでリミットを共有したい場合に指定するキー
-		 */
-		readonly key?: string;
-
-		/**
-		 * リミットを適用する期間(ms)
-		 * このプロパティを設定する場合、max プロパティも設定する必要があります。
-		 */
-		readonly duration?: number;
-
-		/**
-		 * durationで指定した期間内にいくつまでリクエストできるのか
-		 * このプロパティを設定する場合、duration プロパティも設定する必要があります。
-		 */
-		readonly max?: number;
-
-		/**
-		 * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms)
-		 */
-		readonly minInterval?: number;
-	};
+	readonly limit?: Readonly<RateLimit>;
 
 	/**
 	 * ファイルの添付を必要とするか否か
diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts
index 7629cd7a674d4adbd5112ae997e94143ec7c79f0..a1dbb26431a6bde0a70573f469c18c41e97f4969 100644
--- a/packages/backend/src/server/api/endpoints/endpoint.ts
+++ b/packages/backend/src/server/api/endpoints/endpoint.ts
@@ -29,10 +29,13 @@ export const meta = {
 		},
 	},
 
-	// 5 calls per second
+	// 1000 max @ 1/10ms drip = 10/sec average.
+	// Large bucket is ok because this is a fairly lightweight endpoint.
 	limit: {
-		duration: 1000,
-		max: 5,
+		type: 'bucket',
+
+		size: 1000,
+		dripRate: 10,
 	},
 } as const;
 
diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts
index 4d73ba5af1c1833efb38599b72b45bb1e0f29e93..7df991c15c2c70d2a17eb21c61f30b47ba90afaf 100644
--- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts
+++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts
@@ -17,16 +17,24 @@ import { GlobalModule } from '@/GlobalModule.js';
 import { DI } from '@/di-symbols.js';
 import { CoreModule } from '@/core/CoreModule.js';
 import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js';
-import { RateLimiterService } from '@/server/api/RateLimiterService.js';
 import { WebAuthnService } from '@/core/WebAuthnService.js';
 import { SigninService } from '@/server/api/SigninService.js';
 import { IdentifiableError } from '@/misc/identifiable-error.js';
+import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { LimitInfo } from '@/misc/rate-limit-utils.js';
 
 const moduleMocker = new ModuleMocker(global);
 
 class FakeLimiter {
-	public async limit() {
-		return;
+	public async limit(): Promise<LimitInfo> {
+		return {
+			blocked: false,
+			remaining: Number.MAX_SAFE_INTEGER,
+			resetMs: 0,
+			resetSec: 0,
+			fullResetMs: 0,
+			fullResetSec: 0,
+		};
 	}
 }
 
@@ -90,7 +98,7 @@ describe('SigninWithPasskeyApiService', () => {
 			imports: [GlobalModule, CoreModule],
 			providers: [
 				SigninWithPasskeyApiService,
-				{ provide: RateLimiterService, useClass: FakeLimiter },
+				{ provide: SkRateLimiterService, useClass: FakeLimiter },
 				{ provide: SigninService, useClass: FakeSigninService },
 			],
 		}).useMocker((token) => {
diff --git a/packages/backend/test/unit/misc/rate-limit-utils-tests.ts b/packages/backend/test/unit/misc/rate-limit-utils-tests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ea56d170f7aa8795be4b2bda6ba74d80fa560ad9
--- /dev/null
+++ b/packages/backend/test/unit/misc/rate-limit-utils-tests.ts
@@ -0,0 +1,62 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { jest } from '@jest/globals';
+import { Mock } from 'jest-mock';
+import type { FastifyReply } from 'fastify';
+import { LimitInfo, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
+
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+
+describe(sendRateLimitHeaders, () => {
+	let mockHeader: Mock<((name: string, value: unknown) => void)> = null!;
+	let mockReply: FastifyReply = null!;
+	let fakeInfo: LimitInfo = null!;
+
+	beforeEach(() => {
+		mockHeader = jest.fn<((name: string, value: unknown) => void)>();
+		mockReply = {
+			header: mockHeader,
+		} as unknown as FastifyReply;
+		fakeInfo = {
+			blocked: false,
+			remaining: 1,
+			resetSec: 1,
+			resetMs: 567,
+			fullResetSec: 10,
+			fullResetMs: 9876,
+		};
+	});
+
+	it('should send X-RateLimit-Clear', () => {
+		sendRateLimitHeaders(mockReply, fakeInfo);
+
+		expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Clear', '9.876');
+	});
+
+	it('should send X-RateLimit-Remaining', () => {
+		sendRateLimitHeaders(mockReply, fakeInfo);
+
+		expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', '1');
+	});
+
+	describe('when limit is blocked', () => {
+		it('should send X-RateLimit-Reset', () => {
+			fakeInfo.blocked = true;
+
+			sendRateLimitHeaders(mockReply, fakeInfo);
+
+			expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Reset', '0.567');
+		});
+
+		it('should send Retry-After', () => {
+			fakeInfo.blocked = true;
+
+			sendRateLimitHeaders(mockReply, fakeInfo);
+
+			expect(mockHeader).toHaveBeenCalledWith('Retry-After', '1');
+		});
+	});
+});
diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dbf7795fc64500b81a1327e419ebebf0837d7b76
--- /dev/null
+++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
@@ -0,0 +1,857 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { KEYWORD } from 'color-convert/conversions.js';
+import { jest } from '@jest/globals';
+import type Redis from 'ioredis';
+import { LimitCounter, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { BucketRateLimit, Keyed, LegacyRateLimit } from '@/misc/rate-limit-utils.js';
+
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+/* eslint-disable @typescript-eslint/no-unnecessary-condition */
+
+describe(SkRateLimiterService, () => {
+	let mockTimeService: { now: number, date: Date } = null!;
+	let mockRedisGet: ((key: string) => string | null) | undefined = undefined;
+	let mockRedisSet: ((args: unknown[]) => void) | undefined = undefined;
+	let mockEnvironment: Record<string, string | undefined> = null!;
+	let serviceUnderTest: () => SkRateLimiterService = null!;
+
+	let loggedMessages: { level: string, data: unknown[] }[] = [];
+
+	beforeEach(() => {
+		mockTimeService = {
+			now: 0,
+			get date() {
+				return new Date(mockTimeService.now);
+			},
+		};
+
+		mockRedisGet = undefined;
+		mockRedisSet = undefined;
+		const mockRedisClient = {
+			get(key: string) {
+				if (mockRedisGet) return Promise.resolve(mockRedisGet(key));
+				else return Promise.resolve(null);
+			},
+			set(...args: unknown[]): Promise<void> {
+				if (mockRedisSet) mockRedisSet(args);
+				return Promise.resolve();
+			},
+		} as unknown as Redis.Redis;
+
+		mockEnvironment = Object.create(process.env);
+		mockEnvironment.NODE_ENV = 'production';
+		const mockEnvService = {
+			env: mockEnvironment,
+		};
+
+		loggedMessages = [];
+		const mockLogService = {
+			getLogger() {
+				return {
+					createSubLogger(context: string, color?: KEYWORD) {
+						return mockLogService.getLogger(context, color);
+					},
+					error(...data: unknown[]) {
+						loggedMessages.push({ level: 'error', data });
+					},
+					warn(...data: unknown[]) {
+						loggedMessages.push({ level: 'warn', data });
+					},
+					succ(...data: unknown[]) {
+						loggedMessages.push({ level: 'succ', data });
+					},
+					debug(...data: unknown[]) {
+						loggedMessages.push({ level: 'debug', data });
+					},
+					info(...data: unknown[]) {
+						loggedMessages.push({ level: 'info', data });
+					},
+				};
+			},
+		} as unknown as LoggerService;
+
+		let service: SkRateLimiterService | undefined = undefined;
+		serviceUnderTest = () => {
+			return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, mockLogService, mockEnvService);
+		};
+	});
+
+	function expectNoUnhandledErrors() {
+		const unhandledErrors = loggedMessages.filter(m => m.level === 'error');
+		if (unhandledErrors.length > 0) {
+			throw new Error(`Test failed: got unhandled errors ${unhandledErrors.join('\n')}`);
+		}
+	}
+
+	describe('limit', () => {
+		const actor = 'actor';
+		const key = 'test';
+
+		let counter: LimitCounter | undefined = undefined;
+		let minCounter: LimitCounter | undefined = undefined;
+
+		beforeEach(() => {
+			counter = undefined;
+			minCounter = undefined;
+
+			mockRedisGet = (key: string) => {
+				if (key === 'rl_actor_test_bucket' && counter) {
+					return JSON.stringify(counter);
+				}
+
+				if (key === 'rl_actor_test_min' && minCounter) {
+					return JSON.stringify(minCounter);
+				}
+
+				return null;
+			};
+
+			mockRedisSet = (args: unknown[]) => {
+				const [key, value] = args;
+
+				if (key === 'rl_actor_test_bucket') {
+					if (value == null) counter = undefined;
+					else if (typeof(value) === 'string') counter = JSON.parse(value);
+					else throw new Error('invalid redis call');
+				}
+
+				if (key === 'rl_actor_test_min') {
+					if (value == null) minCounter = undefined;
+					else if (typeof(value) === 'string') minCounter = JSON.parse(value);
+					else throw new Error('invalid redis call');
+				}
+			};
+		});
+
+		it('should bypass in non-production', async () => {
+			mockEnvironment.NODE_ENV = 'test';
+
+			const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, 'actor');
+
+			expect(info.blocked).toBeFalsy();
+			expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
+			expect(info.resetSec).toBe(0);
+			expect(info.resetMs).toBe(0);
+			expect(info.fullResetSec).toBe(0);
+			expect(info.fullResetMs).toBe(0);
+		});
+
+		describe('with bucket limit', () => {
+			let limit: Keyed<BucketRateLimit> = null!;
+
+			beforeEach(() => {
+				limit = {
+					type: 'bucket',
+					key: 'test',
+					size: 1,
+				};
+			});
+
+			it('should allow when limit is not reached', async () => {
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should not error when allowed', async () => {
+				await serviceUnderTest().limit(limit, actor);
+
+				expectNoUnhandledErrors();
+			});
+
+			it('should return correct info when allowed', async () => {
+				limit.size = 2;
+				counter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(0);
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(2);
+				expect(info.fullResetMs).toBe(2000);
+			});
+
+			it('should increment counter when called', async () => {
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter).not.toBeUndefined();
+				expect(counter?.c).toBe(1);
+			});
+
+			it('should set timestamp when called', async () => {
+				mockTimeService.now = 1000;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter).not.toBeUndefined();
+				expect(counter?.t).toBe(1000);
+			});
+
+			it('should decrement counter when dripRate has passed', async () => {
+				counter = { c: 2, t: 0 };
+				mockTimeService.now = 2000;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter).not.toBeUndefined();
+				expect(counter?.c).toBe(1); // 2 (starting) - 2 (2x1 drip) + 1 (call) = 1
+			});
+
+			it('should decrement counter by dripSize', async () => {
+				counter = { c: 2, t: 0 };
+				limit.dripSize = 2;
+				mockTimeService.now = 1000;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter).not.toBeUndefined();
+				expect(counter?.c).toBe(1); // 2 (starting) - 2 (1x2 drip) + 1 (call) = 1
+			});
+
+			it('should maintain counter between calls over time', async () => {
+				limit.size = 5;
+
+				await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
+				mockTimeService.now += 1000; // 1 - 1 = 0
+				await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
+				await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
+				await serviceUnderTest().limit(limit, actor); // 2 + 1 = 3
+				mockTimeService.now += 1000; // 3 - 1 = 2
+				mockTimeService.now += 1000; // 2 - 1 = 1
+				await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
+
+				expect(counter?.c).toBe(2);
+				expect(counter?.t).toBe(3000);
+			});
+
+			it('should log error and continue when update fails', async () => {
+				mockRedisSet = () => {
+					throw new Error('test error');
+				};
+
+				await serviceUnderTest().limit(limit, actor);
+
+				const matchingError = loggedMessages
+					.find(m => m.level === 'error' && m.data
+						.some(d => typeof(d) === 'string' && d.includes('Failed to update limit')));
+				expect(matchingError).toBeTruthy();
+			});
+
+			it('should block when bucket is filled', async () => {
+				counter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeTruthy();
+			});
+
+			it('should calculate correct info when blocked', async () => {
+				counter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(1);
+				expect(info.fullResetMs).toBe(1000);
+			});
+
+			it('should allow when bucket is filled but should drip', async () => {
+				counter = { c: 1, t: 0 };
+				mockTimeService.now = 1000;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should scale limit by factor', async () => {
+				counter = { c: 1, t: 0 };
+
+				const i1 = await serviceUnderTest().limit(limit, actor, 0.5); // 1 + 1 = 2
+				const i2 = await serviceUnderTest().limit(limit, actor, 0.5); // 2 + 1 = 3
+
+				expect(i1.blocked).toBeFalsy();
+				expect(i2.blocked).toBeTruthy();
+			});
+
+			it('should set key expiration', async () => {
+				const mock = jest.fn(mockRedisSet);
+				mockRedisSet = mock;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
+			});
+
+			it('should not increment when already blocked', async () => {
+				counter = { c: 1, t: 0 };
+				mockTimeService.now += 100;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter?.c).toBe(1);
+				expect(counter?.t).toBe(0);
+			});
+
+			it('should skip if factor is zero', async () => {
+				const info = await serviceUnderTest().limit(limit, actor, 0);
+
+				expect(info.blocked).toBeFalsy();
+				expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
+			});
+
+			it('should throw if factor is negative', async () => {
+				const promise = serviceUnderTest().limit(limit, actor, -1);
+
+				await expect(promise).rejects.toThrow(/factor is zero or negative/);
+			});
+
+			it('should throw if size is zero', async () => {
+				limit.size = 0;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/size is less than 1/);
+			});
+
+			it('should throw if size is negative', async () => {
+				limit.size = -1;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/size is less than 1/);
+			});
+
+			it('should throw if size is fraction', async () => {
+				limit.size = 0.5;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/size is less than 1/);
+			});
+
+			it('should throw if dripRate is zero', async () => {
+				limit.dripRate = 0;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/dripRate is less than 1/);
+			});
+
+			it('should throw if dripRate is negative', async () => {
+				limit.dripRate = -1;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/dripRate is less than 1/);
+			});
+
+			it('should throw if dripRate is fraction', async () => {
+				limit.dripRate = 0.5;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/dripRate is less than 1/);
+			});
+
+			it('should throw if dripSize is zero', async () => {
+				limit.dripSize = 0;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/dripSize is less than 1/);
+			});
+
+			it('should throw if dripSize is negative', async () => {
+				limit.dripSize = -1;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/dripSize is less than 1/);
+			});
+
+			it('should throw if dripSize is fraction', async () => {
+				limit.dripSize = 0.5;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/dripSize is less than 1/);
+			});
+		});
+
+		describe('with min interval', () => {
+			let limit: Keyed<LegacyRateLimit> = null!;
+
+			beforeEach(() => {
+				limit = {
+					type: undefined,
+					key,
+					minInterval: 1000,
+				};
+			});
+
+			it('should allow when limit is not reached', async () => {
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should not error when allowed', async () => {
+				await serviceUnderTest().limit(limit, actor);
+
+				expectNoUnhandledErrors();
+			});
+
+			it('should calculate correct info when allowed', async () => {
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(0);
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(1);
+				expect(info.fullResetMs).toBe(1000);
+			});
+
+			it('should increment counter when called', async () => {
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(minCounter).not.toBeUndefined();
+				expect(minCounter?.c).toBe(1);
+			});
+
+			it('should set timestamp when called', async () => {
+				mockTimeService.now = 1000;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(minCounter).not.toBeUndefined();
+				expect(minCounter?.t).toBe(1000);
+			});
+
+			it('should decrement counter when minInterval has passed', async () => {
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now = 1000;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(minCounter).not.toBeUndefined();
+				expect(minCounter?.c).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1
+			});
+
+			it('should reset counter entirely', async () => {
+				minCounter = { c: 2, t: 0 };
+				mockTimeService.now = 1000;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(minCounter).not.toBeUndefined();
+				expect(minCounter?.c).toBe(1); // 2 (starting) - 2 (interval) + 1 (call) = 1
+			});
+
+			it('should maintain counter between calls over time', async () => {
+				await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
+				mockTimeService.now += 1000; // 1 - 1 = 0
+				await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
+				await serviceUnderTest().limit(limit, actor); // blocked
+				await serviceUnderTest().limit(limit, actor); // blocked
+				mockTimeService.now += 1000; // 1 - 1 = 0
+				mockTimeService.now += 1000; // 0 - 1 = 0
+				await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
+
+				expect(minCounter?.c).toBe(1);
+				expect(minCounter?.t).toBe(3000);
+			});
+
+			it('should log error and continue when update fails', async () => {
+				mockRedisSet = () => {
+					throw new Error('test error');
+				};
+
+				await serviceUnderTest().limit(limit, actor);
+
+				const matchingError = loggedMessages
+					.find(m => m.level === 'error' && m.data
+						.some(d => typeof(d) === 'string' && d.includes('Failed to update limit')));
+				expect(matchingError).toBeTruthy();
+			});
+
+			it('should block when interval exceeded', async () => {
+				minCounter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeTruthy();
+			});
+
+			it('should calculate correct info when blocked', async () => {
+				minCounter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(1);
+				expect(info.fullResetMs).toBe(1000);
+			});
+
+			it('should allow when bucket is filled but interval has passed', async () => {
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now = 1000;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should scale interval by factor', async () => {
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now += 500;
+
+				const info = await serviceUnderTest().limit(limit, actor, 0.5);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should set key expiration', async () => {
+				const mock = jest.fn(mockRedisSet);
+				mockRedisSet = mock;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]);
+			});
+
+			it('should not increment when already blocked', async () => {
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now += 100;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(minCounter?.c).toBe(1);
+				expect(minCounter?.t).toBe(0);
+			});
+
+			it('should skip if factor is zero', async () => {
+				const info = await serviceUnderTest().limit(limit, actor, 0);
+
+				expect(info.blocked).toBeFalsy();
+				expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
+			});
+
+			it('should throw if factor is negative', async () => {
+				const promise = serviceUnderTest().limit(limit, actor, -1);
+
+				await expect(promise).rejects.toThrow(/factor is zero or negative/);
+			});
+
+			it('should skip if minInterval is zero', async () => {
+				limit.minInterval = 0;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+				expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
+			});
+
+			it('should throw if minInterval is negative', async () => {
+				limit.minInterval = -1;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/minInterval is negative/);
+			});
+		});
+
+		describe('with legacy limit', () => {
+			let limit: Keyed<LegacyRateLimit> = null!;
+
+			beforeEach(() => {
+				limit = {
+					type: undefined,
+					key,
+					max: 1,
+					duration: 1000,
+				};
+			});
+
+			it('should allow when limit is not reached', async () => {
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should not error when allowed', async () => {
+				await serviceUnderTest().limit(limit, actor);
+
+				expectNoUnhandledErrors();
+			});
+
+			it('should infer dripRate from duration', async () => {
+				limit.max = 10;
+				limit.duration = 10000;
+				counter = { c: 10, t: 0 };
+
+				const i1 = await serviceUnderTest().limit(limit, actor);
+				mockTimeService.now += 1000;
+				const i2 = await serviceUnderTest().limit(limit, actor);
+				mockTimeService.now += 2000;
+				const i3 = await serviceUnderTest().limit(limit, actor);
+				const i4 = await serviceUnderTest().limit(limit, actor);
+				const i5 = await serviceUnderTest().limit(limit, actor);
+				mockTimeService.now += 2000;
+				const i6 = await serviceUnderTest().limit(limit, actor);
+
+				expect(i1.blocked).toBeTruthy();
+				expect(i2.blocked).toBeFalsy();
+				expect(i3.blocked).toBeFalsy();
+				expect(i4.blocked).toBeFalsy();
+				expect(i5.blocked).toBeTruthy();
+				expect(i6.blocked).toBeFalsy();
+			});
+
+			it('should calculate correct info when allowed', async () => {
+				limit.max = 10;
+				limit.duration = 10000;
+				counter = { c: 10, t: 0 };
+				mockTimeService.now += 2000;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(1);
+				expect(info.resetSec).toBe(0);
+				expect(info.resetMs).toBe(0);
+				expect(info.fullResetSec).toBe(9);
+				expect(info.fullResetMs).toBe(9000);
+			});
+
+			it('should calculate correct info when blocked', async () => {
+				limit.max = 10;
+				limit.duration = 10000;
+				counter = { c: 10, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(0);
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(10);
+				expect(info.fullResetMs).toBe(10000);
+			});
+
+			it('should allow when bucket is filled but interval has passed', async () => {
+				counter = { c: 10, t: 0 };
+				mockTimeService.now = 1000;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeTruthy();
+			});
+
+			it('should scale limit by factor', async () => {
+				counter = { c: 10, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor, 0.5); // 10 + 1 = 11
+
+				expect(info.blocked).toBeTruthy();
+			});
+
+			it('should set key expiration', async () => {
+				const mock = jest.fn(mockRedisSet);
+				mockRedisSet = mock;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
+			});
+
+			it('should not increment when already blocked', async () => {
+				counter = { c: 1, t: 0 };
+				mockTimeService.now += 100;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter?.c).toBe(1);
+				expect(counter?.t).toBe(0);
+			});
+
+			it('should not allow dripRate to be lower than 0', async () => {
+				// real-world case; taken from StreamingApiServerService
+				limit.max = 4096;
+				limit.duration = 2000;
+				counter = { c: 4096, t: 0 };
+
+				const i1 = await serviceUnderTest().limit(limit, actor);
+				mockTimeService.now = 1;
+				const i2 = await serviceUnderTest().limit(limit, actor);
+
+				expect(i1.blocked).toBeTruthy();
+				expect(i2.blocked).toBeFalsy();
+			});
+
+			it('should skip if factor is zero', async () => {
+				const info = await serviceUnderTest().limit(limit, actor, 0);
+
+				expect(info.blocked).toBeFalsy();
+				expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
+			});
+
+			it('should throw if factor is negative', async () => {
+				const promise = serviceUnderTest().limit(limit, actor, -1);
+
+				await expect(promise).rejects.toThrow(/factor is zero or negative/);
+			});
+
+			it('should throw if max is zero', async () => {
+				limit.max = 0;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/size is less than 1/);
+			});
+
+			it('should throw if max is negative', async () => {
+				limit.max = -1;
+
+				const promise = serviceUnderTest().limit(limit, actor);
+
+				await expect(promise).rejects.toThrow(/size is less than 1/);
+			});
+		});
+
+		describe('with legacy limit and min interval', () => {
+			let limit: Keyed<LegacyRateLimit> = null!;
+
+			beforeEach(() => {
+				limit = {
+					type: undefined,
+					key,
+					max: 5,
+					duration: 5000,
+					minInterval: 1000,
+				};
+			});
+
+			it('should allow when limit is not reached', async () => {
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should not error when allowed', async () => {
+				await serviceUnderTest().limit(limit, actor);
+
+				expectNoUnhandledErrors();
+			});
+
+			it('should block when limit exceeded', async () => {
+				counter = { c: 5, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeTruthy();
+			});
+
+			it('should block when minInterval exceeded', async () => {
+				minCounter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeTruthy();
+			});
+
+			it('should calculate correct info when allowed', async () => {
+				counter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(0);
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(2);
+				expect(info.fullResetMs).toBe(2000);
+			});
+
+			it('should calculate correct info when blocked by limit', async () => {
+				counter = { c: 5, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(0);
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(5);
+				expect(info.fullResetMs).toBe(5000);
+			});
+
+			it('should calculate correct info when blocked by minInterval', async () => {
+				minCounter = { c: 1, t: 0 };
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.remaining).toBe(0);
+				expect(info.resetSec).toBe(1);
+				expect(info.resetMs).toBe(1000);
+				expect(info.fullResetSec).toBe(1);
+				expect(info.fullResetMs).toBe(1000);
+			});
+
+			it('should allow when counter is filled but interval has passed', async () => {
+				counter = { c: 5, t: 0 };
+				mockTimeService.now = 1000;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should allow when minCounter is filled but interval has passed', async () => {
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now = 1000;
+
+				const info = await serviceUnderTest().limit(limit, actor);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should scale limit and interval by factor', async () => {
+				counter = { c: 5, t: 0 };
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now += 500;
+
+				const info = await serviceUnderTest().limit(limit, actor, 0.5);
+
+				expect(info.blocked).toBeFalsy();
+			});
+
+			it('should set key expiration', async () => {
+				const mock = jest.fn(mockRedisSet);
+				mockRedisSet = mock;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
+				expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]);
+			});
+
+			it('should not increment when already blocked', async () => {
+				counter = { c: 5, t: 0 };
+				minCounter = { c: 1, t: 0 };
+				mockTimeService.now += 100;
+
+				await serviceUnderTest().limit(limit, actor);
+
+				expect(counter?.c).toBe(5);
+				expect(counter?.t).toBe(0);
+				expect(minCounter?.c).toBe(1);
+				expect(minCounter?.t).toBe(0);
+			});
+		});
+	});
+});