Skip to content
GitHub

How a call runs

MeoCord 4.2 · The request pipeline · page 24 of 41 · since 4.1.0

The stages every handler call passes through, in the order they run, and why each one sits where it does.

You'll learn

  • Name the stages of a call and the order they run in
  • Tell which stages apply to which kinds of handler
  • Declare a stage globally, on a controller or on one handler

Every handler MeoCord runs, whether a command, a component, an autocomplete, a message, a reaction or a gateway event, goes through the same stages in the same order. Each stage has one job, and its place in the order is what makes that job cheap and safe: a denied call fetches nothing, and bad input never uses up a cooldown.

This page is the map. The pages after it take one stage each.

When to use it

Read this before you write a stage of your own, to pick the one that fits:

You want toUse
Decide whether a call may run at alla guard
Check or convert the handler's inputvalidation and pipes
Act before and after the handler, such as timing itan interceptor
Limit how often a handler runsa cooldown
Answer an error your own wayan exception filter
Record every call and how it ended, for metrics or auditsan observer

Example

This slash command has a guard, an interceptor and a pipe, and each of them records when it runs:

controllers/slash/stages.slash.controller.ts
@Command('stages', CommandType.SLASH)
@Defer()
@UseGuard(RecordingGuard)
@UseInterceptor(RecordingInterceptor)
@UsePipe('text', RecordingPipe)
async run(interaction: ChatInputCommandInteraction, { text }: { text: string }) {
  stages.push('handler')
  await respond(interaction).send({ content: text })
}
Dispatches /stages text:' hello '

The test runs it the way the bot does and reads the order back:

controllers/slash/stages.slash.controller.spec.ts
describe('the stages of a call', () => {
  it('run in order: @Defer, guards, interceptors around the pipes and the handler', async () => {
    const module = MeoCordTestingModule.create({ controllers: [StagesSlashController] }).compile()
    const interaction = createMockInteraction(ChatInputCommandInteraction)
    interaction.options = createChatInputOptions({ text: '  hello  ' })

    await module.invoke(StagesSlashController, 'run', interaction)

    expect(stages).toEqual(['guard, deferred: true', 'interceptor, before', 'pipe', 'handler', 'interceptor, after'])
  })
})

The guard runs after @Defer has acknowledged the interaction, the interceptor wraps the pipe and the handler, and the handler runs last.

How it works

A call runs these stages, top to bottom. Each links to the page that teaches it, and a stage drawn around others wraps them. Pick a kind of handler to see only the stages it runs:

Show the stages for
  1. Observers: onStart @Observer

    Hear about the call before anything runs.

  2. Exception filters @UseFilter

    Catch an error from any stage below, or from the handler.

    1. @Defer: acknowledge @Defer

      Acknowledge the interaction, so slow stages never miss Discord's three seconds.

    2. Parse

      Read a message command's words into typed params.

    3. Guards @UseGuard

      Decide whether the handler runs: global first, then the controller, then the method.

    4. Cooldown check @Cooldown

      Check the cooldowns without counting the call, when the params name anything Discord must fetch.

    5. Fetch

      Get what the params name that Discord must fetch: members, users, roles, channels, or an app type’s ref.

    6. Interceptors @UseInterceptor

      Act before and after everything below.

      1. Validation @Validate

        Check the handler's input against a schema.

      2. Pipes @UsePipe

        Turn the valid values into what the handler works with.

      3. Cooldowns @Cooldown

        Count the call, last, so a refused call or bad input never uses one up.

      4. @Defer: lock @Defer

        Lock a component's message, only once the call will run.

      5. The handler

        Runs with what the stages produced.

    7. The built-in fallback

      Answer or log what no filter handled, as the kind of handler allows: pick one to see how.

      Answer a refusal, a cooldown or a user's mistake privately, in its own words or MeoCord's, and anything else as a generic error.

      Log the error and close the menu.

      Reply with the command's usage, or why a guard or validation refused it, and to a user's mistake; skip a cooldown; log anything else.

      Reply to a user's mistake; skip a refusal or a cooldown; log anything else.

      Reply to a user's mistake where there's a message to reply to, and log anything else.

  3. Observers: onSettled @Observer

    Hear how the call ended, and how long it took.

Each stage sits where it does for a reason:

  1. @Defer acknowledges first, so slow stages never miss Discord's three seconds.
  2. Parse reads a message command's words into typed params, with no request to Discord. It comes before the guards so they can read the params. A word of the wrong type ends the call with the command's usage, which observers see as 'invalid'.
  3. Guards decide whether the handler runs at all. They come before anything that costs a request or counts a call, so a caller they refuse costs nothing.
  4. The cooldown check runs only when a message command's params still need fetching. It checks the handler's cooldowns without counting the call, so a caller on cooldown costs no request.
  5. Fetch gets from Discord what the message names and discord.js doesn't hold yet. Each ID goes out once, however many calls ask for it at the same time.
  6. Interceptors wrap everything after them. They can act before and after the handler, skip it, or replace its error. Validation runs inside them, so an interceptor sees invalid input as the handler's error.
  7. Validation checks the handler's input against a schema, and pipes turn the valid values into what the handler works with.
  8. Cooldowns count the call last, so a denied call or bad input never uses one up.
  9. @Defer locks a component's message only now, so a denied or invalid call never touches it.
  10. The handler runs with what the stages produced.

Exception filters surround all of it. An error from any stage, or from the handler, reaches them, and one no filter handles goes to the built-in fallback, which answers the user. The handler, its filters and the fallback all answer through respond(), so each sees where the others left the answer.

Observers frame the whole call. onStart hears about it before @Defer and the guards, and onSettled once it has settled and been answered, with how it ended and how long it took. The call waits for neither.

Which handlers take which stages

Not every stage applies to every handler:

  • Parse, the cooldown check and the fetch apply only to message handlers with a pattern.
  • Validation and pipes apply to command, component and modal handlers, and to message handlers with a pattern. The bot refuses to start with @Validate or @UsePipe on any other handler.
  • Cooldowns apply to interaction and message handlers. @Cooldown on an autocomplete, reaction or event handler stops the bot at startup.
  • @Defer applies to command, component and modal handlers only.
  • Autocomplete runs its guards and filters, and no interceptors: it must answer within three seconds and has no reply to shape.
  • Guards, interceptors and filters run for reaction and event handlers too. A global guard or interceptor that reads an interaction declares types: ['interaction'], so it skips the rest.

Where stages are declared

Guards, interceptors and filters apply at three levels:

  1. globally, in @MeoCord({ guards, interceptors, filters });
  2. on a controller, for every handler it declares or inherits;
  3. on one handler method.

Guards and interceptors run in that order, global first. Filters are tried the other way round: the method's first, then the controller's, then the global ones. Cooldowns go on a controller or a method.

A controller's stages also apply to every class that extends it, and a base's wrap what extends it, as global stages wrap controllers. Guards and interceptors run the top base class's first, then each subclass's in turn, then the method's; filters are tried the other way round, the method's first, then the subclass's, then each base's, so a subclass's own filter comes before a catch-all on its base. Class cooldowns count from the base class down. @Controller({ inheritStages: false }) keeps the handlers a subclass declares to its own stages; the ones it inherits keep their base's.

MeoCord resolves this chain once per handler, so dispatch pays nothing for it. inspectHandler lists what a handler ends up with, in the order it runs.

A subclass's routes

A subclass answers every route its bases declare, for the handlers it inherits. One it re-decorates on the same route takes its own options there. One it re-decorates on another route answers that route as well as the inherited one, and the bot names each such handler in a warning as it starts. Give the subclass @Controller({ inheritedRoutes: 'replace' }), and a handler it re-decorates answers only the routes it declares for it: the inherited ones are dropped, of every kind, from commands and component patterns to message patterns, reactions and autocompletes. A slash or context menu command that only they answered is not registered. A method the subclass overrides without decorators keeps every route it inherits either way.

inspectHandler's inheritedRoutes lists the routes a handler answers because a base declares them, so a test can pin what a subclass still answers.

The call's context

Every stage can read the call through ExecutionContext: the interaction or message, the controller and handler, the handler's params and the metadata on them. A guard injects it through its constructor; an interceptor, a filter, a pipe and an observer receive it as an argument.

getType() says what is being handled: 'interaction', 'autocomplete', 'message', 'reaction' or 'event'. A guard, interceptor or observer declared with types runs only for those.

getHandlerParams() and getArgs() follow the stages. A guard sees the params raw; an interceptor sees them raw before next.handle() and validated and piped after it; a filter sees them as they stood when the error was thrown.

Gotchas

  • A direct call runs only the guards. Calling a controller method yourself runs its guards, and no interceptors, validation, pipes, cooldowns or filters. Run it with the testing module to get every stage.
  • A global stage runs for events too. A global guard that reads interaction.user.id throws on a messageCreate handler; declare types: ['interaction'].
  • @Defer on a message, reaction, event or autocomplete handler stops the bot at startup, since there is nothing to acknowledge.

Next steps