Closed thiagoolsilva closed 2 years ago
@thiagoolsilva Hi, could you provide your code?
Also, could you describe, what would you like to achieve with the new RateLimiterS3
class?
Hi @animir thanks for your reply. I'd like to create a new rateLimiter using the same idea of RateLimiterRedis saving data to AWS S3 bucket. Is it possible to do it?
@thiagoolsilva This is interesting idea. How would you implement atomic upsert
for incrementing counter there on S3?
Could you provide the code you already developed to get that error message?
Hi @animir I don't have enough code implementation to share with you. I only created a random class and extended the class RateLimiterStoreAbstract to get the error reported. I've observed in the documentation that I must implement the following methods, but in a typescript context, it did not appear to me.
_getRateLimiterRes parses raw data from store to RateLimiterRes object. _upsert must be atomic. it inserts or updates value by key and returns raw data. it must support forceExpire mode to overwrite key expiration time. _get returns raw data by key or null if there is no key. _delete deletes all key related data and returns true on deleted, false if key is not found.
@thiagoolsilva Hi, typescript definitions may be not complete enough.
You found the list of required methods. Do you have an idea how would you implement those for S3?
@thiagoolsilva Hey, I am closing this, feel free to re-open, if you are going to work on this issue or you have any questions.
Hi, I'm trying to do something similar, i.e. extend RateLimiterStoreAbstract
so that it can rate limit using Cloudflare durable objects. The code for my class is below.
export class CloudflareDurableObjectsRateLimiterStore extends RateLimiterStoreAbstract {
protected _doClient: CloudflareDurableObjectsRateLimiterClient
protected _getGraphQlOperation = /(?<=\s)(\w+)(?=\s|\()/g
constructor(opts: IRateLimiterStoreOptions & { ctx: AppContext }) {
super(opts)
// We don't have the GraphQl schema info yet, as we are pre-parse,
// so just extract either the query or mutation name directly from the context
const match = this._getGraphQlOperation.exec(opts.ctx.params?.query ?? "")
const key = CloudflareDurableObjectsRateLimiterStore.keyGenerator(null, null, null, opts.ctx, { fieldName: match?.[1] ?? "generic" })
const id = opts.ctx.RATE_LIMITER.idFromName(key)
this._doClient = opts.ctx.RATE_LIMITER.get(id) as unknown as CloudflareDurableObjectsRateLimiterClient
}
static keyGenerator(directiveArgs: any, source: any, args: any, context: any, info: any): string {
let key = `site.com-${context.jwt?.sub ?? context.clientIp ?? 'noid'}-${info.fieldName}`
console.log(key)
return key
}
/**
* Have to be implemented
* Resolve with object used for {@link _getRateLimiterRes} to generate {@link RateLimiterRes}
*
* @param {string} rlKey
* @param {number} points
* @param {number} msDuration
* @param {boolean} forceExpire
* @param {Object} options
* @abstract
*
* @return Promise<Object>
*/
async _upsert(rlKey: any, points: any, msDuration: any, forceExpire = false, options = {}) {
await this._doClient.upsert<RateLimitStorage>(rlKey, { points: points, expiry: nowUnixMs() + msDuration })
}
/**
* Have to be implemented in every limiter
* Resolve with raw result from Store OR null if rlKey is not set
* or Reject with error
*
* @param rlKey
* @param {Object} options
* @private
*
* @return Promise<any>
*/
async _get(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
let result = await this._doClient.get<RateLimitStorage>(rlKey)
if (!result) {
return null
}
return result.points
}
/**
* Have to be implemented
* Resolve with true OR false if rlKey doesn't exist
* or Reject with error
*
* @param rlKey
* @param {Object} options
* @private
*
* @return Promise<any>
*/
async _delete(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
return await this._doClient.delete(rlKey)
}
}
And within the context of using GraphQl, this is how I try to create the class in my initial testing, using the graphql-rate-limit-directive
package. Note that storeClient
is passed as an empty object, but this is OK as it's actually created in the constructor of CloudflareDurableObjectsRateLimiterStore
.
const { rateLimitDirectiveTransformer } = rateLimitDirective({
keyGenerator: (directiveArgs, source, args, context, info) => {
return CloudflareDurableObjectsRateLimiterStore.keyGenerator(directiveArgs, source, args, context as AppContext, info)
},
limiterClass: CloudflareDurableObjectsRateLimiterStore,
limiterOptions: { storeClient: {}, ctx }
})
return rateLimitDirectiveTransformer(this._builder.toSchema({}))
At runtime, I simply get back:
Uncaught TypeError: Class extends value undefined is not a constructor or null
I know creating the CloudflareDurableObjectsRateLimiterStore
is the issue, as if I replace it with the RateLimiterMemory
class, all is good. It just doesn't seem to be able to find / import the RateLimiterStoreAbstract
class at runtime. If I change my CloudflareDurableObjectsRateLimiterStore
not to extend the RateLimiterStoreAbstract
class, it starts working. Is there anything I should be looking at in terms of bundling / transpiling?
I also checked using dpdm
that I don't have any circular references in my imports.
@armstrong-pv Hi, do you import RateLimiterStoreAbstract
class?
In Redis limiter it is imported like const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract');
It is a little confusing as there is another definition of RateLimiterStoreAbstract
in https://github.com/animir/node-rate-limiter-flexible/blob/683e37f414d7fe2ee0762de0ccec3119b027f140/lib/index.d.ts#L207
Wonder if TypeScript relies on definition and not on implementation.
Thanks for replying. I asked a much wiser head than me when it comes to the typescript ecosystem, and got it working with this approach. There are still some quirks but I can live with them. At compile time, it can't find any extended variables / methods from RateLimiterStoreAbstract
, but I only needed to refer to this.points
import _RateLimiterStoreAbstract from "rate-limiter-flexible/lib/RateLimiterStoreAbstract"
const RateLimiterStoreAbstract = _RateLimiterStoreAbstract
export class CloudflareDurableObjectsRateLimiterStore extends RateLimiterStoreAbstract {
....
}
For anyone who is interested in the full rate limiter, here it is. Durable objects can be quite expensive, so it uses the Cloudflare caching API once the limit for a key has been reached. Therefore you only get charged for non-rate limited DO operations. Shout out to: @OUltimoCoder / https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate/blob/main/src/durable-objects/rateLimiter.do.ts for figuring out the hard stuff. It's working for GraphQl integration currently, but can be easily adapted,
import { TimeIntervalType, addInterval, nowUnixMs } from './date-time'
import { Environment } from '../../bindings'
import { AppContext } from '../types/context.types'
import { IRateLimiterStoreOptions, RateLimiterRes } from 'rate-limiter-flexible'
// @ts-ignore
import _RateLimiterStoreAbstract from "rate-limiter-flexible/lib/RateLimiterStoreAbstract"
const RateLimiterStoreAbstract = _RateLimiterStoreAbstract
const domainAddress = 'http://yourdomainname.com'
type RateLimitStorage = {
points: number
expiry: number
}
export class CloudflareDurableObjectsRateLimiterClient {
constructor(private _state: DurableObjectState, private _env: Environment['Bindings']) {
}
async fetch(request: Request): Promise<Response> {
// Always schedule a cleanup
this.scheduleCleanup()
const url = new URL(request.url)
const keyName = url.searchParams.get('key')
let response = null
if (request.method === 'POST') {
response = await this.upsert(
keyName ?? 'generic',
await request.json(),
parseInt(url.searchParams.get('total') ?? '1000000'),
url.searchParams.get('forceExpire') === 'true'
)
} else if (request.method === 'GET') {
response = await this.get(keyName)
} else if (request.method === 'DELETE') {
await this.delete(keyName)
} else {
return new Response(null, { status: 400 })
}
return new Response(response ? JSON.stringify(response) : null, { status: 200 })
}
async upsert(key: string, value: RateLimitStorage, totalPoints: number, forceExpire = false) {
let currentLimiter = await this.get(key)
let newLimiter: RateLimitStorage & { msBeforeNext?: number }
if (!currentLimiter || currentLimiter.expiry <= nowUnixMs() || forceExpire) {
newLimiter = value
} else {
newLimiter = {
points: currentLimiter.points + value.points,
expiry: value.expiry
}
}
if (newLimiter.points <= totalPoints) {
await this._state.storage.put(key, newLimiter)
} else {
// If we've exceeded the total points, no point in incurring the write storage cost
newLimiter.expiry = currentLimiter?.expiry ?? newLimiter.expiry
newLimiter.msBeforeNext = newLimiter.expiry - nowUnixMs()
}
return newLimiter
}
async get(key: any): Promise<RateLimitStorage | null> {
let result = await this._state.storage.get(key)
if (!result) {
return null
}
return result as RateLimitStorage
}
async list() {
return await this._state.storage.list()
}
async delete(rlKey: any) {
return await this._state.storage.delete(rlKey)
}
async setAlarm(interval: TimeIntervalType, duration: number) {
const alarm = await this._state.storage.getAlarm()
if (!alarm) {
this._state.storage.setAlarm(addInterval(new Date(), interval, duration))
}
}
async alarm() {
await this.performCleanup()
}
async scheduleCleanup() {
await this.setAlarm(TimeIntervalType.minute, 15)
}
async performCleanup() {
const values = await this._state.storage.list<RateLimitStorage>()
for await (const [key, value] of values) {
if (value.expiry <= nowUnixMs()) {
await this.delete(key)
}
}
this.setAlarm(TimeIntervalType.minute, 15)
}
}
export class CloudflareDurableObjectsRateLimiterStore extends RateLimiterStoreAbstract {
protected _getGraphQlOperation = /(?<=\s)(\w+)(?=\s|\()/g
protected _context: AppContext
protected _execContext: ExecutionContext
protected _graphQlOperation: string
// @ts-ignore The points property is there, but not discovered correctly at compile time
private _maxPointsForKey = this.points
constructor(opts: IRateLimiterStoreOptions & { ctx: AppContext, execContext: ExecutionContext }) {
super(opts)
// We don't have the GraphQl schema info yet, as we are pre-parse,
// so just extract either the query or mutation name directly from the context
const match = this._getGraphQlOperation.exec(opts.ctx.params?.query ?? "")
this._graphQlOperation = match?.[1] ?? 'no-op'
this._context = opts.ctx
this._execContext = opts.execContext
}
private getRateLimitClient(): DurableObjectStub {
const key = CloudflareDurableObjectsRateLimiterStore.keyGenerator(null, null, null, this._context, { fieldName: this._graphQlOperation })
const id = this._context.RATE_LIMITER.idFromName(key)
return this._context.RATE_LIMITER.get(id)
}
static keyGenerator(directiveArgs: any, source: any, args: any, context: any, info: any): string {
let key = `${domainAddress}/${context.jwt?.sub ?? context.clientIp ?? 'noid'}/${info.fieldName}`
//console.log(key)
return key
}
async _upsert(rlKey: any, points: any, msDuration: any, forceExpire = false, options = {}) {
const cache = await caches.open('rate-limiter')
// Seems to be added automatically by the rate limiter package and makes the key invalid
// as a caching URL
rlKey = rlKey.replace('rateLimit:', '')
const cached = await cache.match(rlKey)
// Always try the cache first. If we retrieve it, it avoids all of the DO
// costs until the next rate limit interval
if (cached) {
let res = await cached.json<RateLimitStorage>()
if (res.expiry > nowUnixMs()) {
return { points: this._maxPointsForKey + 1, expiry: res.expiry, msBeforeNext: res.expiry - nowUnixMs() }
} else {
this._context
// Only wait for cache deletion to complete after the client has received response
this._execContext.waitUntil(cache.delete(rlKey))
}
}
const client = this.getRateLimitClient()
const result = await client.fetch(`${domainAddress}?key=${rlKey}&total=${this.points}&forceExpire=${forceExpire}`, { method: 'POST', body: JSON.stringify({ points: points, expiry: nowUnixMs() + msDuration }) });
const rateLimitRecord = await result.json<RateLimitStorage>()
if (rateLimitRecord.points >= this._maxPointsForKey && !cached) {
const remainingSeconds = Math.floor((rateLimitRecord.expiry - nowUnixMs()) / 1000)
this._execContext.waitUntil(
// Only wait for cache put to complete after the client has received response
cache.put(rlKey, new Response(JSON.stringify(rateLimitRecord), {
headers: {
'expires': `${new Date(rateLimitRecord.expiry).toUTCString()}`,
'cache-control': `public, max-age=${remainingSeconds}, s-maxage=${remainingSeconds}, must-revalidate`
}
}))
)
}
return rateLimitRecord
}
async _get(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
const client = this.getRateLimitClient()
let result = await client.fetch(`${domainAddress}?key=${rlKey}`, { method: 'GET' });
if (!result) {
return null
}
return (await result.json() as RateLimitStorage).points
}
async _delete(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
const client = this.getRateLimitClient()
const result = await client.fetch(`${domainAddress}?key=${rlKey}`, { method: 'DELETE' });
return result.status === 200
}
_getRateLimiterRes(rlKey: string, changedPoints: number, result: any) { // eslint-disable-line no-unused-vars
const consumedPoints = parseInt(result.points)
const isFirstInDuration = result.points === changedPoints
const remainingPoints = Math.max(this._maxPointsForKey - consumedPoints, 0)
const msBeforeNext = result.msBeforeNext
const res = new RateLimiterRes(remainingPoints, msBeforeNext, consumedPoints, isFirstInDuration)
return res
}
}
Hi, I'm trying to create a new class that extends the RateLimiterStoreAbstract, but after extending it I got the below error. What I am doing wrong? Could you give me a typescript sample to implement it or provide a RateLimiterS3?
Class extends value undefined is not a constructor or null