RateLimiter.mjs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. /* eslint-disable @overleaf/require-script-runner */
  2. // This file contains helper functions used by other scripts.
  3. // The scripts that import these helpers should use Script Runner.
  4. import { setTimeout } from 'node:timers/promises'
  5. export const DEFAULT_RECURLY_RATE_LIMIT = 10
  6. export const DEFAULT_STRIPE_RATE_LIMIT = 50
  7. export const DEFAULT_RECURLY_API_RETRIES = 5
  8. export const DEFAULT_RECURLY_RETRY_DELAY_MS = 1000
  9. export const DEFAULT_STRIPE_API_RETRIES = 5
  10. export const DEFAULT_STRIPE_RETRY_DELAY_MS = 1000
  11. /**
  12. * Rate limiter using sliding window algorithm.
  13. *
  14. * Rate limits (conservative targets, leaving headroom):
  15. * - Recurly: 2000 requests per 5 minutes → target 1500/5min = 300/min = 5/sec
  16. * https://support.recurly.com/hc/en-us/articles/360034160731-What-Are-Recurly-s-API-Rate-Limits
  17. * - Stripe: 100 requests per second → target 50/sec (plenty of headroom)
  18. * https://docs.stripe.com/rate-limits
  19. *
  20. * Recurly is the bottleneck. With 2 Recurly calls per customer (getAccount, getBillingInfo),
  21. * we can process ~2.5 customers/second = ~150 customers/minute = ~9000 customers/hour.
  22. * For 150K customers, expect ~17 hours at full throughput.
  23. */
  24. class RateLimiter {
  25. /**
  26. * @param {string} name - Name for logging
  27. * @param {number} maxRequests - Maximum requests allowed in the window
  28. * @param {number} windowMs - Window size in milliseconds
  29. * @param {Function} logDebug - Optional debug logging function
  30. * @param {Function} logWarn - Optional warning logging function
  31. */
  32. constructor(
  33. name,
  34. maxRequests,
  35. windowMs,
  36. logDebug = () => null,
  37. logWarn = () => null
  38. ) {
  39. this.name = name
  40. this.maxRequests = maxRequests
  41. this.windowMs = windowMs
  42. this.requests = [] // timestamps of recent requests
  43. this.totalRequests = 0
  44. this._pending = Promise.resolve()
  45. this.logDebug = logDebug
  46. this.logWarn = logWarn
  47. }
  48. /**
  49. * Wait if necessary to stay within rate limits, then record the request.
  50. */
  51. async throttle() {
  52. this._pending = this._pending
  53. .catch(error => {
  54. // this should never happen since setTimeout or logDebug are very unlikely to ever fail
  55. // but if it does, we log it and continue without blocking the queue (fail-open)
  56. this.logWarn(`Rate limiter chain error for ${this.name}`, {
  57. error: error?.message || String(error),
  58. })
  59. })
  60. .then(async () => {
  61. while (true) {
  62. const now = Date.now()
  63. // Remove requests outside the window
  64. const windowStart = now - this.windowMs
  65. this.requests = this.requests.filter(ts => ts > windowStart)
  66. // If at limit, wait until the oldest request exits the window
  67. if (this.requests.length >= this.maxRequests) {
  68. const oldestRequest = this.requests[0]
  69. const waitTime = oldestRequest - windowStart + 1
  70. if (waitTime > 0) {
  71. this.logDebug(
  72. `Rate limit throttle for ${this.name}`,
  73. {
  74. waitMs: waitTime,
  75. currentRequests: this.requests.length,
  76. maxRequests: this.maxRequests,
  77. },
  78. { verboseOnly: true }
  79. )
  80. await setTimeout(waitTime)
  81. continue
  82. }
  83. }
  84. // Record this request
  85. this.requests.push(Date.now())
  86. this.totalRequests++
  87. break
  88. }
  89. })
  90. return this._pending
  91. }
  92. /**
  93. * Get current rate (requests per second over the last window)
  94. */
  95. getCurrentRate() {
  96. const now = Date.now()
  97. const windowStart = now - this.windowMs
  98. const recentRequests = this.requests.filter(ts => ts > windowStart).length
  99. return (recentRequests / this.windowMs) * 1000 // requests per second
  100. }
  101. getStats() {
  102. return {
  103. name: this.name,
  104. totalRequests: this.totalRequests,
  105. currentWindowRequests: this.requests.length,
  106. maxRequests: this.maxRequests,
  107. currentRate: this.getCurrentRate().toFixed(2) + '/sec',
  108. }
  109. }
  110. }
  111. /**
  112. * Helper to extract Stripe rate limit reason from error headers
  113. */
  114. function getStripeRateLimitReason(error) {
  115. const headers =
  116. error?.headers || error?.raw?.headers || error?.response?.headers || {}
  117. return (
  118. headers['stripe-rate-limit-reason'] ||
  119. headers['Stripe-Rate-Limited-Reason'] ||
  120. headers['stripe-rate-limited-reason'] ||
  121. null
  122. )
  123. }
  124. /**
  125. * Create rate-limited API wrapper with unified service routing.
  126. *
  127. * @param {object} config - Configuration options
  128. * @param {number} config.recurlyRateLimit - Requests per second for Recurly (default: 10)
  129. * @param {number} config.recurlyApiRetries - Number of retries on Recurly 429s (default: 5)
  130. * @param {number} config.recurlyRetryDelayMs - Delay between Recurly retries in ms (default: 1000)
  131. * @param {number} config.stripeRateLimit - Requests per second for Stripe (default: 50)
  132. * @param {number} config.stripeApiRetries - Number of retries on Stripe 429s (default: 5)
  133. * @param {number} config.stripeRetryDelayMs - Delay between Stripe retries in ms (default: 1000)
  134. * @param {Function} config.logDebug - Optional debug logging function
  135. * @param {Function} config.logWarn - Optional warning logging function
  136. *
  137. * @returns {object} Object with unified call function and stats getter
  138. * @returns {Function} returns.call - Unified wrapper for API calls (service, operation, context)
  139. * @returns {Function} returns.getRateLimiterStats - Get current rate limiter statistics
  140. */
  141. export function createRateLimitedApiWrappers(config = {}) {
  142. const {
  143. recurlyRateLimit = 10,
  144. recurlyApiRetries = 5,
  145. recurlyRetryDelayMs = 1000,
  146. stripeRateLimit = 50,
  147. stripeApiRetries = 5,
  148. stripeRetryDelayMs = 1000,
  149. logDebug = () => null,
  150. logWarn = () => null,
  151. } = config
  152. const RATE_LIMIT_WINDOW_MS = 1000
  153. // Service configuration registry
  154. const serviceConfigs = {
  155. recurly: {
  156. rateLimit: recurlyRateLimit,
  157. apiRetries: recurlyApiRetries,
  158. retryDelayMs: recurlyRetryDelayMs,
  159. isStripe: false,
  160. },
  161. stripe: {
  162. rateLimit: stripeRateLimit,
  163. apiRetries: stripeApiRetries,
  164. retryDelayMs: stripeRetryDelayMs,
  165. isStripe: true,
  166. },
  167. }
  168. // Rate limiter instances per service
  169. const rateLimiters = new Map()
  170. function getRateLimiter(service) {
  171. const key = String(service || 'unknown').toLowerCase()
  172. if (rateLimiters.has(key)) {
  173. return rateLimiters.get(key)
  174. }
  175. // Determine service config
  176. let serviceConfig
  177. if (key === 'recurly') {
  178. serviceConfig = serviceConfigs.recurly
  179. } else if (key.startsWith('stripe')) {
  180. serviceConfig = serviceConfigs.stripe
  181. } else {
  182. throw new Error(`Unknown service: ${service}`)
  183. }
  184. const limiter = new RateLimiter(
  185. key,
  186. serviceConfig.rateLimit,
  187. RATE_LIMIT_WINDOW_MS,
  188. logDebug,
  189. logWarn
  190. )
  191. rateLimiters.set(key, limiter)
  192. return limiter
  193. }
  194. function getServiceConfig(service) {
  195. const key = String(service || 'unknown').toLowerCase()
  196. if (key === 'recurly') {
  197. return serviceConfigs.recurly
  198. } else if (key.startsWith('stripe')) {
  199. return serviceConfigs.stripe
  200. } else {
  201. throw new Error(`Unknown service: ${service}`)
  202. }
  203. }
  204. async function requestWithRetries(service, operation, { context } = {}) {
  205. const serviceConfig = getServiceConfig(service)
  206. const rateLimiter = getRateLimiter(service)
  207. let attempt = 0
  208. while (true) {
  209. try {
  210. await rateLimiter.throttle()
  211. return await operation()
  212. } catch (error) {
  213. const statusCode =
  214. error?.statusCode ?? error?.status ?? error?.raw?.statusCode
  215. if (statusCode === 429) {
  216. attempt++
  217. if (attempt > serviceConfig.apiRetries) {
  218. logWarn(
  219. `${service} rate limit exceeded after ${attempt - 1} retries`,
  220. {
  221. ...context,
  222. service,
  223. attempt,
  224. ...(serviceConfig.isStripe
  225. ? { rateLimitReason: getStripeRateLimitReason(error) }
  226. : {}),
  227. }
  228. )
  229. throw error
  230. }
  231. logDebug(`${service} rate limited, retrying`, {
  232. ...context,
  233. service,
  234. attempt,
  235. retryDelayMs: serviceConfig.retryDelayMs,
  236. ...(serviceConfig.isStripe
  237. ? { rateLimitReason: getStripeRateLimitReason(error) }
  238. : {}),
  239. })
  240. await setTimeout(serviceConfig.retryDelayMs)
  241. continue
  242. }
  243. throw error
  244. }
  245. }
  246. }
  247. /**
  248. * Get rate limiter statistics for logging
  249. */
  250. function getRateLimiterStats() {
  251. const allLimiters = [...rateLimiters.values()]
  252. // Separate Recurly and Stripe limiters
  253. const recurlyLimiters = allLimiters.filter(
  254. limiter => limiter.name === 'recurly'
  255. )
  256. const stripeLimiters = allLimiters.filter(limiter =>
  257. limiter.name.startsWith('stripe')
  258. )
  259. const stripeTotalRequests = stripeLimiters.reduce(
  260. (sum, limiter) => sum + limiter.totalRequests,
  261. 0
  262. )
  263. const stripeCurrentRate = stripeLimiters.reduce(
  264. (sum, limiter) => sum + limiter.getCurrentRate(),
  265. 0
  266. )
  267. return {
  268. recurly:
  269. recurlyLimiters.length > 0
  270. ? recurlyLimiters[0].getStats()
  271. : { totalRequests: 0, currentRate: '0.00/sec' },
  272. stripe: {
  273. totalRequests: stripeTotalRequests,
  274. currentRate: stripeCurrentRate.toFixed(2) + '/sec',
  275. },
  276. stripeByRegion: stripeLimiters.map(limiter => limiter.getStats()),
  277. }
  278. }
  279. return {
  280. requestWithRetries,
  281. getRateLimiterStats,
  282. }
  283. }