{"record":{"id":"643a0fb1f87b7013","repo":"antiwork/gumroad","slug":"please-wait-a-few-seconds-and-try-sending-again","errorCode":null,"errorMessage":"Please wait a few seconds and try sending again.","messagePattern":"Please wait a few seconds and try sending again\\.","errorType":"exception","errorClass":"Installment::InstallmentInvalid","httpStatus":422,"severity":"warning","filePath":"app/controllers/api/internal/customers/single_customer_emails_controller.rb","lineNumber":189,"sourceCode":"      CreatorContactingCustomersEmailInfo.where(purchase:, installment:).first_or_create!(\n        email_name: EmailEventInfo::PURCHASE_INSTALLMENT_MAILER_METHOD,\n        state: \"sent\",\n        sent_at: Time.current\n      )\n    end\n\n    def delivery_cache_key(installment, purchase)\n      \"single_customer_email_delivery:#{installment.id}:#{purchase.id}\"\n    end\n\n    def with_redis_lock(lock_key)\n      token = SecureRandom.uuid\n      lock_acquired = false\n      deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REDIS_LOCK_WAIT_TIMEOUT\n\n      until $redis.set(lock_key, token, ex: REDIS_LOCK_TTL.to_i, nx: true)\n        if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline\n          raise Installment::InstallmentInvalid, \"Please wait a few seconds and try sending again.\"\n        end\n\n        sleep REDIS_LOCK_RETRY_INTERVAL_SECONDS\n      end\n\n      lock_acquired = true\n      yield\n    ensure\n      $redis.eval(REDIS_LOCK_RELEASE_SCRIPT, keys: [lock_key], argv: [token]) if lock_acquired\n    end\n\n    def files_params(permitted_params)\n      { files: permitted_params[:files] || [] }.with_indifferent_access\n    end\n\n    def single_customer_email_idempotency_key(purchase, permitted_params)\n      content_digest = Digest::SHA256.hexdigest(\n        [","sourceCodeStart":171,"sourceCodeEnd":207,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/controllers/api/internal/customers/single_customer_emails_controller.rb#L171-L207","documentation":"Installment::InstallmentInvalid raised by with_redis_lock in the same controller when the per-delivery Redis lock cannot be acquired within REDIS_LOCK_WAIT_TIMEOUT (busy-wait loop with REDIS_LOCK_RETRY_INTERVAL_SECONDS sleeps until a monotonic deadline). It means another request held the \"single_customer_email_delivery:<installment>:<purchase>\"-adjacent lock longer than the wait budget, so this request gave up instead of queueing behind it. Lock release uses a token-checked Lua script, so the timeout is a contention signal, not corruption.","triggerScenarios":"Two or more concurrent sends for the same installment+purchase (or a slow holder — long delivery record write while holding the lock) so $redis SET NX keeps failing until Process.clock_gettime passes the deadline; the raise happens inside the until loop before yield.","commonSituations":"Burst of retries after a slow first request; Redis latency spikes stretching hold time; REDIS_LOCK_TTL shorter than the holder's work causing lock churn; overlapping background jobs targeting the same customer email.","solutions":["Retry the request after a few seconds — contention is usually momentary; add client-side backoff rather than hammering.","If it recurs, check what holds the lock long (delivery recording, cache writes under the lock) and shrink the critical section or raise REDIS_LOCK_WAIT_TIMEOUT for this path.","Verify Redis health/latency — slow SET NX round trips inflate both wait and hold times.","De-duplicate triggers at the source so concurrent sends for the same installment+purchase don't race at all."],"exampleFix":"# before: single attempt surfaces contention to the user\nSendSingleCustomerEmailJob.perform_now(installment, purchase)\n# after: bounded retry with jitter on the lock-timeout error\nretries = 0\nbegin\n  SendSingleCustomerEmailJob.perform_now(installment, purchase)\nrescue Installment::InstallmentInvalid => e\n  raise if retries >= 3 || e.message !~ /try sending again/\n  retries += 1\n  sleep(rand(1..3))\n  retry\nend","handlingStrategy":"retry","validationCode":"# avoid piling on: check lock availability cheaply before entering the wait loop\nreturn :busy if $redis.set(lock_key, SecureRandom.uuid, ex: REDIS_LOCK_TTL.to_i, nx: true).nil? && short_budget?","typeGuard":null,"tryCatchPattern":"retries = 0\nbegin\n  controller.send_email\nrescue Installment::InstallmentInvalid => e\n  raise unless e.message =~ /wait a few seconds/i && (retries += 1) <= 3\n  sleep(rand(2..5))\n  retry\nend","preventionTips":["Add jittered backoff on lock-timeout instead of immediate retries.","Serialize sends per installment+purchase at the caller (queue/job dedup).","Keep the lock's critical section short and monitor Redis latency."],"tags":["redis","distributed-lock","concurrency","email","timeout"],"backgroundTag":"lock-acquisition-timeout","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}