4f95c9de4e
* 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
171 lines
5.4 KiB
TypeScript
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>;
|