ES /docs

[post_pointcloud_state_change_worker] Retrying 1 times, error: 429 Too Many Requests

RCA: post_pointcloud_state_change_worker 429 Too Many Requests

Overview#

What Happened#

2026-06-13 18:01 KST 무렵, cupixworks-workerPostPointcloudStateChangeWorker가 Slack incoming webhook(hooks.slack.com)에 알림을 전송하다 4초 동안 13건의 429 Too Many Requests를 기록했다. 모든 job 은 Sidekiq 의 1차 retry 에서 성공했고, Final retry attempt failed 로그는 발생하지 않아 사용자 영향은 없었다 (Slack 메시지 일부의 도착 지연만 발생).

Quick Facts#

Field Value
exception.class RestClient::TooManyRequests (RestClient::Exception, http_code 429)
exception.message 429 Too Many Requests
top_frame app/workers/post_pointcloud_state_change_worker.rb:24 (Cupix::HttpClient.post)
runtime Ruby on Rails (Sidekiq worker)
env production, region us-west-2
tenant cupix

Affected Teams#

Team / Domain Error Count Impact
Pointcloud notifications (Slack #pointcloud-state) 13 Slack 알림 도착이 1차 Sidekiq retry 만큼 지연. 처리 파이프라인 자체 영향 없음

Timeline#

  1. 2026-06-13 18:01:49 KST — Slack webhook 첫 429 응답 (3건 기록)
  2. 2026-06-13 18:01:51 KST — 추가 4건 429
  3. 2026-06-13 18:01:53 KST — 마지막 6건 429 (총 13건)
  4. 이후 — Sidekiq job-level 1차 retry 가 모두 성공, Retrying 2 times 또는 Final retry attempt failed 로그 없음

Error Log#

Datadog Logs

text
[post_pointcloud_state_change_worker] Retrying 1 times, error: 429 Too Many Requests

Impact#

  • Service: cupixworks-worker
  • 발생 횟수: 13
  • 최초 발생: 2026-06-13 18:01:49 KST
  • 최근 발생: 2026-06-13 18:01:53 KST
  • 사용자 영향: 없음 (Sidekiq retry 로 모두 복구). 다만 동시에 처리되는 pointcloud 가 많을 때 Slack 알림 지연 가능.

Root Cause Summary#

Slack incoming webhook 은 공식적으로 webhook 당 1 message/sec (short bursts >1 허용) rate limit 을 가진다 (Slack Web API rate limits — Incoming webhooks: "1 per second", "Short bursts >1 allowed"). 메시지 게시(chat.postMessage 및 incoming webhook 포함) 는 추가로 채널 당 1 msg/sec 의 per-channel 제한과 workspace-wide ceiling 도 함께 적용된다 (같은 문서). 이 워커는 SLACK_SERVICE_WEBHOOK_URL 단일 webhook 으로 단일 채널(#pointcloud-state) 에 게시하므로, webhook-level / channel-level 두 limit 모두 같은 1 msg/sec 로 수렴한다. 다수의 pointcloud 가 동시에 상태 전이(queued / processing / done)하는 burst 가 발생하면 Notifiable::Pointcloud#notify 가 동일 webhook 으로 메시지를 짧은 시간에 다발적으로 enqueue 한다. Cupix::HttpClient.post 가 client-side 에서 최대 3회 (1s/2s/4s + jitter) 재시도하지만 burst 길이가 이를 초과하면 결국 RestClient::Exception(http_code 429) 이 worker 까지 전파되고, Sidekiq 의 sidekiq_retry_in 훅이 Retrying 1 times, error: 429 Too Many Requests 를 error 레벨로 로깅한다. job 은 Sidekiq 기본 backoff 로 다시 실행되어 결국 성공하므로 기능적 장애는 아니지만, 정상 운영 중 발생하는 transient rate-limit 이 error 레벨로 alert noise 를 만든다.

Technical Analysis#

Code Path#

  • Trigger: app/models/concerns/notifiable/pointcloud.rb:48 — pointcloud 상태 전이 시마다 worker enqueue
  • Worker entry: app/workers/post_pointcloud_state_change_worker.rb:13 (perform)
  • HTTP call: app/workers/post_pointcloud_state_change_worker.rb:24 (Cupix::HttpClient.post)
  • Client retry: lib/cupix/http_client.rb:34-46 — 3회 재시도 (429/502/503/504)
  • Failure point (retry 훅): app/workers/post_pointcloud_state_change_worker.rb:5-7 (sidekiq_retry_in 블록에서 error 레벨 로그)
app/models/concerns/notifiable/pointcloud.rb:26-49ruby
def notify
  return unless self.pointcloud_group?

  case state
  when 'done'
    description = 'Processing completed'
    icon = ':clap:'
  when 'queued'
    description = 'Waiting in queue'
    icon = ':hourglass_flowing_sand:'
  when 'processing'
    description = 'Processing started'
    icon = ':rocket:'
  when 'error'
    description = "Error (error_code: #{self.error_code})"
    icon = ':exclamation:'
  else
    return
  end

  message = "#{icon} [#{id}] <#{team_url}/pj/#{facility.key}/cap/#{record_id}/pc?cplv=#{level_id}|*#{name}* \| _#{facility.name}_ \| _#{team.name}_> #{description}"

  PostPointcloudStateChangeWorker.perform_async(message)
end
app/workers/post_pointcloud_state_change_worker.rb:1-27ruby
class PostPointcloudStateChangeWorker
  include Sidekiq::Worker
  sidekiq_options queue: :default, retry: 2

  sidekiq_retry_in do |count, e|
    Cupix::Logger.error("[post_pointcloud_state_change_worker] Retrying #{count + 1} times, error: #{e.message}")
  end

  sidekiq_retries_exhausted do |job, e|
    Cupix.logger.error "[post_pointcloud_state_change_worker] Final retry attempt failed: #{job['args'].first}, error: #{e.message}"
  end

  def perform(message)
    channel = 'pointcloud-state'
    channel = "pointcloud-state-#{Rails.env}" unless Rails.env.production?

    data = {
      channel: channel,
      username: $SLACK_REGION_USER_NAME,
      icon_emoji: SLACK_REGION_ICON,
      text: message
    }

    Cupix::HttpClient.post(SLACK_SERVICE_WEBHOOK_URL, data.to_json, { content_type: :json })
    Cupix::Logger.info("[post_pointcloud_state_change_worker] Successfully sent message to webhook: #{message}")
  end
end
lib/cupix/http_client.rb:34-46ruby
def self.post(url, payload, headers = {}, retries: MAX_RETRIES)
  attempt = 0
  begin
    RestClient.post(url, payload, headers)
  rescue RestClient::Exception => e
    if RETRIABLE_STATUS_CODES.include?(e.http_code) && attempt < retries
      attempt += 1
      sleep((2**(attempt - 1)) + rand(0.0..0.5))
      retry
    end
    raise
  end
end

기대 동작: client-side 재시도(MAX_RETRIES = 3, 최대 7s + jitter) 안에서 Slack 이 회복되어 200 응답을 돌려주면 worker 는 정상 종료한다. 실제 동작: 동시에 다수의 pointcloud sub-cluster 가 상태 전이하는 burst (Datadog info 로그에서 단일 capture 의 Sub-Cluster 17 이 같은 초에 queued/done 상태로 메시지 전송되는 패턴 확인됨) 에서 7초 안에 회복되지 않아 13개 job 이 worker 까지 예외를 전파했다. sidekiq_retry_in 블록은 retry 간격 계산 대신 로깅 용도로만 쓰이고 (반환값이 numeric 이 아니므로 Sidekiq 기본 exponential backoff 사용), Sidekiq job-level 1차 retry 시 모두 성공했다.

Log Evidence#

Datadog query (cluster 파일에서 그대로):

text
service:cupixworks-worker status:error @environment:production "[post_pointcloud_state_change_worker] Retrying 1 times, error: 429 Too Many Requests"

13건 모두 동일 메시지. 시간 분포(KST):

text
2026-06-13 18:01:49  ×3
2026-06-13 18:01:51  ×4
2026-06-13 18:01:53  ×6

같은 시간대(service:cupixworks-worker "post_pointcloud_state_change_worker" info) 에서 burst 패턴 확인:

text
2026-06-13 18:25:20  info  [...] :hourglass_flowing_sand: [1146653] ... Sub-Cluster 1 ... Waiting in queue
2026-06-13 18:25:14  info  [...] :hourglass_flowing_sand: [1146649] ... Sub-Cluster 1 ... Waiting in queue
2026-06-13 18:25:10  info  [...] :hourglass_flowing_sand: [1146651] ... Sub-Cluster 2 ... Waiting in queue
2026-06-13 18:25:00  info  [...] :hourglass_flowing_sand: [1146647] ... Sub-Cluster 2 ... Waiting in queue
2026-06-13 18:24:50  info  [...] :hourglass_flowing_sand: [1146645] ... Sub-Cluster 3 ... Waiting in queue
2026-06-13 18:24:38  info  [...] :hourglass_flowing_sand: [1146643] ... Sub-Cluster 5 ... Waiting in queue
2026-06-13 18:24:24  info  [...] :hourglass_flowing_sand: [1146641] ... Sub-Cluster 4 ... Waiting in queue
2026-06-13 18:24:16  info  [...] :hourglass_flowing_sand: [1146639] ... Sub-Cluster 3 ... Waiting in queue

→ 단일 capture(132781)의 7개 sub-cluster 가 같은 분 안에 queueddone 양쪽 메시지를 발행하는 패턴. 동일 webhook 으로 short-burst 알림이 집중됨이 확인된다.

Retrying 2 times 검색 결과:

text
service:cupixworks-worker "post_pointcloud_state_change_worker" "Retrying 2 times"
→ 0 logs

Final retry 검색 결과:

text
service:cupixworks-worker "post_pointcloud_state_change_worker" "Final retry"
→ 0 logs

→ 모든 job 이 Sidekiq 1차 retry 에서 복구됨.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Slack incoming webhook rate limit (공식: webhook 당 1 msg/sec, 채널 당 1 msg/sec, short bursts 허용 — docs) 을 burst 알림이 초과 동일 webhook(SLACK_SERVICE_WEBHOOK_URL) 으로 다수 worker 가 동시에 POST; 같은 capture 의 7개 sub-cluster 가 같은 분에 queued/done 두 번씩 발행되는 info 로그; 13건이 4초 안에 집중; client retry(lib/cupix/http_client.rb:34-46)는 7s 한도 안에서 회복 실패 Confirmed
H2 Slack 측 광역 incident / outage 동일 시간대 다른 worker(post_record_state_change_worker, post_transfer_state_change_worker 등) 도 동일 webhook 사용 다른 webhook 사용 worker 들의 429 에러가 동시 발생했다는 evidence 없음; 4초 만에 자체 회복; Slack status 페이지 incident 와의 상관관계 미확인 Rejected
H3 Worker 코드 결함 (예: payload 손상) → Slack 가 거부 메시지 payload 가 정상이라면 retry 시 또 429 가 떠야 함. 그러나 Sidekiq 1차 retry 에서 모두 성공 → payload 문제 아님 Rejected
H4 Cupix::HttpClient 의 retry 로직 자체 결함 lib/cupix/http_client.rb:34-46 코드상 429 에 대해 정상적으로 재시도; sleep + jitter 도 적절. 단지 7s 안에 회복 안 된 것뿐 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

없음. 사용자 영향이 없고(Sidekiq retry 로 모두 성공), error 레벨 alert noise 만 발생.

단기 개선 (1주 이내)#

  • app/workers/post_pointcloud_state_change_worker.rb:6sidekiq_retry_in 훅 로그 레벨을 검토. transient 한 429/502/503/504 에 대한 1차 Sidekiq retry 는 warn 레벨이 적절. Final retry attempt failed (sidekiq_retries_exhausted, line 10) 만 error 로 유지하면 진짜 장애 시점에만 alert 가 트리거된다. (메모리에 기록된 AUTH20022/AUTH20023 사례와 동일한 패턴: 정상 운영에서 발생 가능한 transient 조건은 warn)
  • 같은 패턴이 다른 Slack-webhook worker(post_record_state_change_worker, post_transfer_state_change_worker, post_deviation_state_change_worker, refresh_elements_count_worker, publish_siteinsights_events_*) 에도 존재 — 동일 처리를 일괄 적용하거나 공통 base worker 로 정리 검토.

장기 개선 (재발 방지)#

  • 동일 webhook 으로의 burst 를 줄이기 위한 옵션:
    • sub-cluster 단위가 아닌 cluster 단위로 알림을 집계 (예: 같은 record_id 의 sub-cluster 상태를 1초 debounce 후 단일 메시지로 발송)
    • Slack 알림 worker 를 별도 큐(queue: :slack_notifications) 와 단일 concurrency 의 Sidekiq 프로세스로 분리하여 자연스럽게 직렬화
  • Cupix::HttpClientMAX_RETRIES 또는 backoff 상한을 webhook 호출에 한해 늘리는 것은 Sidekiq thread pool 점유 시간을 늘리므로 권장하지 않음.

Monitoring#

이미 있는 service:cupixworks-worker status:error "[post_pointcloud_state_change_worker]" 검색이 충분. 추가 monitoring 권장:

전체 retry 발생률(transient 포함):

text
service:cupixworks-worker @environment:production "[post_pointcloud_state_change_worker] Retrying"

진짜 장애만 (alert 대상):

text
service:cupixworks-worker status:error @environment:production "[post_pointcloud_state_change_worker] Final retry attempt failed"

다른 Slack-webhook worker 의 전체 429 동향:

text
service:cupixworks-worker @environment:production "Retrying" "429 Too Many Requests"

Risk Assessment#

  • Risk level: low (transient, 사용자 영향 없음, Sidekiq retry 로 자체 회복)
  • 예상 복잡도: trivial (로그 레벨 조정만 적용 시) / standard (debounce/batching 까지 진행 시)

Revision History#

Revision 1#

Feedback: "slack webhook rate limit 이 채널/webhook 당 얼마인지 공식문서 찾아바"

판정:

피드백 항목 판정 근거
Slack incoming webhook 의 채널/webhook 당 rate limit 을 공식문서로 확인 수용 Slack 공식 문서 docs.slack.dev/apis/web-api/rate-limits 의 Incoming Webhooks 항목: "1 per second", "Short bursts >1 allowed" (per webhook). 동일 문서의 Posting messages 항목: 메시지 게시(chat.postMessage 및 incoming webhook 포함) 는 채널 당 1 msg/sec 와 workspace-wide ceiling 도 적용. 본 incident 의 webhook(SLACK_SERVICE_WEBHOOK_URL) 은 단일 channel(#pointcloud-state) 로만 게시하므로 두 limit 이 동일하게 1 msg/sec 로 수렴함. 기존 보고서의 "≈1 msg/sec" 추정은 정확했으나 출처 미기재 상태였다.

변경 사항:

  • ## Root Cause Summary — "약 1 msg/sec" 추정 표현을 공식 수치(webhook 당 1 msg/sec, 채널 당 1 msg/sec, short bursts 허용) 로 교체하고 출처 링크 추가. 본 incident 가 webhook-level / channel-level 어느 limit 에도 동일하게 부합함을 명시.
  • ## Hypotheses Considered H1 행에 공식 출처 링크 추가.

추가 조사 내용:

  • https://docs.slack.dev/apis/web-api/rate-limits (Slack Web API rate limits) — Incoming Webhooks tier 와 Posting messages tier 의 정확한 문구 확인.
  • https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks 도 확인했으나 rate limit 구체 수치는 기재되어 있지 않음 (rate-limits 페이지로 위임).