mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Move feature rate limiters to shared web folder (#31855)
* feat: remove old assist split test * feat: moving featue rate limiters to main shared directory for use in multiple modules * feat: base workbench rate limiter on a token specific base class * feat: rename aiErrorAssistRateLimiter to AiFeatureUsageRateLimiter to better reflect its for our shared ai usage quota GitOrigin-RevId: 89464d115b5904f6274756a7169e2b35945e2fc9
This commit is contained in:
committed by
Copybot
parent
f1137cbabb
commit
501e11a42a
@@ -49,6 +49,7 @@ const ignoreWords = {
|
|||||||
camel: new Set([
|
camel: new Set([
|
||||||
'addWorkflowScope',
|
'addWorkflowScope',
|
||||||
'aiErrorAssistant',
|
'aiErrorAssistant',
|
||||||
|
'aiFeatureUsage',
|
||||||
'beginAuth',
|
'beginAuth',
|
||||||
'brandVariationId',
|
'brandVariationId',
|
||||||
'closeEditor',
|
'closeEditor',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { isStandaloneAiAddOnPlanCode } from '../Subscription/AiHelper.mjs'
|
|||||||
import SubscriptionController from '../Subscription/SubscriptionController.mjs'
|
import SubscriptionController from '../Subscription/SubscriptionController.mjs'
|
||||||
import { formatCurrency } from '../../util/currency.js'
|
import { formatCurrency } from '../../util/currency.js'
|
||||||
import UserSettingsHelper from './UserSettingsHelper.mjs'
|
import UserSettingsHelper from './UserSettingsHelper.mjs'
|
||||||
|
import AiFeatureUsageRateLimiter from '../../infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs'
|
||||||
|
|
||||||
const { isPaidSubscription } = SubscriptionHelper
|
const { isPaidSubscription } = SubscriptionHelper
|
||||||
const { hasAdminAccess } = AdminAuthorizationHelper
|
const { hasAdminAccess } = AdminAuthorizationHelper
|
||||||
@@ -795,13 +796,8 @@ const _ProjectController = {
|
|||||||
let featureUsage = {}
|
let featureUsage = {}
|
||||||
|
|
||||||
if (Features.hasFeature('saas')) {
|
if (Features.hasFeature('saas')) {
|
||||||
const usagesLeft = await Modules.promises.hooks.fire(
|
featureUsage =
|
||||||
'remainingFeatureAllocation',
|
await AiFeatureUsageRateLimiter.getRemainingFeatureUses(userId)
|
||||||
userId
|
|
||||||
)
|
|
||||||
usagesLeft?.forEach(usage => {
|
|
||||||
featureUsage = { ...featureUsage, ...usage }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ProjectController._setWritefullTrialState(
|
await ProjectController._setWritefullTrialState(
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import UserGetter from '../../Features/User/UserGetter.mjs'
|
||||||
|
import FeatureUsageRateLimiter from './FeatureUsageRateLimiter.mjs'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
|
import SplitTestHandler from '../../Features/SplitTests/SplitTestHandler.mjs'
|
||||||
|
|
||||||
|
class AiFeatureUsageRateLimiter extends FeatureUsageRateLimiter {
|
||||||
|
constructor() {
|
||||||
|
super('aiFeatureUsage')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
async _getAllowance(userId) {
|
||||||
|
const user = await UserGetter.promises.getUser(userId, {
|
||||||
|
features: 1,
|
||||||
|
writefull: 1,
|
||||||
|
})
|
||||||
|
// todo: quota clean-up: remove aiErrorAssistant checking, and split test
|
||||||
|
const inQuotaSplitTest =
|
||||||
|
await SplitTestHandler.promises.featureFlagEnabledForUser(
|
||||||
|
userId,
|
||||||
|
'plans-2026-phase-1'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inQuotaSplitTest) {
|
||||||
|
const quotaTier = user?.writefull?.isPremium
|
||||||
|
? Settings.writefull.quotaTierGranted
|
||||||
|
: user.features.aiUsageQuota
|
||||||
|
return _quotaTierToAllowance(quotaTier)
|
||||||
|
} else {
|
||||||
|
const DEFAULT_ALLOWANCE = 1
|
||||||
|
const ADD_ON_ALLOWANCE = 200
|
||||||
|
const hasAddOn =
|
||||||
|
user?.features?.aiErrorAssistant || user?.writefull?.isPremium
|
||||||
|
return hasAddOn ? ADD_ON_ALLOWANCE : DEFAULT_ALLOWANCE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a quota tier identifier to its corresponding numeric allowance
|
||||||
|
* using the configured quota grants for AI features.
|
||||||
|
*
|
||||||
|
* @param {string} quotaTier - The quota tier identifier for the user
|
||||||
|
* @returns {number} The numeric allowance for the given tier
|
||||||
|
*/
|
||||||
|
function _quotaTierToAllowance(quotaTier) {
|
||||||
|
const quota = Settings.quotaGrants.ai[quotaTier]
|
||||||
|
if (!quota || typeof quota !== 'number') {
|
||||||
|
throw new Error(`Quota tier "${quotaTier}" is not initialized in settings`)
|
||||||
|
}
|
||||||
|
return Math.floor(quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AiFeatureUsageRateLimiter()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { UserFeatureUsage } from '../models/UserFeatureUsage.mjs'
|
import { UserFeatureUsage } from '../../models/UserFeatureUsage.mjs'
|
||||||
import { TooManyRequestsError } from '../Features/Errors/Errors.js'
|
import { TooManyRequestsError } from '../../Features/Errors/Errors.js'
|
||||||
|
|
||||||
const PERIOD = 24 // hours
|
const PERIOD = 24 // hours
|
||||||
const PERIOD_IN_MILLISECONDS = PERIOD * 60 * 60 * 1000
|
const PERIOD_IN_MILLISECONDS = PERIOD * 60 * 60 * 1000
|
||||||
@@ -166,9 +166,7 @@ export default class FeatureUsageRateLimiter {
|
|||||||
const pastUsageLimit = usage > allowance && refreshEpoch > Date.now()
|
const pastUsageLimit = usage > allowance && refreshEpoch > Date.now()
|
||||||
|
|
||||||
if (pastUsageLimit) {
|
if (pastUsageLimit) {
|
||||||
throw new TooManyRequestsError(
|
throw new TooManyRequestsError(`${this.featureName} rate limit exceeded`)
|
||||||
`${this.featureName} assistant rate limit exceeded`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { UserFeatureUsage } from '../../models/UserFeatureUsage.mjs'
|
||||||
|
import { TooManyRequestsError } from '../../Features/Errors/Errors.js'
|
||||||
|
import AnalyticsManager from '../../Features/Analytics/AnalyticsManager.mjs'
|
||||||
|
/** @typedef {{usage?: number | null, periodStart?: Date | null}} FeatureUsage */
|
||||||
|
/** @typedef {{remainingUsage: number, resetDate?: string}} RemainingUsage */
|
||||||
|
|
||||||
|
const PERIOD = 24 // hours
|
||||||
|
const PERIOD_IN_MILLISECONDS = PERIOD * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// todo: quota clean-up: extend this off base RateLimitController and unify behaviour where possible.
|
||||||
|
|
||||||
|
export default class TokenUsageRateLimiter {
|
||||||
|
/**
|
||||||
|
* @param {string} featureName
|
||||||
|
*/
|
||||||
|
constructor(featureName) {
|
||||||
|
this.featureName = featureName
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetFeatureUsagePipelineSection() {
|
||||||
|
return {
|
||||||
|
$set: {
|
||||||
|
features: {
|
||||||
|
[this.featureName]: {
|
||||||
|
$cond: {
|
||||||
|
if: {
|
||||||
|
$lte: [
|
||||||
|
{
|
||||||
|
$dateAdd: {
|
||||||
|
startDate: `$features.${this.featureName}.periodStart`,
|
||||||
|
unit: 'hour',
|
||||||
|
amount: PERIOD,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'$$NOW',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
then: {
|
||||||
|
usage: 0,
|
||||||
|
periodStart: '$$NOW',
|
||||||
|
},
|
||||||
|
else: `$features.${this.featureName}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} _userId
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
async _getAllowance(_userId) {
|
||||||
|
throw new Error('_getAllowance must be implemented by subclasses')
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordUsage(userId, res, amount) {
|
||||||
|
const allowance = await this._getAllowance(userId)
|
||||||
|
|
||||||
|
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
|
||||||
|
{ _id: userId },
|
||||||
|
[
|
||||||
|
this._resetFeatureUsagePipelineSection(),
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
features: {
|
||||||
|
[this.featureName]: {
|
||||||
|
usage: {
|
||||||
|
$add: [`$features.${this.featureName}.usage`, amount],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
new: true,
|
||||||
|
upsert: true,
|
||||||
|
}
|
||||||
|
).exec()
|
||||||
|
|
||||||
|
const featureUsage = featureUsages.features?.[this.featureName] ?? {}
|
||||||
|
this.setRateLimitHeaders(res, featureUsage, allowance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Promise<FeatureUsage>}
|
||||||
|
*/
|
||||||
|
async getCurrentUsage(userId) {
|
||||||
|
const reportedUsage = await UserFeatureUsage.findOne({ _id: userId }).exec()
|
||||||
|
const featureUsage = reportedUsage?.features?.[this.featureName] ?? {}
|
||||||
|
return {
|
||||||
|
usage: featureUsage.usage ?? 0,
|
||||||
|
periodStart: featureUsage.periodStart ?? new Date(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async checkUsage(userId, res) {
|
||||||
|
const allowance = await this._getAllowance(userId)
|
||||||
|
const currentUsage = await this.getCurrentUsage(userId)
|
||||||
|
const periodStart = currentUsage.periodStart ?? new Date()
|
||||||
|
if (periodStart.getTime() + PERIOD_IN_MILLISECONDS <= Date.now()) {
|
||||||
|
// Period has expired, so reset usage
|
||||||
|
currentUsage.usage = 0
|
||||||
|
currentUsage.periodStart = new Date()
|
||||||
|
}
|
||||||
|
this.setRateLimitHeaders(res, currentUsage, allowance)
|
||||||
|
if ((currentUsage.usage ?? 0) >= allowance) {
|
||||||
|
await AnalyticsManager.recordEventForUser(
|
||||||
|
userId,
|
||||||
|
'ai-token-usage-limit-exceeded'
|
||||||
|
)
|
||||||
|
|
||||||
|
throw new TooManyRequestsError({
|
||||||
|
message: `${this.featureName} rate limit exceeded`,
|
||||||
|
info: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @param {FeatureUsage} featureUsage
|
||||||
|
* @param {number} allowance
|
||||||
|
*/
|
||||||
|
setRateLimitHeaders(res, featureUsage, allowance) {
|
||||||
|
const periodStart = featureUsage.periodStart ?? new Date()
|
||||||
|
const usage = featureUsage.usage ?? 0
|
||||||
|
const refreshEpoch = periodStart.getTime() + PERIOD_IN_MILLISECONDS
|
||||||
|
const secondsTillReset = Math.ceil((refreshEpoch - Date.now()) / 1000)
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.set('RateLimit-Limit', allowance.toString())
|
||||||
|
res.set('RateLimit-Remaining', Math.max(0, allowance - usage).toString())
|
||||||
|
res.set('RateLimit-Reset', Math.max(0, secondsTillReset).toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a weighted token usage based on cost incurred for different token
|
||||||
|
* types.
|
||||||
|
*
|
||||||
|
* @param {import('ai').LanguageModelUsage} tokenUsage
|
||||||
|
* @return {number}
|
||||||
|
*/
|
||||||
|
calculateTokenUsage(tokenUsage) {
|
||||||
|
const {
|
||||||
|
outputTokens,
|
||||||
|
inputTokenDetails: { noCacheTokens, cacheReadTokens },
|
||||||
|
} = tokenUsage
|
||||||
|
|
||||||
|
return Math.ceil(
|
||||||
|
(noCacheTokens ?? 0) +
|
||||||
|
(outputTokens ?? 0) * 10 +
|
||||||
|
(cacheReadTokens ?? 0) * 0.1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// @ts-check
|
||||||
|
import SplitTestHandler from '../../Features/SplitTests/SplitTestHandler.mjs'
|
||||||
|
import UserGetter from '../../Features/User/UserGetter.mjs'
|
||||||
|
import TokenUsageRateLimiter from './TokenUsageRateLimiter.mjs'
|
||||||
|
/** @typedef {{usage?: number | null, periodStart?: Date | null}} FeatureUsage */
|
||||||
|
/** @typedef {{remainingUsage: number, resetDate?: string}} RemainingUsage */
|
||||||
|
|
||||||
|
const DEFAULT_USER_TOKEN_ALLOWANCE = 8_000_000
|
||||||
|
const ALPHA_USER_TOKEN_ALLOWANCE = 8_000_000
|
||||||
|
|
||||||
|
class WorkbenchRateLimiter extends TokenUsageRateLimiter {
|
||||||
|
constructor() {
|
||||||
|
super('aiWorkbench')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
async _getAllowance(userId) {
|
||||||
|
const splitTestAssignment =
|
||||||
|
await SplitTestHandler.promises.getAssignmentForUser(
|
||||||
|
userId,
|
||||||
|
'ai-workbench-release'
|
||||||
|
)
|
||||||
|
const inSplitTest = splitTestAssignment.variant === 'enabled'
|
||||||
|
if (!inSplitTest) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const user = await UserGetter.promises.getUser(userId, {
|
||||||
|
features: 1,
|
||||||
|
writefull: 1,
|
||||||
|
alphaProgram: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (user?.alphaProgram) {
|
||||||
|
return ALPHA_USER_TOKEN_ALLOWANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: quota clean-up: remove split test
|
||||||
|
let hasAddOn
|
||||||
|
const inQuotaSplitTest =
|
||||||
|
await SplitTestHandler.promises.featureFlagEnabledForUser(
|
||||||
|
userId,
|
||||||
|
'plans-2026-phase-1'
|
||||||
|
)
|
||||||
|
if (inQuotaSplitTest) {
|
||||||
|
// post rollout, all users have the same token limit (fair usage)
|
||||||
|
return DEFAULT_USER_TOKEN_ALLOWANCE
|
||||||
|
} else {
|
||||||
|
hasAddOn = user.features.aiErrorAssistant || user.writefull?.isPremium
|
||||||
|
return hasAddOn ? DEFAULT_USER_TOKEN_ALLOWANCE : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default new WorkbenchRateLimiter()
|
||||||
@@ -8,7 +8,7 @@ const Usage = new Schema({
|
|||||||
|
|
||||||
export const UserFeatureUsageSchema = new Schema({
|
export const UserFeatureUsageSchema = new Schema({
|
||||||
features: {
|
features: {
|
||||||
aiErrorAssistant: Usage,
|
aiFeatureUsage: Usage,
|
||||||
aiWorkbench: Usage,
|
aiWorkbench: Usage,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,15 +76,15 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
|||||||
const [hasPremiumSuggestion, setHasPremiumSuggestion] = useState<boolean>(
|
const [hasPremiumSuggestion, setHasPremiumSuggestion] = useState<boolean>(
|
||||||
() => {
|
() => {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
featureUsage?.aiErrorAssistant &&
|
featureUsage?.aiFeatureUsage &&
|
||||||
featureUsage?.aiErrorAssistant.remainingUsage > 0
|
featureUsage?.aiFeatureUsage.remainingUsage > 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const [premiumSuggestionResetDate, setPremiumSuggestionResetDate] =
|
const [premiumSuggestionResetDate, setPremiumSuggestionResetDate] =
|
||||||
useState<Date>(() => {
|
useState<Date>(() => {
|
||||||
return featureUsage?.aiErrorAssistant?.resetDate
|
return featureUsage?.aiFeatureUsage?.resetDate
|
||||||
? new Date(featureUsage.aiErrorAssistant.resetDate)
|
? new Date(featureUsage.aiFeatureUsage.resetDate)
|
||||||
: new Date()
|
: new Date()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ describe('ProjectController', function () {
|
|||||||
promises: { hooks: { fire: sinon.stub().resolves() } },
|
promises: { hooks: { fire: sinon.stub().resolves() } },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.AiFeatureUsageRateLimiter = {
|
||||||
|
getRemainingFeatureUses: sinon.stub().resolves({
|
||||||
|
aiFeatureUsage: { remainingUsage: 0 },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
vi.doMock('mongodb-legacy', () => ({
|
vi.doMock('mongodb-legacy', () => ({
|
||||||
default: { ObjectId },
|
default: { ObjectId },
|
||||||
}))
|
}))
|
||||||
@@ -485,6 +491,13 @@ describe('ProjectController', function () {
|
|||||||
default: ctx.Modules,
|
default: ctx.Modules,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.doMock(
|
||||||
|
'../../../../app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter',
|
||||||
|
() => ({
|
||||||
|
default: ctx.AiFeatureUsageRateLimiter,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
ctx.ProjectController = (await import(MODULE_PATH)).default
|
ctx.ProjectController = (await import(MODULE_PATH)).default
|
||||||
|
|
||||||
ctx.projectName = '£12321jkj9ujkljds'
|
ctx.projectName = '£12321jkj9ujkljds'
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { expect, vi } from 'vitest'
|
||||||
|
import sinon from 'sinon'
|
||||||
|
import mongodb from 'mongodb-legacy'
|
||||||
|
const ObjectId = mongodb.ObjectId
|
||||||
|
|
||||||
|
vi.mock('../../../../../app/src/Features/Errors/Errors.js', () => {
|
||||||
|
return vi.importActual('../../../../../app/src/Features/Errors/Errors.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
const modulePath =
|
||||||
|
'../../../../app/src/infrastructure/rate-limiters/AiFeatureUsageRateLimiter.mjs'
|
||||||
|
|
||||||
|
describe('AiFeatureUsageRateLimiter', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.userId = new ObjectId().toString()
|
||||||
|
|
||||||
|
ctx.UserFeatureUsageModel = {
|
||||||
|
findOneAndUpdate: sinon.stub().returns({
|
||||||
|
exec: sinon.stub().resolves({
|
||||||
|
features: {
|
||||||
|
aiFeatureUsage: {
|
||||||
|
usage: 0,
|
||||||
|
periodStart: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
findOne: sinon.stub().returns({
|
||||||
|
exec: sinon.stub().resolves({
|
||||||
|
features: {
|
||||||
|
aiFeatureUsage: {
|
||||||
|
usage: 0,
|
||||||
|
periodStart: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.user = {
|
||||||
|
features: { aiUsageQuota: 'basic' },
|
||||||
|
writefull: { isPremium: false },
|
||||||
|
}
|
||||||
|
ctx.userWithOLBundle = {
|
||||||
|
features: { aiUsageQuota: 'unlimited' },
|
||||||
|
writefull: { isPremium: false },
|
||||||
|
}
|
||||||
|
ctx.userWithOLBundleThroughWf = {
|
||||||
|
features: { aiUsageQuota: 'basic' },
|
||||||
|
writefull: { isPremium: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.UserGetter = {
|
||||||
|
promises: {
|
||||||
|
getUser: sinon.stub().resolves(ctx.user),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.settings = {
|
||||||
|
writefull: {
|
||||||
|
quotaTierGranted: 'unlimited',
|
||||||
|
},
|
||||||
|
aiFeatures: {
|
||||||
|
freeTrialQuota: 'basic',
|
||||||
|
unlimitedQuota: 'unlimited',
|
||||||
|
},
|
||||||
|
quotaGrants: {
|
||||||
|
ai: {
|
||||||
|
basic: 5,
|
||||||
|
unlimited: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SplitTestHandler = {
|
||||||
|
promises: {
|
||||||
|
featureFlagEnabledForUser: sinon.stub().resolves(true),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.doMock('@overleaf/settings', () => ({
|
||||||
|
default: ctx.settings,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('../../../../app/src/models/UserFeatureUsage', () => ({
|
||||||
|
UserFeatureUsage: ctx.UserFeatureUsageModel,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('../../../../app/src/Features/User/UserGetter.mjs', () => ({
|
||||||
|
default: ctx.UserGetter,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock(
|
||||||
|
'../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
|
||||||
|
() => ({
|
||||||
|
default: ctx.SplitTestHandler,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const module = await import(modulePath)
|
||||||
|
ctx.AiFeatureUsageRateLimiter = module.default
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useFeature', function () {
|
||||||
|
describe('with some remaining allowance left', function () {
|
||||||
|
it('should suceed', async function (ctx) {
|
||||||
|
const res = { set: () => null }
|
||||||
|
await expect(ctx.AiFeatureUsageRateLimiter.useFeature(ctx.userId, res))
|
||||||
|
.to.not.be.rejected
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with 0 allowance left', function () {
|
||||||
|
beforeEach(function (ctx) {
|
||||||
|
ctx.UserFeatureUsageModel.findOneAndUpdate = sinon.stub().returns({
|
||||||
|
exec: sinon.stub().resolves({
|
||||||
|
features: {
|
||||||
|
aiFeatureUsage: {
|
||||||
|
usage: ctx.settings.quotaGrants.ai.unlimited + 1,
|
||||||
|
periodStart: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be rejected with TooManyRequestsError', async function (ctx) {
|
||||||
|
const res = { set: () => null }
|
||||||
|
await expect(
|
||||||
|
ctx.AiFeatureUsageRateLimiter.useFeature(ctx.userId, res)
|
||||||
|
).to.be.rejectedWith('aiFeatureUsage rate limit exceeded')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRemainingFeatureUses', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.UserFeatureUsageModel.findOneAndUpdate = sinon.stub().returns({
|
||||||
|
exec: sinon.stub().resolves({
|
||||||
|
features: {
|
||||||
|
aiFeatureUsage: {
|
||||||
|
usage: 0,
|
||||||
|
periodStart: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
ctx.UserGetter.promises.getUser = sinon.stub()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should give higher usage for OL assist bundle owners', async function (ctx) {
|
||||||
|
ctx.UserGetter.promises.getUser = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves(ctx.userWithOLBundle)
|
||||||
|
const usages =
|
||||||
|
await ctx.AiFeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
||||||
|
await expect(usages.aiFeatureUsage.remainingUsage).to.equal(
|
||||||
|
ctx.settings.quotaGrants.ai.unlimited
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should give higher usage for assist bundle owners who have the feature via Writefull', async function (ctx) {
|
||||||
|
ctx.UserGetter.promises.getUser = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves(ctx.userWithOLBundleThroughWf)
|
||||||
|
const usages =
|
||||||
|
await ctx.AiFeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
||||||
|
await expect(usages.aiFeatureUsage.remainingUsage).to.equal(
|
||||||
|
ctx.settings.quotaGrants.ai.unlimited
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate remaining usages for free users', async function (ctx) {
|
||||||
|
ctx.UserGetter.promises.getUser = sinon.stub().resolves(ctx.user)
|
||||||
|
const usages =
|
||||||
|
await ctx.AiFeatureUsageRateLimiter.getRemainingFeatureUses(ctx.userId)
|
||||||
|
await expect(usages.aiFeatureUsage.remainingUsage).to.equal(
|
||||||
|
ctx.settings.quotaGrants.ai.basic
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,7 +19,7 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () => {
|
|||||||
const MOCKED_FEATURE_NAME = 'aiWorkbench'
|
const MOCKED_FEATURE_NAME = 'aiWorkbench'
|
||||||
|
|
||||||
const modulePath =
|
const modulePath =
|
||||||
'../../../../app/src/infrastructure/FeatureUsageRateLimiter.mjs'
|
'../../../../app/src/infrastructure/rate-limiters/FeatureUsageRateLimiter'
|
||||||
|
|
||||||
describe('FeatureUsageRateLimiter', function () {
|
describe('FeatureUsageRateLimiter', function () {
|
||||||
beforeAll(async function () {
|
beforeAll(async function () {
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import { beforeAll, beforeEach, describe, it, vi, expect } from 'vitest'
|
||||||
|
import sinon from 'sinon'
|
||||||
|
import mongodb from 'mongodb-legacy'
|
||||||
|
import {
|
||||||
|
cleanupTestDatabase,
|
||||||
|
db,
|
||||||
|
waitForDb,
|
||||||
|
} from '../../../../app/src/infrastructure/mongodb.mjs'
|
||||||
|
import { UserFeatureUsage } from '../../../../app/src/models/UserFeatureUsage.mjs'
|
||||||
|
|
||||||
|
const { ObjectId } = mongodb
|
||||||
|
|
||||||
|
const MODULE_PATH =
|
||||||
|
'../../../../app/src/infrastructure/rate-limiters/WorkbenchRateLimiter'
|
||||||
|
|
||||||
|
describe('WorkbenchRateLimiter', function () {
|
||||||
|
beforeAll(async function () {
|
||||||
|
await waitForDb()
|
||||||
|
})
|
||||||
|
beforeAll(cleanupTestDatabase)
|
||||||
|
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.alphaUserId = new ObjectId()
|
||||||
|
ctx.alphaUser = {
|
||||||
|
_id: ctx.alphaUserId,
|
||||||
|
alphaProgram: true,
|
||||||
|
features: {
|
||||||
|
aiUsageQuota: 'unlimited',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.userWithoutAiAddOnId = new ObjectId()
|
||||||
|
ctx.userWithAiAddOn = {
|
||||||
|
_id: ctx.userWithoutAiAddOnId,
|
||||||
|
features: {
|
||||||
|
aiUsageQuota: 'unlimited',
|
||||||
|
},
|
||||||
|
alphaProgram: false,
|
||||||
|
}
|
||||||
|
ctx.otherUserId = new ObjectId()
|
||||||
|
ctx.otherUser = {
|
||||||
|
_id: ctx.otherUserId,
|
||||||
|
features: {
|
||||||
|
aiUsageQuota: 'basic',
|
||||||
|
},
|
||||||
|
alphaProgram: false,
|
||||||
|
}
|
||||||
|
ctx.UserGetter = {
|
||||||
|
promises: {
|
||||||
|
getUser: sinon.stub(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.UserGetter.promises.getUser
|
||||||
|
.withArgs(ctx.alphaUserId)
|
||||||
|
.resolves(ctx.alphaUser)
|
||||||
|
ctx.UserGetter.promises.getUser
|
||||||
|
.withArgs(ctx.userWithoutAiAddOnId)
|
||||||
|
.resolves(ctx.userWithAiAddOn)
|
||||||
|
ctx.UserGetter.promises.getUser
|
||||||
|
.withArgs(ctx.otherUserId)
|
||||||
|
.resolves(ctx.otherUser)
|
||||||
|
|
||||||
|
ctx.SplitTestHandler = {
|
||||||
|
promises: {
|
||||||
|
getAssignmentForUser: sinon.stub(),
|
||||||
|
featureFlagEnabledForUser: sinon.stub().resolves(true),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.SplitTestHandler.promises.getAssignmentForUser
|
||||||
|
.withArgs(ctx.alphaUserId, 'ai-workbench-release')
|
||||||
|
.resolves({ variant: 'enabled' })
|
||||||
|
|
||||||
|
vi.doMock('../../../../app/src/infrastructure/mongodb', () => ({
|
||||||
|
ObjectId,
|
||||||
|
db,
|
||||||
|
waitForDb,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
|
||||||
|
default: ctx.UserGetter,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.doMock(
|
||||||
|
'../../../../app/src/Features/SplitTests/SplitTestHandler',
|
||||||
|
() => ({
|
||||||
|
default: ctx.SplitTestHandler,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
vi.doMock(
|
||||||
|
'../../../../app/src/Features/Analytics/AnalyticsManager',
|
||||||
|
() => ({
|
||||||
|
default: {
|
||||||
|
recordEventForUser: sinon.stub(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.WorkbenchRateLimiter = (await import(MODULE_PATH)).default
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateTokenUsage', function () {
|
||||||
|
it('treats input tokens as 1', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.WorkbenchRateLimiter.calculateTokenUsage({
|
||||||
|
inputTokenDetails: {
|
||||||
|
noCacheTokens: 100,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
},
|
||||||
|
outputTokens: 0,
|
||||||
|
})
|
||||||
|
).to.equal(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats output tokens as 10', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.WorkbenchRateLimiter.calculateTokenUsage({
|
||||||
|
inputTokenDetails: {
|
||||||
|
noCacheTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
},
|
||||||
|
outputTokens: 100,
|
||||||
|
})
|
||||||
|
).to.equal(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats output tokens correctly', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.WorkbenchRateLimiter.calculateTokenUsage({
|
||||||
|
inputTokenDetails: {
|
||||||
|
noCacheTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
},
|
||||||
|
outputTokens: 100,
|
||||||
|
})
|
||||||
|
).to.equal(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds up to nearest integer', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.WorkbenchRateLimiter.calculateTokenUsage({
|
||||||
|
inputTokenDetails: {
|
||||||
|
noCacheTokens: 1,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
},
|
||||||
|
outputTokens: 0,
|
||||||
|
})
|
||||||
|
).to.equal(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums mixed tokens', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.WorkbenchRateLimiter.calculateTokenUsage({
|
||||||
|
inputTokenDetails: {
|
||||||
|
noCacheTokens: 10,
|
||||||
|
cacheReadTokens: 10,
|
||||||
|
},
|
||||||
|
outputTokens: 10,
|
||||||
|
})
|
||||||
|
).to.equal(10 + 100 + 0 + 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('checkUsage', function () {
|
||||||
|
describe('with no data', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
ctx.res = {
|
||||||
|
set: sinon.stub(),
|
||||||
|
headersSent: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw', async function (ctx) {
|
||||||
|
await expect(
|
||||||
|
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
).to.eventually.be.fulfilled
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets rate limit headers', async function (ctx) {
|
||||||
|
await ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Limit',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Remaining',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
// We can't mock the mongo date, so just check that something was set
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Reset',
|
||||||
|
matchRateLimit(24 * 60 * 60)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with existing usage', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
ctx.res = {
|
||||||
|
set: sinon.stub(),
|
||||||
|
headersSent: false,
|
||||||
|
}
|
||||||
|
const usageRecord = new UserFeatureUsage({
|
||||||
|
_id: ctx.alphaUserId,
|
||||||
|
features: {
|
||||||
|
aiWorkbench: {
|
||||||
|
usage: 2000000,
|
||||||
|
periodStart: new Date(new Date().getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await usageRecord.save()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw if under limit', async function (ctx) {
|
||||||
|
await expect(
|
||||||
|
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
).to.eventually.be.fulfilled
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets rate limit headers', async function (ctx) {
|
||||||
|
await ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Limit',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Remaining',
|
||||||
|
'6000000'
|
||||||
|
)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Reset',
|
||||||
|
matchRateLimit(23 * 60 * 60)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if over limit', async function (ctx) {
|
||||||
|
const usageRecord = await UserFeatureUsage.findById(
|
||||||
|
ctx.alphaUserId
|
||||||
|
).exec()
|
||||||
|
usageRecord.features.aiWorkbench.usage = 9000000
|
||||||
|
await usageRecord.save()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
).to.eventually.be.rejectedWith(/rate limit exceeded/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with an expired old usage period', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
ctx.res = {
|
||||||
|
set: sinon.stub(),
|
||||||
|
headersSent: false,
|
||||||
|
}
|
||||||
|
const usageRecord = new UserFeatureUsage({
|
||||||
|
_id: ctx.alphaUserId,
|
||||||
|
features: {
|
||||||
|
aiWorkbench: {
|
||||||
|
usage: 2000000,
|
||||||
|
periodStart: new Date(new Date().getTime() - 25 * 60 * 60 * 1000), // 25 hours ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await usageRecord.save()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw', async function (ctx) {
|
||||||
|
await expect(
|
||||||
|
ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
).to.eventually.be.fulfilled
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets rate limit headers', async function (ctx) {
|
||||||
|
await ctx.WorkbenchRateLimiter.checkUsage(ctx.alphaUserId, ctx.res)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Limit',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Remaining',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
// A new period
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Reset',
|
||||||
|
matchRateLimit(24 * 60 * 60)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recordUsage', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
ctx.res = {
|
||||||
|
set: sinon.stub(),
|
||||||
|
headersSent: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('without existing usage', function () {
|
||||||
|
it('creates new usage record if none exists', async function (ctx) {
|
||||||
|
await ctx.WorkbenchRateLimiter.recordUsage(
|
||||||
|
ctx.alphaUserId,
|
||||||
|
ctx.res,
|
||||||
|
1500000
|
||||||
|
)
|
||||||
|
const usageRecord = await UserFeatureUsage.findById(
|
||||||
|
ctx.alphaUserId
|
||||||
|
).exec()
|
||||||
|
expect(usageRecord).to.exist
|
||||||
|
expect(usageRecord.features.aiWorkbench.usage).to.equal(1500000)
|
||||||
|
expect(
|
||||||
|
usageRecord.features.aiWorkbench.periodStart.getTime()
|
||||||
|
).to.approximately(new Date().getTime(), 60_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with existing usage', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
const usageRecord = new UserFeatureUsage({
|
||||||
|
_id: ctx.alphaUserId,
|
||||||
|
features: {
|
||||||
|
aiWorkbench: {
|
||||||
|
usage: 2000000,
|
||||||
|
periodStart: new Date(new Date().getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await usageRecord.save()
|
||||||
|
await ctx.WorkbenchRateLimiter.recordUsage(
|
||||||
|
ctx.alphaUserId,
|
||||||
|
ctx.res,
|
||||||
|
1000000
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates existing usage record', async function (ctx) {
|
||||||
|
const updatedRecord = await UserFeatureUsage.findById(
|
||||||
|
ctx.alphaUserId
|
||||||
|
).exec()
|
||||||
|
expect(updatedRecord.features.aiWorkbench.usage).to.equal(3000000)
|
||||||
|
})
|
||||||
|
it('sets rate limit headers', async function (ctx) {
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Limit',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Remaining',
|
||||||
|
'5000000'
|
||||||
|
)
|
||||||
|
// Keeps the original period start time
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Reset',
|
||||||
|
matchRateLimit(23 * 60 * 60)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with an expired old usage period', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
await UserFeatureUsage.deleteMany({}).exec()
|
||||||
|
const usageRecord = new UserFeatureUsage({
|
||||||
|
_id: ctx.alphaUserId,
|
||||||
|
features: {
|
||||||
|
aiWorkbench: {
|
||||||
|
usage: 2000000,
|
||||||
|
periodStart: new Date(new Date().getTime() - 25 * 60 * 60 * 1000), // 25 hours ago
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await usageRecord.save()
|
||||||
|
await ctx.WorkbenchRateLimiter.recordUsage(
|
||||||
|
ctx.alphaUserId,
|
||||||
|
ctx.res,
|
||||||
|
1000000
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets usage and period start', async function (ctx) {
|
||||||
|
const updatedRecord = await UserFeatureUsage.findById(
|
||||||
|
ctx.alphaUserId
|
||||||
|
).exec()
|
||||||
|
expect(updatedRecord.features.aiWorkbench.usage).to.equal(1000000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets rate limit headers', async function (ctx) {
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Limit',
|
||||||
|
'8000000'
|
||||||
|
)
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Remaining',
|
||||||
|
'7000000'
|
||||||
|
)
|
||||||
|
// New period start time
|
||||||
|
expect(ctx.res.set).to.have.been.calledWith(
|
||||||
|
'RateLimit-Reset',
|
||||||
|
matchRateLimit(24 * 60 * 60)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function matchRateLimit(expectedValue, delta = 60) {
|
||||||
|
return sinon.match(function (value) {
|
||||||
|
const number = parseInt(value, 10)
|
||||||
|
return Math.abs(number - expectedValue) <= delta
|
||||||
|
}, `${expectedValue} ± ${delta}`)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user