antiwork/gumroad · error · User::CreateAdminCommentService::IdempotencyConflictError

Idempotency key already used with different content

Error message

Idempotency key already used with different content

What it means

User::CreateAdminCommentService makes admin-note creation idempotent via a per-user unique idempotency_key on comments. If a comment already exists with that key but its normalized content (Comment.normalize_content) differs from the new content, IdempotencyConflictError (a StandardError subclass defined in the service) is raised — the key was reused for a semantically different request, which is a client bug, not a duplicate retry. Same key plus same normalized content returns the existing comment.

Source

Thrown at app/services/user/create_admin_comment_service.rb:18

# frozen_string_literal: true

class User::CreateAdminCommentService
  class IdempotencyConflictError < StandardError; end

  def initialize(user:, content:, idempotency_key:, author_id: GUMROAD_ADMIN_ID)
    @user = user
    @content = content
    @idempotency_key = idempotency_key
    @author_id = author_id
  end

  def perform
    normalized_content = Comment.normalize_content(@content)

    existing = @user.comments.find_by(idempotency_key: @idempotency_key)
    if existing
      raise IdempotencyConflictError if existing.content != normalized_content
      return existing
    end

    comment = @user.comments.new(
      content: @content,
      comment_type: Comment::COMMENT_TYPE_NOTE,
      author_id: @author_id,
      idempotency_key: @idempotency_key
    )
    comment.save
    comment
  rescue ActiveRecord::RecordNotUnique
    existing = @user.comments.find_by!(idempotency_key: @idempotency_key)
    raise IdempotencyConflictError if existing.content != normalized_content
    existing
  end
end

View on GitHub (pinned to afeacbd394)

Solutions

  1. Mint the key once per logical request (e.g. SecureRandom.uuid at first attempt) and reuse it verbatim with the exact same content on retries.
  2. If the content legitimately changed, use a new key.
  3. Remember only exact equality after Comment.normalize_content dedupes — trailing-whitespace or formatting differences still conflict.

Example fix

# before: stable key, mutating content — second edit conflicts
User::CreateAdminCommentService.new(user:, content: note_text, idempotency_key: "note-#{user.id}").perform

# after: key fixed to the first attempt, retried verbatim
key = SecureRandom.uuid # stored with the draft and reused unchanged on retry
User::CreateAdminCommentService.new(user:, content: note_text, idempotency_key: key).perform
Defensive patterns

Strategy: validation

Validate before calling

# Check for an existing comment with this key before creating
existing = user.comments.find_by(idempotency_key: key)
return existing if existing && existing.content == Comment.normalize_content(content)
raise_409 if existing # same key, different content — stop before the service does

Try / catch

begin
  comment = User::CreateAdminCommentService.new(user:, content:, idempotency_key: key).perform
rescue User::CreateAdminCommentService::IdempotencyConflictError
  render json: { error: "Idempotency key already used with different content" }, status: :conflict
end

Prevention

When it happens

Trigger: Calling perform twice with the same idempotency_key but edited content; a key derived from something stable like the user id instead of the original request; two different admin notes submitted with a colliding key.

Common situations: API client retries after a timeout but rebuilds the payload (any difference after normalization counts as different content); key generated from a fixed field; user edits a note and resubmits under the original key.

Related errors


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/4005da161819d2a0. Report an issue: GitHub.