ES /docs

RestClient::GatewayTimeout: 504 Gateway Timeout

RCA: RestClient::GatewayTimeout: 504 Gateway Timeout

Overview#

What Happened#

tesla (cupixworks-worker) 의 PostCaptureStateChangeWorker 가 capture 상태 변경 시 내부 운영용 Slack 채널 (capture-state) 로 알림 메시지를 Slack Incoming Webhook 에 POST 하는데, Slack 이 504 Gateway Timeout 을 반환했다. cupixworks-rest_client 는 실제 서비스가 아니라 tesla 의 RestClient outbound 호출을 계측하는 Datadog APM span 이름이다. 이 504 는 Cupix::HttpClient.post 의 재시도(4회) + Sidekiq retry: 1 로 모두 소진된 후에야 표면화되며, 백그라운드 내부 알림이라 사용자 영향이 없다. 저빈도(약 2년간 11건)의 transient Slack 인프라 이벤트다.

Quick Facts#

Field Value
exception.class RestClient::GatewayTimeout
exception.message 504 Gateway Timeout
top_frame lib/cupix/http_client.rb:37 (enqueue: app/workers/post_capture_state_change_worker.rb:15)
runtime Ruby 3.3.0, rest-client 2.1.0, Sidekiq 7.3.9
env production
downstream Slack Incoming Webhook (hooks.slack.com/services/...)

Affected Teams#

Team / Domain Error Count Impact
tesla (internal ops Slack notification) 11 (2024-05 ~ 2026-08) 없음 — capture 상태 변경 Slack 알림 1건 누락 가능 (사용자 비노출 내부 채널)

Timeline#

  1. 2024-05-10 23:56 KST — 최초 발생 (first_seen)
  2. 2026-08-04 00:28 KST — 최근 발생 (last_seen). 14일 span 검색에서 동일 경로 504 2건 확인
  3. 2026-08-04 — RCA 수행. 원인 = downstream Slack webhook transient 504

Error Log#

Datadog Logs

text
504 Gateway Timeout

Impact#

  • Service: cupixworks-rest_client (실제 앱 = tesla)
  • 발생 횟수: 11
  • 최초 발생: 2024-05-10 23:56 KST
  • 최근 발생: 2026-08-04 00:28 KST

Root Cause Summary#

PostCaptureStateChangeWorker 는 capture 상태 변경 콜백(Notifiable::Capture#notify)에서 enqueue 되어, 내부 운영용 Slack 채널 알림을 SLACK_SERVICE_WEBHOOK_URL (Slack Incoming Webhook) 로 POST 한다. 이 504 는 tesla 코드 결함이 아니라 Slack 측 게이트웨이의 transient 응답이다. 504 는 Cupix::HttpClient::RETRIABLE_STATUS_CODES = [429, 502, 503, 504] 에 포함되어 있어 HttpClient.post 가 지수 백오프로 최대 4회(초기 1 + MAX_RETRIES 3) 재시도하고, 그 위에 Sidekiq retry: 1 이 한 번 더 전체 job 을 재실행한다. 즉 모든 재시도가 연속으로 504 를 받은 경우에만 예외가 표면화된다. 백그라운드 알림 job 이므로 실패해도 사용자에게 노출되지 않으며, 결과는 내부 Slack 상태 메시지 1건 누락뿐이다.

Technical Analysis#

Code Path#

  • Enqueue point: app/models/concerns/notifiable/capture.rb:44
  • Entry point (worker): app/workers/post_capture_state_change_worker.rb:15
  • HTTP 호출: lib/cupix/http_client.rb:37
  • Failure point: lib/cupix/http_client.rb:44 (재시도 소진 후 raise)

capture 상태 변경 시 콜백이 내부 Slack 알림 job 을 enqueue 한다.

app/models/concerns/notifiable/capture.rb:19-45ruby
def notify
  case state
  when 'done'
    # ...
    description = 'Processing completed'
    icon = ':clap:'
  # ... (uploading / queued / processing / error)
  end

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

  PostCaptureStateChangeWorker.perform_async(message)
end

worker 는 Slack Incoming Webhook 으로 메시지를 POST 한다.

app/workers/post_capture_state_change_worker.rb:1-17ruby
class PostCaptureStateChangeWorker
  include Sidekiq::Worker
  sidekiq_options queue: :default, retry: 1

  def perform(message)
    channel = 'capture-state'
    channel = "capture-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 })
  end
end

POST 대상은 Slack Incoming Webhook URL 이며, span 의 http.url 과 정확히 일치한다.

config/initializers/slack.rb:3ruby
SLACK_SERVICE_WEBHOOK_URL = 'https://hooks.slack.com/services/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'.freeze

HttpClient.post 는 504 를 재시도 대상으로 취급한다. 기대 동작 = transient 504 는 재시도로 자가 복구. 실제 동작 = 4회 재시도 + Sidekiq retry: 1 모두 504 → 예외 표면화(APM error span 태깅).

lib/cupix/http_client.rb:8-46ruby
RETRIABLE_STATUS_CODES = [429, 502, 503, 504].freeze
MAX_RETRIES = 3

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

Log Evidence#

cupixworks-rest_client 는 APM 계측 span 이므로 504 는 로그가 아니라 error span 으로만 남는다. 로그 검색은 0건, span 검색으로 실제 발생을 확인했다.

Span 검색 쿼리 (searchSpans, /api/v2/spans/events/search):

text
service:cupixworks-rest_client status:error @http.status_code:504

now-24h 결과 = 1건, now-14d = 3건(그중 Slack webhook 2건, 무관한 /authentication/v2/token 1건). 대표 span attributes (attributes.error / attributes.http):

json
{
  "error": {
    "type": "RestClient::GatewayTimeout",
    "message": "504 Gateway Timeout",
    "file": "/var/app/current/vendor/bundle/ruby/3.3.0/gems/rest-client-2.1.0/lib/restclient/abstract_response.rb"
  },
  "http": {
    "method": "POST",
    "status_code": "504",
    "url": "/services/T0A84RHMM/B08D2LMV0UB/7vluNRWdwHkYXWOrIu1CSFn3",
    "path_group": "/services/?/?/?"
  }
}

span stack 의 앱 프레임이 worker 진입점을 명시한다.

text
.../lib/cupix/http_client.rb:37:in `post'
.../app/workers/post_capture_state_change_worker.rb:15:in `perform'

로그 검색(모두 0건, 재시도로 rescue 되거나 예외가 로깅되지 않음):

text
service:cupixworks-worker "504 Gateway Timeout"     => 0
"RestClient::GatewayTimeout"                         => 0

대조: PostCaptureStateChangeWorker 자체는 정상 실행이 다수다 (sidekiq-logstash job 로그).

text
service:cupixworks-worker "PostCaptureStateChangeWorker"   => 53756 logs (now-14d)

53,756 회 실행 중 504 span 2건 = 실패율 사실상 무시 가능 수준의 transient 이벤트임을 보여준다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 downstream Slack Incoming Webhook 의 transient 504 (재시도 소진 후 표면화, 백그라운드 알림이라 무영향) span http.url = SLACK_SERVICE_WEBHOOK_URL (config/initializers/slack.rb:3) 정확 일치; stack top = http_client.rb:37post_capture_state_change_worker.rb:15; 14d 504 span 2건 vs worker 실행 53,756건 = 무시 가능 실패율; 504 ∈ RETRIABLE_STATUS_CODES 이미 4회 재시도 Confirmed
H2 voxel-service captured_area/merge 계열 downstream 500/503/504 (기존 유사 이슈들) 같은 cupixworks-rest_client service span http.url 이 Slack webhook 이고 voxel-service API Gateway 가 아님; stack 이 PostCaptureStateChangeWorker 이지 VoxelService 아님 Rejected
H3 tesla 코드 결함 (nil, 잘못된 payload 로 인한 게이트웨이 실패) payload 는 정상 Slack 메시지 JSON; 동일 코드가 53,756회 정상 실행; 504 는 Slack 게이트웨이 응답이지 요청 형식 오류(4xx) 아님 Rejected
H4 Representative Error 가 stale (실제 현재 메시지가 다름) ET 가 변형을 묶는 경향 504 Gateway Timeout 은 고정 HTTP status 문자열; last_seen(2026-08-04 00:28 KST) span 이 동일 메시지·경로 재현 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 없음. tesla 코드 결함이 아니며 코드 변경 불필요. Datadog Error Tracking 에서 이 이슈를 IGNORE 처리 권장.

단기 개선 (1주 이내)#

  • (선택) 재시도까지 소진된 Slack webhook 실패의 알람 노이즈를 줄이려면, PostCaptureStateChangeWorker 에서 Cupix::HttpClient.postrescue RestClient::GatewayTimeout(또는 RestClient::ExceptionRETRIABLE_STATUS_CODES) 하여 warn 로그 후 조용히 종료하는 방향 고려. 내부 운영 알림이라 손실 허용 가능. 단, 이는 노이즈 축소이지 버그 수정이 아니다.

장기 개선 (재발 방지)#

  • 내부 운영 Slack 알림 다수 worker (Post*StateChangeWorker, capture_invoker.rb:343, aws_adapter/fargate.rb:53 등)가 동일하게 raw SLACK_SERVICE_WEBHOOK_URL POST 를 반복한다. best-effort 성격의 Slack 알림 전용 헬퍼(예: Cupix::Slack.post 계열)로 통합하고 그 안에서 transient 실패를 흡수하면, 이런 APM error span 노이즈를 일괄 제거할 수 있다.

Monitoring#

Slack webhook 504 발생 추이 (release dashboard timeseries widget 용):

text
service:cupixworks-rest_client status:error @http.status_code:504 @http.url:"/services/T0A84RHMM/B08D2LMV0UB/7vluNRWdwHkYXWOrIu1CSFn3"

worker 전체 처리량 대비 실패율 참고 (정상 실행 baseline):

text
service:cupixworks-worker "PostCaptureStateChangeWorker"

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial (코드 변경 불필요; 선택적 노이즈 축소만)

Noise Verdict#

noise — Slack Incoming Webhook 의 일시적 504 로, 이미 HttpClient 4회 재시도와 Sidekiq retry 로 처리되는 백그라운드 내부 알림이라 사용자 영향과 코드 결함이 없다.