Skip to content

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:

if errors.Is(err, chatplatform.ErrNotConnected) {
    // Connect has not run, or the session is gone.
}

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.

chatplatform: not connected

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:

  • New was 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, ErrNilVoiceSink and ErrNilVoiceFrames, which were removed. Match ErrVoiceBusy and ErrInvalidArgument instead; 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:

choice 2 ("delete"): chatplatform: choice keys must be unique

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 ErrRateLimited and no retry policy. Whatever the platform SDK does about rate limits is what happens.
  • Permission denied on a moderation action. ErrForbidden exists, 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.LastReconnectLostEvents covers reconnect loss, but a full buffer is invisible.
  • Authentication failure. A bad token surfaces from Connect as 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.