antiwork/gumroad · warning · Installment::InstallmentInvalid
Please wait a few seconds and try sending again.
Error message
Please wait a few seconds and try sending again.
What it means
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.
Source
Thrown at app/controllers/api/internal/customers/single_customer_emails_controller.rb:189
CreatorContactingCustomersEmailInfo.where(purchase:, installment:).first_or_create!(
email_name: EmailEventInfo::PURCHASE_INSTALLMENT_MAILER_METHOD,
state: "sent",
sent_at: Time.current
)
end
def delivery_cache_key(installment, purchase)
"single_customer_email_delivery:#{installment.id}:#{purchase.id}"
end
def with_redis_lock(lock_key)
token = SecureRandom.uuid
lock_acquired = false
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REDIS_LOCK_WAIT_TIMEOUT
until $redis.set(lock_key, token, ex: REDIS_LOCK_TTL.to_i, nx: true)
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
raise Installment::InstallmentInvalid, "Please wait a few seconds and try sending again."
end
sleep REDIS_LOCK_RETRY_INTERVAL_SECONDS
end
lock_acquired = true
yield
ensure
$redis.eval(REDIS_LOCK_RELEASE_SCRIPT, keys: [lock_key], argv: [token]) if lock_acquired
end
def files_params(permitted_params)
{ files: permitted_params[:files] || [] }.with_indifferent_access
end
def single_customer_email_idempotency_key(purchase, permitted_params)
content_digest = Digest::SHA256.hexdigest(
[View on GitHub (pinned to afeacbd394)
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.
Example fix
# before: single attempt surfaces contention to the user SendSingleCustomerEmailJob.perform_now(installment, purchase) # after: bounded retry with jitter on the lock-timeout error retries = 0 begin SendSingleCustomerEmailJob.perform_now(installment, purchase) rescue Installment::InstallmentInvalid => e raise if retries >= 3 || e.message !~ /try sending again/ retries += 1 sleep(rand(1..3)) retry end
Defensive patterns
Strategy: retry
Validate before calling
# avoid piling on: check lock availability cheaply before entering the wait loop return :busy if $redis.set(lock_key, SecureRandom.uuid, ex: REDIS_LOCK_TTL.to_i, nx: true).nil? && short_budget?
Try / catch
retries = 0 begin controller.send_email rescue Installment::InstallmentInvalid => e raise unless e.message =~ /wait a few seconds/i && (retries += 1) <= 3 sleep(rand(2..5)) retry end
Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- This email is already being sent. Please wait a few minutes
- Sorry, something went wrong. Please try again.
- Sorry, something went wrong. Please try again.
- stale_content_conflict
- File(s) #{missing_ids.join(', ')} no longer exist; they may
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/643a0fb1f87b7013.
Report an issue: GitHub.