Files
triggerdotdev--trigger.dev/apps/webapp/app/services/apiRateLimit.server.ts
Eric Allam 4f95c9de4e v3: Cancel awaited subtasks and reliable rate-limit recovery (#1200)
* v3: cancel subtasks when parent task runs are cancelled

* v3: recover from server rate limiting errors in a more reliable way

- Changing from sliding window to token bucket in the API rate limiter, to help smooth out traffic
- Adding spans to the API Client core & SDK functions
- Added waiting spans when retrying in the API Client
- Retrying in the API Client now respects the x-ratelimit-reset
- Retrying ApiError’s in tasks now respects the x-ratelimit-reset
- Added AbortTaskRunError that when thrown will stop retries
- Added idempotency keys SDK functions and automatically injecting the run ID when inside a task
- Added the ability to configure ApiRequestOptions (retries only for now) globally and on specific calls
- Implement the maxAttempts TaskRunOption (it wasn’t doing anything before)

* Adding some docs about the request options

* Fix type error

* Remove context propagation through graphile jobs

* Remove logger

* only select a subset of task run columns

* limit columns selected in batchTrigger as well

* added idempotency doc

* allow scoped idempotency keys, and fixed an issue with the unique index on BatchTaskRun and TaskRun

* Removed old cancel task run children code
2024-07-05 10:30:52 +01:00

171 lines
5.4 KiB
TypeScript

import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { RedisOptions } from "ioredis";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { Duration, Limiter, RateLimiter, createRedisRateLimitClient } from "./rateLimiter.server";
type Options = {
redis?: RedisOptions;
keyPrefix: string;
pathMatchers: (RegExp | string)[];
pathWhiteList?: (RegExp | string)[];
limiter: Limiter;
log?: {
requests?: boolean;
rejections?: boolean;
};
};
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
export function authorizationRateLimitMiddleware({
redis,
keyPrefix,
limiter,
pathMatchers,
pathWhiteList = [],
log = {
rejections: true,
requests: true,
},
}: Options) {
const rateLimiter = new RateLimiter({
redis,
keyPrefix,
limiter,
logSuccess: log.requests,
logFailure: log.rejections,
});
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
}
// allow OPTIONS requests
if (req.method.toUpperCase() === "OPTIONS") {
return next();
}
//first check if any of the pathMatchers match the request path
const path = req.path;
if (
!pathMatchers.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
}
return next();
}
// Check if the path matches any of the whitelisted paths
if (
pathWhiteList.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
}
return next();
}
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
}
const authorizationValue = req.headers.authorization;
if (!authorizationValue) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
}
res.setHeader("Content-Type", "application/problem+json");
return res.status(401).send(
JSON.stringify(
{
title: "Unauthorized",
status: 401,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
detail: "No authorization header provided",
error: "No authorization header provided",
},
null,
2
)
);
}
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedAuthorizationValue = hash.digest("hex");
const { success, pending, limit, reset, remaining } = await rateLimiter.limit(
hashedAuthorizationValue
);
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
res.set("x-ratelimit-limit", limit.toString());
res.set("x-ratelimit-remaining", $remaining.toString());
res.set("x-ratelimit-reset", reset.toString());
if (success) {
return next();
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
remaining,
secondsUntilReset,
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
)
);
};
}
export const apiRateLimiter = authorizationRateLimitMiddleware({
keyPrefix: "api",
limiter: Ratelimit.tokenBucket(
env.API_RATE_LIMIT_REFILL_RATE,
env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
env.API_RATE_LIMIT_MAX
),
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
"/api/internal/stripe_webhooks",
"/api/v1/authorization-code",
"/api/v1/token",
"/api/v1/usage/ingest",
/^\/api\/v1\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/tasks/$id/callback/$secret
/^\/api\/v1\/runs\/[^\/]+\/tasks\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/runs/$runId/tasks/$id/callback/$secret
/^\/api\/v1\/http-endpoints\/[^\/]+\/env\/[^\/]+\/[^\/]+$/, // /api/v1/http-endpoints/$httpEndpointId/env/$envType/$shortcode
/^\/api\/v1\/sources\/http\/[^\/]+$/, // /api/v1/sources/http/$id
/^\/api\/v1\/endpoints\/[^\/]+\/[^\/]+\/index\/[^\/]+$/, // /api/v1/endpoints/$environmentId/$endpointSlug/index/$indexHookIdentifier
"/api/v1/timezones",
"/api/v1/usage/ingest",
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
},
});
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;