Files
overleaf-cep/services/web/app/src/infrastructure/FeatureUsageRateLimiter.mjs
T
Jimmy Domagala-Tang f96d37b7e3 Revert "Revert "[Web] Enable Quota System for AI Features"" (#31771)
* Revert "Revert "[Web] Enable Quota System for AI Features (#31544)" (#31767)"

This reverts commit f6589bdbf0ac7e71313739e3e3f4fb5bedd48c22.

* Update writefull entitlement syncing for commons (#31774)

* feat: update wf entitlement syncing logic

* feat: remove unused env variable, and parse quota tier as string for analytics

GitOrigin-RevId: 37ae7522a249501719a64327e2b6aaff01a3cc0b
2026-03-06 09:07:15 +00:00

193 lines
5.3 KiB
JavaScript

// @ts-check
import { UserFeatureUsage } from '../models/UserFeatureUsage.mjs'
import { TooManyRequestsError } from '../Features/Errors/Errors.js'
const PERIOD = 24 // hours
const PERIOD_IN_MILLISECONDS = PERIOD * 60 * 60 * 1000
/**
* @class FeatureUsageRateLimiter
* @abstract
* @description A rate limiter for features based on usage data stored in the
* UserFeatureUsage collection.
*/
export default class FeatureUsageRateLimiter {
/**
* @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
* @param {import('express').Response} res
*/
async useFeature(userId, res) {
const allowance = await this._getAllowance(userId)
const featureUsages = await UserFeatureUsage.findOneAndUpdate(
{ _id: userId },
[
this._resetFeatureUsagePipelineSection(),
{
$set: {
features: {
[this.featureName]: {
usage: {
$cond: {
if: {
$lte: [`$features.${this.featureName}.usage`, allowance],
},
then: {
$add: [`$features.${this.featureName}.usage`, 1],
},
else: `$features.${this.featureName}.usage`,
},
},
},
},
},
},
],
{
new: true,
upsert: true,
}
).exec()
const featureUsage = featureUsages.features?.[this.featureName] ?? {}
setRateLimitHeaders(res, featureUsage, allowance)
this._checkRateLimit(featureUsage, allowance)
}
/**
*
* @param {string} userId
* @param {import('express').Response} res
*/
async decrementFeatureUsage(userId, res) {
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`, -1],
},
},
},
],
{
new: true,
upsert: true,
}
).exec()
const featureUsage = featureUsages.features?.[this.featureName] ?? {}
setRateLimitHeaders(res, featureUsage, allowance)
}
/**
* @param {string} userId
* @returns {Promise<{[featureName: string]: { remainingUsage: number, resetDate?: string}}>}
*/
async getRemainingFeatureUses(userId) {
if (!userId) {
return { [this.featureName]: { remainingUsage: 0 } }
}
const allowance = await this._getAllowance(userId)
const reportedUsage = await UserFeatureUsage.findOne({ _id: userId }).exec()
const featureUsage = reportedUsage?.features?.[this.featureName] ?? {}
const periodStart = featureUsage.periodStart ?? new Date()
const usage = featureUsage.usage ?? 0
const usesLeft = allowance - usage
const refreshEpoch = periodStart.getTime() + PERIOD_IN_MILLISECONDS
return {
[this.featureName]: {
remainingUsage: Date.now() > refreshEpoch ? allowance : usesLeft,
resetDate: new Date(refreshEpoch).toString(),
},
}
}
/**
*
* @param {string} _userId
* @returns {Promise<number>}
*/
async _getAllowance(_userId) {
throw new Error('_getAllowance must be implemented by subclasses')
}
/**
*
* @param {{periodStart?: Date, usage?: number}} featureUsage
* @param {number} allowance
*/
_checkRateLimit(featureUsage, allowance) {
const periodStart = featureUsage.periodStart ?? new Date()
const usage = featureUsage.usage ?? 0
const refreshEpoch = periodStart.getTime() + PERIOD_IN_MILLISECONDS
const pastUsageLimit = usage > allowance && refreshEpoch > Date.now()
if (pastUsageLimit) {
throw new TooManyRequestsError(
`${this.featureName} assistant rate limit exceeded`
)
}
}
}
/**
*
* @param {import('express').Response} res
* @param {{periodStart?: Date, usage?: number}} featureUsage
* @param {number} allowance
*/
function 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', String(allowance))
res.set('RateLimit-Remaining', String(Math.max(0, allowance - usage)))
res.set('RateLimit-Reset', String(Math.max(0, secondsTillReset)))
}
}