Reusing Durable Objects

I have a DO that I need multiple instances of, initialised with different options - is there any way of doing this, or will I be forced to duplicate the DO class and hard code defaults in each?

e.g.

class MyNumberDO {
  // Is there a way to pass in options, as I'd be able to if it were a normal class?
  constructor(state, env, number = 0) {
    this.number = number;
  }

  async fetch() { ... }
}

// Is there some way of essentially doing the below in my worker when creating a DO stub?
const five = new MyNumberDO(5);
const ten = new MyNumberDO(10);

Hi @lee.ellam,

You communicate with a DO instance by calling the fetch() method on the stub. This method works like global fetch() with the difference being, that every request is sent to the DO instance instead of the host specified in the URL. For example, you could pass options using URL params:

export default {
    async fetch(req, env) {
        const id = env.MyNumberDO.newUniqueId()
        const stub = env.MyNumberDO.get(id)
        return stub.fetch(new Request('dotp://mynumberdo/?number=5'))
    }
}


class MyNumberDO {
    constructor(state, env) {
        this.state = state
        this.env = env
    }

    async fetch(req) {
        if (!this.initialized) {
            const url = new URL(req.url)
            this.number = Number(url.searchParams.get('number'))
            this.initialized = true
        }
    }
}

Depending on your application it might be better to pass options as a JSON body, but that’s up to you.

I hope this helps - feel free to ask if you have any questions! You’re also more than welcome to join the Cloudflare Workers Community Discord Server :slightly_smiling_face:

Thanks for the reply. Maybe my initial example was overly simplistic. What I have is a DO to manage rate limits, but I would like one instance to manage API key limits, and one instance to manage per-user limits.

My assumption was that the best way to accomplish this would be in the worker, have one DO stub with an id of user IP, and one DO stub with an id of API key. I guess I could utilise the ID within the DO to decide what the limit/window options should be if there’s no way of explicitly passing options to the constructor.

I guess my follow-up question now is, would many small DO stubs be better than a single one that manages multiple limits via fetch API? Is there any performance hit with either solution (many tiny key/value stores vs one large one)?

1 Like

I would recommend you have a instance for each user and each API token. That way the DO would live close to the client and result in significantly better performance.

DOs are evicted from memory after not receiving any requests for a while. This means the first request will take an extra ~300ms (from my tests) - subsequent requests should be quite fast. This could be avoided by using a single DO instance for all users, but that would result in every request being slow for users located far from the DO.

Below is a quick proof-of-concept showing how you could use a single DO class to rate-limit:

  • multiple things
  • each with different limits
  • and multiple users
// IP limit is 20 request every 60 seconds
const IP_LIMITS = '?limit=20&period=60'
// Token limit is 10 request every 60 seconds
const TOKEN_LIMITS = '?limit=10&period=60'
// This way two different tokens can make requests from same IP without issues

export default {
    async fetch(req, env) {
        // Create a unique "key" from the IP and Token
        const ipKey = 'ip:' + req.headers.get('cf-connecting-ip')
        const tokenKey = 'token:' + req.headers.get('authorization')

        // Simultaneously ratelimit IP and Token
        const results = await Promise.all([
            rateLimit(env, ipKey, IP_LIMITS),
            rateLimit(env, tokenKey, TOKEN_LIMITS)
        ])

        // Check each result
        for (let result of results) {
            if (result.status === 429) {
                // Return 429 if over the limit
                return new Response(JSON.stringify({
                    'message': 'Whoah, slow down!'
                }), {status: 429, headers: {'content-type': 'application/json'}})
            }
        }

        // Otherwise return a list of timestamps
        return new Response(JSON.stringify({
            'ip': await results[0].json(),
            'token': await results[1].json()
        }), {headers: {'content-type': 'application/json'}})
    }
}

async function rateLimit(env, key, limits) {
    // The key is used as the DO instance name
    const id = env.SLIDING_LOG.idFromName(key)
    const stub = env.SLIDING_LOG.get(id)
    return await stub.fetch(new Request('http://ratelimit/' + limits))
}

export class SlidingLog {
    constructor(state) {
        this.state = state
        this.cache = {}
    }

    // These are just caching the value so that you'll only be billed a read for each "good" request
    async get(key) {
        let value = this.cache[key]
        if (value === undefined) {
            value = await this.state.storage.get(key)
            this.cache[key] = value
        }
        return value
    }

    async put(key, value) {
        this.cache[key] = value
        await this.state.storage.put(key, value)
    }

    async fetch(req) {
        const url = new URL(req.url)
        
        // Get limits from URL params
        const limit = Number(url.searchParams.get('limit'))
        const period = Number(url.searchParams.get('period'))

        const requests = await this.get('requests') || []
        
        // Difference is in milliseconds so we divide it by 1000
        while ((Date.now() - requests[0]) / 1000 > period) {
            console.log(Date.now() - requests[0])
            // If request was made more than `period` ago, remove it from the list
            requests.shift()
        }

        // Check if client has made more than the allowed number of requests in the period
        if (requests.length >= limit) {
            return new Response(null, {status: 429, headers: {'content-type': 'application/json'}})
        }

        // Add current timestamp to requests
        requests.push(Date.now())

        // Write changes to persistent storage
        this.put('requests', requests)

        // Return list of timestamps (only relevant while developing)
        return new Response(JSON.stringify(requests), {headers: {'content-type': 'application/json'}})
    }
}
1 Like

This was what I meant in my original question, worded poorly (worker creates a stub with IP as an id, and a stub with API key as an id).

I was following the Counter example from the docs, which uses the constructor to set up the internal state, when really I need to be doing it in the fetch. The rateLimit method and passing the options in the request are the missing link - thank you!

2 Likes

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.