Exception filters and UserError
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
Before this
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
@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.` })
}
}/transfer amount:50Open in playgroundA /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:
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:
- the method's
@UseFilter; - the controller's, then each base class's, up from the class that declares or inherits the handler;
- 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.
@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:
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 interaction | The fallback |
|---|---|
| Not answered yet | replies privately |
| A command whose reply is deferred | edits the deferred reply into the error |
| A button, select menu or modal on an ephemeral message | adds the error to that message, if it fits |
| Any other button, select menu or modal | follows up privately; it never edits the message the user clicked |
| Autocomplete | closes 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.
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:
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
@Catchstops the bot at startup, naming the decorator to add.@Catch()with no types handles every error. - An entry of
@Catchthat isn't a class matches no error. Anundefined, 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
Errortells the user nothing useful. If the user can fix the problem, throwUserErrorinstead, 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:
// 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:
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.