@foundatiofx/fetchclient
Version:
A typed JSON fetch client with middleware support for Deno, Node and the browser.
116 lines (115 loc) • 4.99 kB
JavaScript
import { ProblemDetails } from "./ProblemDetails.js";
import { buildRateLimitHeader, buildRateLimitPolicyHeader, RateLimiter, } from "./RateLimiter.js";
/**
* Rate limiting error thrown when requests exceed the rate limit.
*/
export class RateLimitError extends Error {
resetTime;
remainingRequests;
constructor(resetTime, remainingRequests, message) {
super(message ||
`Rate limit exceeded. Try again after ${new Date(resetTime).toISOString()}`);
this.name = "RateLimitError";
this.resetTime = resetTime;
this.remainingRequests = remainingRequests;
}
}
/**
* Rate limiting middleware instance that can be shared across requests.
*/
export class RateLimitMiddleware {
#rateLimiter;
throwOnRateLimit;
errorMessage;
autoUpdateFromHeaders;
constructor(options) {
this.#rateLimiter = new RateLimiter(options);
this.throwOnRateLimit = options.throwOnRateLimit ?? true;
this.errorMessage = options.errorMessage;
this.autoUpdateFromHeaders = options.autoUpdateFromHeaders ?? true;
}
/**
* Gets the underlying rate limiter instance.
*/
get rateLimiter() {
return this.#rateLimiter;
}
/**
* Creates the middleware function.
* @returns The middleware function
*/
middleware() {
return async (context, next) => {
const url = context.request.url;
// Check if request is allowed
if (!this.rateLimiter.isAllowed(url)) {
const group = this.rateLimiter.getGroup(url);
const resetTime = this.rateLimiter.getResetTime(url) ?? Date.now();
const remainingRequests = this.rateLimiter.getRemainingRequests(url);
if (this.throwOnRateLimit) {
throw new RateLimitError(resetTime, remainingRequests, this.errorMessage);
}
// Create a 429 Too Many Requests response
const groupOptions = this.rateLimiter.getGroupOptions(group);
const maxRequests = groupOptions.maxRequests;
const windowSeconds = groupOptions.windowSeconds;
// Create IETF standard rate limit headers
const resetSeconds = Math.ceil((resetTime - Date.now()) / 1000);
const rateLimitHeader = buildRateLimitHeader({
policy: group,
remaining: remainingRequests,
resetSeconds: resetSeconds,
});
const rateLimitPolicyHeader = buildRateLimitPolicyHeader({
policy: group,
limit: maxRequests,
windowSeconds: Math.floor(windowSeconds),
});
const headers = new Headers({
"Content-Type": "application/problem+json",
"RateLimit": rateLimitHeader,
"RateLimit-Policy": rateLimitPolicyHeader,
// Legacy headers for backward compatibility
"RateLimit-Limit": maxRequests.toString(),
"RateLimit-Remaining": remainingRequests.toString(),
"RateLimit-Reset": Math.ceil(resetTime / 1000).toString(),
"Retry-After": resetSeconds.toString(),
});
const problem = new ProblemDetails();
problem.status = 429;
problem.title = "Too Many Requests";
problem.detail = this.errorMessage ||
`Rate limit exceeded. Try again after ${new Date(resetTime).toISOString()}`;
context.response = {
url: context.request.url,
status: 429,
statusText: "Too Many Requests",
body: null,
bodyUsed: true,
ok: false,
headers: headers,
redirected: false,
type: "basic",
problem: problem,
data: null,
meta: { links: {} },
json: () => Promise.resolve(problem),
text: () => Promise.resolve(JSON.stringify(problem)),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
// @ts-ignore: New in Deno 1.44
bytes: () => Promise.resolve(new Uint8Array()),
blob: () => Promise.resolve(new Blob()),
formData: () => Promise.resolve(new FormData()),
clone: () => {
throw new Error("Not implemented");
},
};
return;
}
await next();
if (this.autoUpdateFromHeaders && context.response) {
this.rateLimiter.updateFromHeadersForRequest(url, context.response.headers);
}
};
}
}