From 80881eeba389e577963a20eb085e36e5cb344ed3 Mon Sep 17 00:00:00 2001 From: Thomas Bonnin <233326+TBonnin@users.noreply.github.com> Date: Wed, 15 Jan 2025 14:25:09 -0500 Subject: [PATCH] fix(render): add throttling so fleet never reaches render api limit Render has some rate limit in place regarding the creation of new service. Current limits are 1000 per hours and recommends to stay around 20 creations per minute. This commit adds a throttling mechanism to the Render node provider --- package-lock.json | 11 +--- packages/jobs/lib/runner/render.ts | 68 ++++++++++++++++++++++++- packages/jobs/package.json | 2 + packages/utils/lib/environment/parse.ts | 2 + 4 files changed, 72 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index c49cca5ae10..b024aa656df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35286,15 +35286,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "packages/cli/node_modules/ms": { - "version": "3.0.0-canary.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.1.tgz", - "integrity": "sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==", - "license": "MIT", - "engines": { - "node": ">=12.13" - } - }, "packages/cli/node_modules/strip-ansi": { "version": "7.1.0", "dev": true, @@ -35660,6 +35651,8 @@ "express": "4.20.0", "get-port": "7.1.0", "node-cron": "3.0.3", + "rate-limiter-flexible": "5.0.3", + "redis": "4.6.13", "zod": "3.24.1" }, "devDependencies": { diff --git a/packages/jobs/lib/runner/render.ts b/packages/jobs/lib/runner/render.ts index ff6052563ea..16b3db264a1 100644 --- a/packages/jobs/lib/runner/render.ts +++ b/packages/jobs/lib/runner/render.ts @@ -1,11 +1,16 @@ import type { Node, NodeProvider } from '@nangohq/fleet'; import type { Result } from '@nangohq/utils'; -import { Err, Ok } from '@nangohq/utils'; +import { Err, Ok, getLogger } from '@nangohq/utils'; import { RenderAPI } from './render.api.js'; import { envs } from '../env.js'; -import { getPersistAPIUrl, getProvidersUrl } from '@nangohq/shared'; +import { getPersistAPIUrl, getProvidersUrl, getRedisUrl } from '@nangohq/shared'; import type { AxiosResponse } from 'axios'; import { isAxiosError } from 'axios'; +import type { RateLimiterAbstract } from 'rate-limiter-flexible'; +import { RateLimiterRedis, RateLimiterMemory } from 'rate-limiter-flexible'; +import { createClient } from 'redis'; + +const logger = getLogger('Render'); const render: RenderAPI = new RenderAPI(envs.RENDER_API_KEY || ''); @@ -106,6 +111,13 @@ function serviceName(node: Node) { const rateLimitResetTimestamps = new Map(); async function withRateLimitHandling(rateLimitGroup: 'create' | 'delete' | 'resume' | 'get', fn: () => Promise): Promise> { + if (rateLimitGroup === 'create') { + const throttled = await serviceCreationThrottler.consume(); + if (throttled.isErr()) { + return Err(new Error(`Throttling Render service creation`, { cause: throttled.error })); + } + } + const rateLimitReset = rateLimitResetTimestamps.get(rateLimitGroup); if (rateLimitReset && rateLimitReset > new Date()) { return Err(`Render rate limit exceeded. Resetting at ${rateLimitReset.toISOString()}`); @@ -137,3 +149,55 @@ function getPlan(node: Node): 'starter' | 'standard' | 'pro' { } return 'starter'; } + +// Render has a hard limit of 1000 service creations per hour +// and also recommends to limit ourselves to 20 per minute +// We are throttling to 50 per minute max (to allow for some burst) +// as well as 600 per hour to always keep some buffer +class CombinedThrottler { + private throttlers: RateLimiterAbstract[]; + private clientId = 'render-service-creation'; + + constructor(throttlers: RateLimiterAbstract[]) { + this.throttlers = throttlers; + } + + async consume(points: number = 1): Promise> { + try { + const res = await Promise.all(this.throttlers.map((throttler) => throttler.consume(this.clientId, points))); + logger.info(`Not throttled`, res); + return Ok(undefined); + } catch (err) { + return Err(new Error('Rate limit exceeded', { cause: err })); + } + } +} + +const serviceCreationThrottler = await (async () => { + const minuteThrottlerOpts = { + keyPrefix: 'minute', + points: envs.RENDER_SERVICE_CREATION_MAX_PER_MINUTE || 50, + duration: 60, + blockDuration: 0 + }; + const hourThrottlerOpts = { + keyPrefix: 'hour', + points: envs.RENDER_SERVICE_CREATION_MAX_PER_HOUR || 600, + duration: 3600, + blockDuration: 0 + }; + const url = getRedisUrl(); + if (url) { + const redisClient = await createClient({ url: url, disableOfflineQueue: true }).connect(); + redisClient.on('error', (err) => { + logger.error(`Redis (rate-limiter) error: ${err}`); + }); + if (redisClient) { + return new CombinedThrottler([ + new RateLimiterRedis({ storeClient: redisClient, ...minuteThrottlerOpts }), + new RateLimiterRedis({ storeClient: redisClient, ...hourThrottlerOpts }) + ]); + } + } + return new CombinedThrottler([new RateLimiterMemory(minuteThrottlerOpts), new RateLimiterMemory(hourThrottlerOpts)]); +})(); diff --git a/packages/jobs/package.json b/packages/jobs/package.json index 7230ef18421..d40be956f22 100644 --- a/packages/jobs/package.json +++ b/packages/jobs/package.json @@ -32,6 +32,8 @@ "get-port": "7.1.0", "express": "4.20.0", "node-cron": "3.0.3", + "rate-limiter-flexible": "5.0.3", + "redis": "4.6.13", "zod": "3.24.1" }, "devDependencies": { diff --git a/packages/utils/lib/environment/parse.ts b/packages/utils/lib/environment/parse.ts index c2b15e5b7bf..c4f5702baaa 100644 --- a/packages/utils/lib/environment/parse.ts +++ b/packages/utils/lib/environment/parse.ts @@ -159,6 +159,8 @@ export const ENVS = z.object({ // Render RENDER_API_KEY: z.string().optional(), + RENDER_SERVICE_CREATION_MAX_PER_MINUTE: z.coerce.number().optional(), + RENDER_SERVICE_CREATION_MAX_PER_HOUR: z.coerce.number().optional(), IS_RENDER: bool, // Sentry