Skip to content
GitHub

Exception filters and UserError

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

Tell the user about their own mistakes with UserError, and answer any other error your own way with an exception filter.

You'll learn

  • Refuse a call the user can fix with UserError
  • Answer an error your own way with an exception filter
  • Know what the built-in fallback tells the user, and when

Errors in a call come in two kinds. Some are the user's own mistake, such as too few coins, and the user should be told what to change. Others are faults in the bot, which the user can do nothing about.

For the first kind, throw UserError: MeoCord shows its message to the user who made the call, privately for an interaction and as a reply to a message, including the message an event such as messageCreate carries; from a reaction or an event without a message it is only logged. For anything else, the built-in fallback answers with a generic message and logs the error. When you want another answer, an exception filter catches the error first and answers it your own way.

When to use it

Throw UserError from a handler, a service, a pipe or a guard whenever the user can fix the problem: an amount over their balance, an item they don't own, a name already taken.

Write an exception filter when an error needs an answer the fallback can't give: in the user's language, with a link to a status page, or for an error type of your own. A filter also catches MeoCord's own errors, such as a cooldown's CooldownError.

To stop a call before it starts, use a guard; a guard's GuardDeniedError is answered the same way a UserError is.

Example

controllers/slash/transfer.slash.controller.ts
@Controller()
export class TransferSlashController {
  @Command('transfer', CommandType.SLASH)
  async transfer(interaction: ChatInputCommandInteraction, { amount }: { amount: number }) {
    const balance = balances.get(interaction.user.id) ?? 0
    // The user's own mistake: shown only to them, and no fault of the bot to log
    if (amount > balance) {
      throw new UserError(`You have ${balance} coins, ${amount - balance} short of ${amount}.`, {
        code: 'wallet.short',
        context: { balance, amount },
      })
    }
    balances.set(interaction.user.id, balance - amount)
    await respond(interaction).send({ content: `Sent ${amount} coins.` })
  }
}
Dispatches /transfer amount:50

A /transfer of more coins than the user holds doesn't change the balance, and the user is told privately what they're short of. The error is logged only at debug level, since the bot did nothing wrong, and observers see the outcome 'refused'. code and context let a filter or a presenter phrase it otherwise, such as in the user's language.

Subclass it to name your own errors, class NotEnoughCoinsError extends UserError: the fallback answers every subclass the same way, and a filter can @Catch(NotEnoughCoinsError) to word that one otherwise.

How it works

Filters surround every stage of a call, so an error from the guards, the interceptors, validation, a pipe, a cooldown or the handler reaches them. How a call runs shows the order.

A filter is a class marked @Catch with the error types it handles, matched with instanceof. With no types, it handles every error. It implements ExceptionFilter, whose catch receives the error and the call's ExecutionContext:

filters/rate-limited.filter.ts
import { type ExecutionContext } from 'meocord/common'
import { Catch } from 'meocord/decorator'
import { type ExceptionFilter } from 'meocord/interface'

export class RateLimitedError extends Error {
  constructor(readonly retryAfter: number) {
    super(`Rate limited for ${retryAfter}s`)
  }
}

@Catch(RateLimitedError)
export class RateLimitedFilter implements ExceptionFilter<RateLimitedError> {
  async catch(error: RateLimitedError, context: ExecutionContext) {
    // The same answer the handler was building: respond() picks reply, edit or follow-up
    await context.response?.error(error, {
      message: `Slow down: try again in ${error.retryAfter}s.`,
      visibility: 'private',
    })
  }
}

context.response is the same respond() state the handler was using, so a filter answers where the handler left off: it replies, edits the deferred reply or follows up, whichever the answer allows.

When an error is thrown, MeoCord tries the filters closest to the handler first:

  1. the method's @UseFilter;
  2. the controller's, then each base class's, up from the class that declares or inherits the handler;
  3. the global ones, from @MeoCord({ filters }).

So a subclass's filter for one error is tried before a catch-all on its base, whether the handler is its own or inherited.

Within one list, the first filter whose @Catch matches handles the error. An error no filter handles goes to the built-in fallback. A filter that throws is logged, and the fallback answers the original error.

controllers/slash/quote.slash.controller.ts
@Controller()
@UseFilter(RateLimitedFilter)
export class QuoteSlashController {
  private remaining = 1

  @Command('quote', CommandType.SLASH)
  async quote(interaction: ChatInputCommandInteraction) {
    // Stands in for a call to a rate-limited API
    if (this.remaining-- <= 0) throw new RateLimitedError(30)
    await respond(interaction).send({ content: 'Stay hungry, stay foolish.' })
  }
}

One instance of a filter serves every call, so it can inject services. It can't inject ExecutionContext, which is catch's second argument instead. Options for one use go through { provide, params }, read with context.getParams().

The call's params

context.getHandlerParams() gives a filter the handler's params as they stood when the error was thrown: raw when validation or a pipe threw, validated and piped when the handler did. A filter can answer with what the user asked for:

filters/unknown-account.filter.ts
import { type ExecutionContext } from 'meocord/common'
import { Catch } from 'meocord/decorator'
import { type ExceptionFilter } from 'meocord/interface'

export class UnknownAccountError extends Error {}

@Catch(UnknownAccountError)
export class UnknownAccountFilter implements ExceptionFilter<UnknownAccountError> {
  async catch(error: UnknownAccountError, context: ExecutionContext) {
    // The params as they were when the error was thrown: here the pipe threw, so the uid is still the text
    const { uid } = context.getHandlerParams<{ uid: string }>() ?? {}
    await context.response?.error(error, { message: `There is no account ${uid}.`, visibility: 'private' })
  }
}

Errors outside a handler

An interaction no handler matches, such as a button whose customId fits no pattern, raises CommandNotFoundError. Only global filters see it, and its context has no controller or handler. Catch it globally to answer an expired button in your own words.

The built-in fallback

The fallback logs an error no filter handled, then answers the user through respond() if the interaction can still take an answer, in the style of the app's presenter:

The interactionThe fallback
Not answered yetreplies privately
A command whose reply is deferrededits the deferred reply into the error
A button, select menu or modal on an ephemeral messageadds the error to that message, if it fits
Any other button, select menu or modalfollows up privately; it never edits the message the user clicked
Autocompletecloses the menu with an empty list
Expired (Discord error 10062)logs only

An error that doesn't fit the message it would edit, such as one past Discord's limit of embeds, follows up privately instead.

It says "An error occurred while executing the command." for a fault, and "Command not found!" for CommandNotFoundError, both in the user's language when the app's translator has them; see MeoCord's own texts. A UserError, a guard's GuardDeniedError, a CooldownError and a ValidationError show their own message, only to the caller: on a public deferred command, the deferral is deleted and the message follows up privately.

After a message command, the fallback replies to the message, without a ping, with a UserError's message. A guard's or validation's reason is replied the same way and deleted after @MeoCord({ messages: { deleteUsageRepliesAfter } }) seconds, as is a command's usage for words it can't take, such as a missing word, one of the wrong type, an unknown flag, a member not found, or a command used in a server that runs only in DMs or the other way round. Other errors of message, reaction and event handlers are only logged, unless messages: { dmOnError } or dmOnCooldown tells a message command's author in a direct message.

Failures never end the process

An error anywhere in a call is caught, so a handler or a stage that throws or rejects never ends the bot's process:

  • Inside a handler's call, the filters and then the built-in fallback receive it, as above.
  • Before a handler is reached, such as for an interaction no handler matches, or one that fails while its handler is looked up, the global filters receive it, and the fallback still answers the user.
  • In an event handler, each call is isolated: its error goes to the filters, one none handles is logged with the event and the handler, and the next listener still runs.
  • In MeoCord's own Discord listeners, such as the one for clientReady, a rejection is logged against the event rather than left as an unhandled rejection, which would end the process.

The fallback itself never throws: an answer Discord refuses is logged.

Testing

Filters run under invoke, which resolves with the error a filter handled. The fallback doesn't run under invoke: an error no filter handles rejects, so the test sees it.

controllers/slash/quote.slash.controller.spec.ts
it('answers the error its filter handles, and invoke reports it handled', async () => {
  await module.invoke(QuoteSlashController, 'quote', createMockInteraction(ChatInputCommandInteraction))
  const limited = createMockInteraction(ChatInputCommandInteraction)

  const result = await module.invoke(QuoteSlashController, 'quote', limited)

  expect(result.error).toBeInstanceOf(RateLimitedError)
  expect(getResponse(limited).calls.map(call => call.method)).toEqual(['reply'])
})

To test what the user is told for an error no filter handles, a UserError included, use dispatch, which answers as the bot does:

controllers/slash/transfer.slash.controller.spec.ts
describe('TransferSlashController', () => {
  it('tells only the user what they are short of, as the bot would', async () => {
    const module = MeoCordTestingModule.create({ controllers: [TransferSlashController] }).compile()
    const interaction = createMockInteraction(ChatInputCommandInteraction, { commandName: 'transfer' })
    interaction.options = createChatInputOptions({ amount: 50 })
    balances.set(interaction.user.id, 20)

    // dispatch answers a UserError as the bot does, where invoke would reject with it
    await module.dispatch(interaction)

    const [answer] = getResponse(interaction).calls
    expect(JSON.stringify(answer?.payload)).toContain('You have 20 coins, 30 short of 50.')
    expect(Number((answer?.payload as { flags?: number }).flags) & MessageFlags.Ephemeral).toBeTruthy()
    expect(balances.get(interaction.user.id)).toBe(20)
  })
})

Generate a filter with npx meocord g f <name>.

Gotchas

  • A filter without @Catch stops the bot at startup, naming the decorator to add. @Catch() with no types handles every error.
  • An entry of @Catch that isn't a class matches no error. An undefined, often from two files that import each other, logs a warning naming the filter as it loads, and the filter still handles the other types it lists. In the next major version (5.0) it stops the bot. Import the class where it's defined.
  • A plain Error tells the user nothing useful. If the user can fix the problem, throw UserError instead, and its message reaches them.
  • A global filter catches everything the others don't. List specific filters on the controller or method, and keep the global ones for what every handler shares, such as CommandNotFoundError.

Build it

The review buttons keep working after a restart, but the bot keeps feedback in memory, so a click on a post from before the restart makes FeedbackService throw FeedbackNotFoundError. Without a filter, the member would see the generic error. Answer it in their words:

tutorial/feedback-not-found.filter.ts
// A review button whose feedback is gone, such as one posted before a restart
@Catch(FeedbackNotFoundError)
export class FeedbackNotFoundFilter implements ExceptionFilter<FeedbackNotFoundError> {
  async catch(error: FeedbackNotFoundError, context: ExecutionContext) {
    const interaction = context.getInteraction()
    const message = interaction ? t.for(interaction)('feedback.notFound') : undefined
    await context.response?.error(error, { message, visibility: 'private' })
  }
}

Apply it to both review buttons, on the controller:

tutorial/review.controller.ts
import { UseFilter } from 'meocord/decorator'
// …
import { FeedbackNotFoundFilter } from '@src/tutorial/feedback-not-found.filter'
// …
// A missing feedback is answered in the filter's words
@UseFilter(FeedbackNotFoundFilter)

Restart the bot and press Approve on a post from before the restart: only you are told that the feedback no longer exists.

Next steps

  • Observers: count refused calls and errors, with the outcome each ended with.
  • Presenters: style every error the fallback shows.
  • Localisation: give a filter's answer in the user's language, as the Build it filter does.