Errors¶
Every error this module defines is a package-level sentinel created with
NewSentinel from gitlab.com/phpboyscout/go/errors, the module's one
dependency. Providers wrap them, so always match with errors.Is and never
by comparing strings or using == on a wrapped error:
Message text always begins chatplatform:, so a sentinel is recognisable in a
log line even where it has been wrapped several times.
That package is the module's only dependency, and it is itself standard-library-only, which is what keeps the dependency graph weightless. Providers are free to use richer error libraries; they wrap these.
Wrapping adds to the chain; it never replaces what the platform said. A
provider must return an error that errors.Is matches to the sentinel and that
leaves the platform's own error reachable with errors.As. The two carry
different halves of the answer: the sentinel says how you must respond, the
platform's error says what was actually refused.
if errors.Is(err, chatplatform.ErrForbidden) {
var apiErr *discord.APIError // your provider's error type
if errors.As(err, &apiErr) {
// apiErr names the missing permission; the sentinel does not.
}
}
Reaching for the platform's error costs you portability, so do it in the layer that already knows which provider it is talking to. Everything above that should match the sentinel and stay platform-agnostic.
Use errors.As. Do not walk the chain with errors.Unwrap¶
A provider adding a sentinel beside the platform's error produces a node with
Unwrap() []error, and errors.Unwrap cannot traverse one. It follows
Unwrap() error only, so a hand-rolled walk stops dead at the join:
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf("%T -> ", e) // *fmt.wrapError -> *fmt.wrapErrors -> nil
}
The platform's error is still there. errors.As finds it immediately; the walk
simply cannot see past the branch.
This matters because the wrong answer is indistinguishable from a real defect. A chain that stops early and a provider that discarded the platform's error produce identical output from a single-error walk, so a probe written this way reports a correctly-wrapped error as a broken one. It has already happened once against this contract, on code that was correct.
errors.Is and errors.As both handle branching chains. Use them, including in
whatever you write to check a provider is behaving.
Connection and request errors¶
| Sentinel | Returned by | Meaning |
|---|---|---|
ErrNotConnected |
any Actor method the provider can honour |
the Reader has no live session |
ErrUnsupported |
a provider method, or NewClient |
the provider or transport can never honour the request |
ErrForbidden |
a provider method | the bot's credential is valid and lacks the platform's permission |
ErrChannelDenied |
a provider method | a channel outside the allowlist was named |
ErrNotFound |
NewClient, New, and provider methods |
no such provider, message, thread or member |
ErrAlreadyScoped |
Client.Provider |
the space already has a live scope on that client |
ErrNotConnected: you acted before connecting¶
The commonest error in early development. An Actor is available as soon as the
provider is constructed, but nothing it does works until Reader.Connect has
returned successfully.
Call Connect first, and treat it as retryable, since the session may simply
not be up yet. It is a sentinel rather than a nil-pointer panic precisely so a caller
who starts work early has something to handle.
ErrUnsupported: the provider cannot do this at all¶
Returned when a provider or transport can never honour a request the contract
permits: the platform has no equivalent, or the transport does not reach the
part of the platform that does. Permanent, so it takes precedence over
ErrNotConnected: a method that will never work says so before and after
Connect alike.
Prefer omitting an optional capability over implementing it to return this.
A capability that exists and always fails tells the caller at runtime what the
type system could have told them at compile time. ErrUnsupported is for the
case where a capability is mostly real and one corner of it is not.
Retrying will never help. If you see it, change what you ask for.
The base surface is the exception to the omit-it rule, because Reader.Messages
and the three Actor methods cannot be omitted. A transport that carries audio
only (a voice node with no gateway and no bot token) answers ErrUnsupported
from each of the three, and refuses NeedMessages at construction with the same
sentinel naming the need, so a consumer that declared it wanted messages is told
at NewClient rather than left ranging a channel that never yields. See
ClientConfig.Needs.
ErrForbidden: the platform refused on permission¶
The credential is valid; the bot lacks the right the platform requires. A bot added to a space without permission to speak, to delete, or to time somebody out.
Distinct from ErrUnsupported, and the distinction is your whole response.
Unsupported is structural and permanent: the platform has no such concept, so try
something else. Forbidden is a grant: mutable, and usually fixable by a person. The
useful response is to say so, to somebody who can change it, not to give up.
To say which permission, you need the platform's error. ErrForbidden's own
message is chatplatform: the bot lacks permission for this request, which names
nothing an administrator could act on. The provider's wrapped error does, and the
contract requires it to remain reachable; see
wrapping, above. A bot that tells somebody to go and fix a permission
is only useful if it says which one.
Also distinct from ErrChannelDenied, which is this module's own allowlist
refusing rather than the platform's permissions.
A provider may also return it from Connect, where the platform refuses the
connection itself rather than a single request. Discord does: an ungranted
privileged gateway intent closes with 4014, and
its provider maps that to this sentinel from v0.5.1,
leaving the *websocket.CloseError reachable with errors.As so a caller can
still read the close code.
Only 4014 is mapped. A close for a malformed intent bitfield (4013) or an
invalid credential (4004) is not a missing grant, and reporting either as
ErrForbidden would send somebody to the developer portal looking for a switch
that would not have helped.
That case is worth handling separately from a per-request refusal, because it is one global setting rather than one space's permissions. Every scope on the transport fails identically until a person changes it, and no amount of retrying or falling back to another space helps.
Where the answer is knowable in advance, ask instead of provoking it.
VoiceSession.CanSend() reports whether sending is currently permitted. That check
is advisory and this error remains the authority, because a permission can be
revoked between the two.
ErrChannelDenied: a channel outside the allowlist was named¶
Named as a Ref by most methods, or as a bare channel id by
VoiceReceiver.Join and VoiceParticipants.Participants. A voice channel is a
channel, and the allowlist covers it.
The contract's sentinel for a provider refusing to act on a channel that is not in the scope's allowlist.
Check whether your provider actually enforces this on a Ref. The contract
asks providers to. The shipping Discord provider applies the allowlist to the
inbound feed and to the two methods that take a bare channel id, Join and
Participants, before anything touches the platform, and returns this from no
method that takes a Ref. See
Does the allowlist stop the bot posting somewhere unexpected?
ErrNotFound: the thing you named does not exist¶
Distinct from a transport failure: retrying will not help.
It covers four different situations, and they are not distinguishable from the error alone:
Newwas given a provider name nobody registered.- A referenced message, thread or member does not exist.
- A provider was given an empty identifier where one was required.
- A provider could not obtain a fact it needs. The Discord provider returns it when a member has no join date.
If you need to tell "unknown provider" apart from the rest, use
Lookup instead of New
and check its boolean.
ErrAlreadyScoped: that space already has a scope¶
A space has at most one live scope per Client, and asking for a second while
the first is live is refused with this, naming the space, and a nil
*Provider. The existing scope is left untouched, because replacing it silently would
leave the displaced consumer holding a Provider that looks healthy and never
receives again.
Distinct from ErrInvalidArgument in what you do next: nothing about the
argument is wrong, and retrying can help, because closing the scope releases
the space. Either use the scope you already hold, or close it first.
Voice errors¶
There are three, and they are named for what you must do, not for the condition that produced them.
| Sentinel | Your response | Returned by |
|---|---|---|
ErrNoVoiceSession |
join first | VoiceSender.Send, VoiceSender.Stream |
ErrVoiceBusy |
stop the other thing, or do not ask | VoiceReceiver.Join, VoiceSender.Send, VoiceSender.Stream |
ErrInvalidArgument |
fix your code | VoiceReceiver.Join, VoiceSender.Stream |
The method tells you which condition it was, so the sentinel does not have
to. Join can only be busy on a session (you are already in a channel) and
Send or Stream can only be busy on a stream. Join gets
ErrInvalidArgument for a nil sink; Stream gets it for a nil frame channel.
That is deliberate and it is what stops the set growing. A sentinel per
situation grows once per situation; a sentinel per response is bounded by the
number of things you can usefully do. If you ever need to tell two ways of being
busy apart, the answer will be a type reached with errors.As, in the shape
strconv uses for ErrSyntax and ErrRange, not a fourth sentinel.
These replaced
ErrVoiceStreamActive,ErrNilVoiceSinkandErrNilVoiceFrames, which were removed. MatchErrVoiceBusyandErrInvalidArgumentinstead; the changelog names the release.
Registration errors¶
| Sentinel | Returned by | Meaning |
|---|---|---|
ErrAlreadyRegistered |
Register |
a factory already exists under that name |
ErrInvalidName |
Register |
the name was empty |
ErrNilFactory |
Register |
the factory was nil |
All three come from Register and nothing else, and all three leave the
registry unchanged.
ErrAlreadyRegistered in a running application almost always means two provider
modules chose the same name, or one module was blank-imported through two
different paths. It is not something a consumer can work around at runtime;
Unregister exists for tests, not for resolving a collision in production.
Validation errors¶
Returned by PromptSpec.Validate, FormSpec.Validate and
CommandSpec.Validate, and by provider methods that call them before touching
the wire.
| Sentinel | Condition |
|---|---|
ErrEmptyContent |
a prompt with no content |
ErrNoChoices |
a prompt offering no choices |
ErrEmptyChoiceKey |
a choice with no key |
ErrEmptyChoiceLabel |
a choice with no label |
ErrDuplicateChoiceKey |
two choices sharing a key |
ErrEmptyTitle |
a form with no title |
ErrNoFields |
a form with no fields |
ErrEmptyFieldKey |
a field with no key |
ErrDuplicateFieldKey |
two fields sharing a key |
ErrEmptyCommandName |
a command with no name |
ErrEmptyDescription |
a command with no description |
ErrEmptyOptionName |
an option with no name |
ErrDuplicateOptionName |
two options sharing a name, or a group and a subcommand |
ErrInvalidCommandName |
a name outside 1-32 runes of lowercase letters, digits, - or _ |
ErrInvalidDescription |
a description outside 1-100 runes |
ErrTooManyOptions |
more than 25 options, subcommands or groups at one level |
ErrOptionsWithSubcommands |
a command carrying both options and subcommands |
ErrEmptyGroup |
a subcommand group with nothing in it |
ErrOptionOrder |
a required option listed after an optional one |
ErrUnknownOptionType |
an OptionType this contract does not define |
ErrCommandTooLarge |
names, descriptions and choices over 8000 runes combined |
Errors about a member of a slice carry its index and key in the message while still matching the sentinel:
These are the errors that distinguish a malformed spec from a platform refusal,
so a caller can tell "I built this wrong" from "the platform said no". The exact
rules, and what Validate does not check, are in
What Validate rejects.
What has no sentinel¶
The contract deliberately does not define errors for these, so a provider returning something for them is returning its own error type:
- Rate limiting. There is no
ErrRateLimitedand no retry policy. Whatever the platform SDK does about rate limits is what happens. - Permission denied on a moderation action.
ErrForbiddenexists, but the contract does not require every method to map a platform refusal onto it. A bot lacking permission to delete a message or time somebody out gets the platform's error, wrapped by the provider; the paths the Discord provider does map are listed in the provider reference. - A dropped message. When a consumer is too slow, providers drop rather than
block. Nothing is returned and nothing is logged by this module.
ConnState.LastReconnectLostEventscovers reconnect loss, but a full buffer is invisible. - Authentication failure. A bad token surfaces from
Connectas the platform's own error.
Provider errors this module does not define¶
A provider may define its own sentinels for situations the contract has no word for. They are part of that provider's API, not this one's, and matching them means importing the provider, which is a coupling worth noticing before you do it.
The Discord provider defines two, listed in the provider reference.