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
- 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.
- If the content legitimately changed, use a new key.
- 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
- Generate the idempotency key once per logical operation (SecureRandom.uuid) and pin it to the original payload.
- Replay the exact same content on retry — normalization only smooths formatting, not wording.
- Key by (operation, target, payload identity), never by a stable field like user id alone.
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
- Something went wrong.
- Sorry, something went wrong. Please try again.
- stale_content_conflict
- This email is already being sent. Please wait a few minutes
- Please wait a few seconds and try sending again.
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/4005da161819d2a0.
Report an issue: GitHub.