Skip to content

feat: treat 'already in desired state' API errors as success (idempotent operations) #129

@rianjs

Description

@rianjs

Problem

When scripting with slck, operations like adding a reaction that already exists return errors from the Slack API (e.g. already_reacted). These aren't real failures — the desired state is already achieved. This causes unnecessary error noise in automation.

slck react :eyes: failed: Error: add reaction :eyes:: slack API error: already_reacted

Semantically, these operations are like PUT — "ensure this state exists." If the state already exists, that's success, not failure.

Operations to make idempotent

Command Slack Error Code Current Behavior Desired Behavior
messages react already_reacted Returns error Print Already reacted with :<emoji>: and exit 0
messages unreact no_reaction Returns error Print No :<emoji>: reaction to remove and exit 0
channels archive already_archived Returns error with hint Print Channel already archived: <channel> and exit 0
channels unarchive not_archived Returns error with hint Print Channel not archived: <channel> and exit 0
channels invite already_in_channel Returns error Print User(s) already in channel <channel> and exit 0 (single-user invites only)

Excluded: messages delete + message_not_found — ambiguous (wrong timestamp vs already deleted).

Implementation plan

1. Add IsSlackError helper — internal/client/errors.go

Add a reusable helper to standardize error code checking (replaces ad-hoc strings.Contains usage):

func IsSlackError(err error, code string) bool {
    if err == nil {
        return false
    }
    return strings.Contains(err.Error(), code)
}

Also add missing error hints for already_reacted, no_reaction, and already_in_channel to the errorHints map (useful if these errors surface in other contexts).

2. Update command files

Handle the idempotent error before calling WrapError, print a descriptive message, and return nil:

  • internal/cmd/messages/react.go — check already_reacted after c.AddReaction()
  • internal/cmd/messages/unreact.go — check no_reaction after c.RemoveReaction()
  • internal/cmd/channels/archive.go — check already_archived after c.ArchiveChannel()
  • internal/cmd/channels/unarchive.go — check not_archived after c.UnarchiveChannel(). Also refactor existing not_in_channel check to use IsSlackError
  • internal/cmd/channels/invite.go — check already_in_channel after c.InviteToChannel() (only when len(userIDs) == 1). Also add missing WrapError call for non-idempotent errors

3. Add tests

  • internal/cmd/messages/messages_test.goTestRunReact_AlreadyReacted, TestRunUnreact_NoReaction
  • internal/cmd/channels/channels_test.goTestRunArchive_AlreadyArchived, TestRunUnarchive_NotArchived, TestRunInvite_AlreadyInChannel
  • internal/client/errors_test.goTestIsSlackError + new hint entries

4. Update integration-tests.md

Update expected outputs for the idempotent operations.

Edge case: channels invite with multiple users

Slack's conversations.invite API is all-or-nothing. If you invite 2 users and one is already in the channel, Slack fails the entire batch with already_in_channel — the second user does NOT get invited.

Decision: Only treat as idempotent when len(userIDs) == 1. Multi-user invites still surface the error so callers know not everyone was added.

Verification

make test      # All tests pass
make lint      # No lint errors
make build     # Builds cleanly

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions