Skip to content

Commit

Permalink
fix(render): add throttling so fleet never reaches render api limit
Browse files Browse the repository at this point in the history
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
  • Loading branch information
TBonnin committed Jan 15, 2025
1 parent 6087cdf commit 80881ee
Show file tree
Hide file tree
Showing 4 changed files with 72 additions and 11 deletions.
11 changes: 2 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 66 additions & 2 deletions packages/jobs/lib/runner/render.ts
Original file line number Diff line number Diff line change
@@ -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 || '');

Expand Down Expand Up @@ -106,6 +111,13 @@ function serviceName(node: Node) {
const rateLimitResetTimestamps = new Map<string, Date>();

async function withRateLimitHandling<T>(rateLimitGroup: 'create' | 'delete' | 'resume' | 'get', fn: () => Promise<AxiosResponse>): Promise<Result<T>> {
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()}`);
Expand Down Expand Up @@ -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<Result<void>> {
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)]);
})();
2 changes: 2 additions & 0 deletions packages/jobs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions packages/utils/lib/environment/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 80881ee

Please sign in to comment.