{"record":{"id":"4005da161819d2a0","repo":"antiwork/gumroad","slug":"idempotency-key-already-used-with-different-conten","errorCode":null,"errorMessage":"Idempotency key already used with different content","messagePattern":"Idempotency key already used with different content","errorType":"http","errorClass":"User::CreateAdminCommentService::IdempotencyConflictError","httpStatus":409,"severity":"error","filePath":"app/services/user/create_admin_comment_service.rb","lineNumber":18,"sourceCode":"# frozen_string_literal: true\n\nclass User::CreateAdminCommentService\n  class IdempotencyConflictError < StandardError; end\n\n  def initialize(user:, content:, idempotency_key:, author_id: GUMROAD_ADMIN_ID)\n    @user = user\n    @content = content\n    @idempotency_key = idempotency_key\n    @author_id = author_id\n  end\n\n  def perform\n    normalized_content = Comment.normalize_content(@content)\n\n    existing = @user.comments.find_by(idempotency_key: @idempotency_key)\n    if existing\n      raise IdempotencyConflictError if existing.content != normalized_content\n      return existing\n    end\n\n    comment = @user.comments.new(\n      content: @content,\n      comment_type: Comment::COMMENT_TYPE_NOTE,\n      author_id: @author_id,\n      idempotency_key: @idempotency_key\n    )\n    comment.save\n    comment\n  rescue ActiveRecord::RecordNotUnique\n    existing = @user.comments.find_by!(idempotency_key: @idempotency_key)\n    raise IdempotencyConflictError if existing.content != normalized_content\n    existing\n  end\nend\n","sourceCodeStart":1,"sourceCodeEnd":36,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/services/user/create_admin_comment_service.rb#L1-L36","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before: stable key, mutating content — second edit conflicts\nUser::CreateAdminCommentService.new(user:, content: note_text, idempotency_key: \"note-#{user.id}\").perform\n\n# after: key fixed to the first attempt, retried verbatim\nkey = SecureRandom.uuid # stored with the draft and reused unchanged on retry\nUser::CreateAdminCommentService.new(user:, content: note_text, idempotency_key: key).perform","handlingStrategy":"validation","validationCode":"# Check for an existing comment with this key before creating\nexisting = user.comments.find_by(idempotency_key: key)\nreturn existing if existing && existing.content == Comment.normalize_content(content)\nraise_409 if existing # same key, different content — stop before the service does","typeGuard":null,"tryCatchPattern":"begin\n  comment = User::CreateAdminCommentService.new(user:, content:, idempotency_key: key).perform\nrescue User::CreateAdminCommentService::IdempotencyConflictError\n  render json: { error: \"Idempotency key already used with different content\" }, status: :conflict\nend","preventionTips":["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."],"tags":["idempotency","concurrency","comments","api-design"],"backgroundTag":"idempotency-key-conflict","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}