ES /docs

[Quote][VGSHCL0IAG] Quote application failed: Billable already has a scheduled quote: 870

RCA: [Quote] Quote application failed: Billable already has a scheduled quote: 870

Overview#

What Happened#

2026-07-22 07:38 KST 부터 cupixworks-api 의 admin quote apply 엔드포인트에서 "Billable already has a scheduled quote: 870" 메시지가 error 레벨로 반복 출력됐다. HTTP 응답은 400 (client validation) 이지만 내부에서 Cupix::Errors::Billingrescue StandardError 로 잡히면서 서버 오류처럼 로깅됐다. 동일 시간대에 quote 872 ~ 890 을 순차 apply 시도한 관리자 조작 로그가 이어졌고, 모두 같은 이유로 거부됐다.

Quick Facts#

Field Value
exception.class Cupix::Errors::Billing
exception.message Billable already has a scheduled quote: 870
top_frame app/models/quote.rb:schedule_apply!
env production, region ap-southeast-2, tenant cupix
http_status 400
error_code BILL10000

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (admin billing) 클러스터 내 대표 1건 (동일 오류 반복은 별도 클러스터로 집계) 관리자가 이미 scheduled quote 를 가진 billable 에 추가 미래 시작 quote 를 apply 하려다 400 으로 거부됨. 실제 사용자 트래픽 손상은 없음.

Timeline#

  1. 2026-07-22 07:38 KST — 대표 이벤트: quote VGSHCL0IAG (본 클러스터) apply 시도, 실패.
  2. 2026-07-22 07:38 KST — HTTP 400 로그 (PUT /api/v1/admin/quotes/888/apply) 동시 기록.
  3. 2026-07-22 07:45 KST — 동일 사유로 quote RNXKJ5M77V apply 실패 (별도 클러스터 057dcafd-4b1a-4af1-82a9-ee1ad24bc692).
  4. 2026-07-22 01:06 ~ 07:45 KST — 같은 billable 에 대해 quote 872, 874, 876, 878, 880, 884, 886, 888, 890 순차 apply 시도, 모두 400 응답.
  5. 2026-07-22 03:29 KST — TSLA-13689 브랜치 (feature/TSLA-13689) 에 Cupix::Errors::Billingwarn 으로 다운그레이드하는 커밋 9ae8d896c 이 존재 (아직 develop/master 병합 여부는 미확인).

Error Log#

Datadog Logs

text
[Quote][VGSHCL0IAG] Quote application failed: Billable already has a scheduled quote: 870

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1 (본 클러스터 기준; 동일 사유 반복 이벤트는 Datadog 에서 9회 이상 확인됨)
  • 최초 발생: 2026-07-22 07:38 KST
  • 최근 발생: 2026-07-22 07:38 KST

Root Cause Summary#

Quote#apply!billable.scheduled_quote_id 가 이미 다른 quote 로 설정돼 있으면 Cupix::Errors::Billing 을 raise 하도록 TSLA-12975 리팩터에서 도입됐다. 이 예외는 client_error_controller.rbrescue_from 에 의해 HTTP 400 (BILL10000) 로 응답되지만, apply! 내부의 rescue StandardError => e 블록이 먼저 잡아서 Cupix::Logger.error("[Quote][...] Quote application failed: ...") 를 남긴다. 결과적으로 "관리자가 이미 예약된 quote 가 있는 billable 에 다시 apply 를 시도"라는 정상 입력 검증 실패 시나리오가 서버 error 로 집계돼 error-sweeper 알림을 유발했다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/admin/quotes_controller.rb:37 (Api::V1::Admin::QuotesController#apply)
  • Repository: app/repositories/admin/quote_repository.rb:45-50 (Admin::QuoteRepository#apply) → @model.apply!
  • Failure raise: app/models/quote.rb (origin/master) schedule_apply!billable.scheduled_quote_id 가 이미 존재하고 다른 id 이면 Cupix::Errors::Billing raise
  • Log point: app/models/quote.rb (origin/master) rescue StandardErrorCupix::Logger.error(...)
  • HTTP mapping: app/controllers/concerns/client_error_controller.rb:12-14 (Cupix::Errors::Billing → 400)
app/controllers/api/v1/admin/quotes_controller.rb:37-41ruby
def apply
  @model = repository_instance.apply

  show
end
app/repositories/admin/quote_repository.rb:45-50ruby
def apply
  raise Cupix::Errors::Billing.new(code: 'BILL10000', reason: 'Permission denied operation: Only accounting deparment can apply the quote') unless Pundit.policy(self.current_user, @model).apply?

  @model.apply!
  @model
end

master 브랜치의 Quote#apply!schedule_apply! 로 분기하며, 여기서 문제 exception 이 raise 된다:

app/models/quote.rb (origin/master)ruby
def apply!
  Cupix::Logger.info("[Quote][#{number}] Quote application begins #{billable.class.name} #{billable.id}")
  check_appliable

  _billing_started_at, _billing_expires_at = extract_billing_dates
  # ...
  ActiveRecord::Base.transaction do
    if _billing_started_at > Time.zone.today
      schedule_apply!(_billing_started_at)
    else
      activate_apply!(_billing_started_at, _billing_expires_at)
    end
  end
rescue Cupix::Errors::Parameter => e
  Cupix::Logger.error("[Quote][#{number}] Invalid Parameter : #{e.message}", ...)
  raise e
rescue StandardError => e
  Cupix::Logger.error("[Quote][#{number}] Quote application failed: #{e.message}")
  raise e
app/models/quote.rb (origin/master) schedule_apply!ruby
def schedule_apply!(billing_started_at)
  if billable.scheduled_quote_id.present? && billable.scheduled_quote_id != id
    raise Cupix::Errors::Billing.new(
      code: 'BILL10000',
      reason: "Billable already has a scheduled quote: #{billable.scheduled_quote_id}"
    )
  end
  # ...
end

Cupix::Errors::BillingStandardError 하위이므로 rescue Cupix::Errors::Parameter 에 걸리지 않고 아래 rescue StandardError 에 잡혀 error 레벨로 기록된다. 기대 동작은 "관리자 입력 검증 실패"이므로 warn 이하가 자연스럽다.

HTTP 응답이 500 이 아닌 400 인 이유는 다음 rescue 매핑 때문이다:

app/controllers/concerns/client_error_controller.rb:7-14ruby
rescue_from Cupix::Errors::Unknown,
            Cupix::Errors::Resource,
            Cupix::Errors::Session,
            Cupix::Errors::Parameter,
            Cupix::Errors::Entity,
            Cupix::Errors::Billing,
            Cupix::Errors::InvalidState,
            Cupix::Errors::Siteinsights, with: :client_400_error

이미 fix 브랜치(feature/TSLA-13689, 커밋 9ae8d896c) 는 아래처럼 Cupix::Errors::Billing 을 별도 rescue 로 분리해 warn 으로 로깅한다:

app/models/quote.rb rescue chain (TSLA-13689)
   rescue Cupix::Errors::Parameter => e     Cupix::Logger.error("[Quote][#{number}] Invalid Parameter : #{e.message}", class: self.class.name, function: __method__, module: 'Parameter::Quote')     raise e+  rescue Cupix::Errors::Billing => e+    Cupix::Logger.warn("[Quote][#{number}] Quote application failed: #{e.message}")+    raise e   rescue StandardError => e     Cupix::Logger.error("[Quote][#{number}] Quote application failed: #{e.message}")     raise e

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-api "Billable already has a scheduled quote"

시간대 (KST): 2026-07-22 00:54 ~ 07:45 KST 동안 동일 billable 을 대상으로 quote 872~890 이 순차 apply 시도됐고, 모두 400 으로 거부됨. 각 시도마다 error 레벨 로그가 함께 남았다.

json
{
  "timestamp": "2026-07-22 07:38 KST",
  "status": "error",
  "message": "[Quote][VGSHCL0IAG] Quote application failed: Billable already has a scheduled quote: 870"
}
json
{
  "timestamp": "2026-07-22 07:37:57 KST",
  "status": "info",
  "message": "[400] PUT /api/v1/admin/quotes/888/apply (Api::V1::Admin::QuotesController#apply)",
  "error": {
    "reason": "Billable already has a scheduled quote: 870",
    "code": "BILL10000",
    "message": "Billable already has a scheduled quote: 870",
    "class": "Cupix::Errors::Billing"
  }
}

동일 오류의 반복 (같은 billable, 다른 quote 시도) 이 이어지는 사실이 재확인된다:

text
2026-07-22 00:54:03 KST  quote 872  error + [400] PUT ...quotes/872/apply
2026-07-22 00:54:19 KST  quote 874  error + [400] PUT ...quotes/874/apply
2026-07-22 00:55:27 KST  quote 876  error + [400] PUT ...quotes/876/apply
2026-07-22 00:58:15 KST  quote 878  error + [400] PUT ...quotes/878/apply
2026-07-22 01:06:13 KST  quote 880  error + [400] PUT ...quotes/880/apply
2026-07-22 01:06:51 KST  quote 884  error + [400] PUT ...quotes/884/apply
2026-07-22 01:08:37 KST  quote 886  error + [400] PUT ...quotes/886/apply
2026-07-22 07:37:57 KST  quote 888  error + [400] PUT ...quotes/888/apply
2026-07-22 07:45:17 KST  quote 890  error + [400] PUT ...quotes/890/apply

모든 실패에서 reason 은 "Billable already has a scheduled quote: 870" 로 동일 → billable 하나에 미래 시작일 quote 870 이 이미 스케줄되어 있고, 관리자가 새로운 quote 를 만들어 apply 하려 할 때마다 정상적으로 거부되는 상태다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 입력 검증 실패(Cupix::Errors::Billing) 가 rescue StandardError 에 걸려 error 레벨로 로깅되고 있어 정상 400 응답 시나리오가 alert 로 잡힘 app/models/quote.rb (origin/master) rescue StandardErrorCupix::Errors::Billing 을 catch, Cupix::Logger.error 호출; Datadog 로그에서 동일 요청이 HTTP 400 (BILL10000) 으로 client 응답됨; TSLA-13689 fix 커밋 9ae8d896c 이 정확히 이 로그 레벨을 warn 으로 다운그레이드 Confirmed
H2 서버측 실제 버그 (예: DB 데이터 정합성 문제로 scheduled_quote_id 가 잘못 세팅됨) 동일 billable 에 quote 872~890 이 순차적으로 시도된 패턴은 관리자 UI 상에서 계속 새 quote 를 만들며 apply 를 반복한 정상 조작 흐름과 일치. quote 870 이 이미 예약돼 있어 거부되는 것은 코드 명세대로의 동작. Rejected
H3 최근 배포에서 scheduled_quote 초기화(reset) 로직이 누락돼 오래된 값이 남음 activate_apply!scheduled_quote: nil, scheduled_billing_started_at: nil 로 초기화; 별도 회귀 근거 없음. 클러스터 로그는 "새로 만든 quote 를 apply 하다 이미 있는 스케줄에 막힘" 이지 "예전 값이 남아있음" 을 뒷받침하지 않음. Rejected
H4 리전(ap-southeast-2) 특이 이슈 클러스터 frontmatter regions: [ap-southeast-2] 로직상 리전 종속 코드 없음; 단일 tenant cupix 의 어드민 조작이 해당 리전에 있었을 뿐. Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/models/quote.rb apply! 의 rescue chain 을 수정해 Cupix::Errors::Billing 을 별도로 warn 으로 로깅한다. 이는 이미 feature/TSLA-13689 브랜치의 커밋 9ae8d896c 에 준비돼 있음 — 해당 fix 를 develop/master 로 병합/배포하면 alert 이 즉시 사라진다.
  • 병합 시 spec (spec/models/quote_spec.rb) 에서 "Cupix::Errors::Billing 은 warn, 기타 StandardError 는 error" 두 케이스가 유지되는지 확인.

단기 개선 (1주 이내)#

  • 같은 패턴이 있는 다른 도메인(예: Admin::QuoteRepository#updaterescue StandardError, check_appliable 의 다양한 Cupix::Errors::Billing raise 지점) 에서도 client 400 응답으로 매핑되는 예외가 error 로 로깅되는지 grep 으로 점검.
    • 대상: Cupix::Errors::Billing, Cupix::Errors::Parameter, Cupix::Errors::InvalidState, Cupix::Errors::Entity, Cupix::Errors::Session, Cupix::Errors::Resourceclient_400_error 로 rescue 되는 예외 클래스.
  • error-sweeper 관점: 관리자 UI 의 반복 apply 시도가 클러스터 여러 개를 만들지 않도록 fingerprint 규칙에 quote number 를 정규화(예: [Quote][****] Quote application failed: Billable already has a scheduled quote: N) 하는 것도 검토.

장기 개선 (재발 방지)#

  • 컨벤션: client 400 으로 응답되는 exception 클래스는 서비스/모델 계층에서 error 로 로깅하지 않는다는 룰을 rubocop 커스텀 룰 혹은 리뷰 체크리스트로 문서화.
  • 어드민 UI 측: 이미 scheduled quote 가 있는 billable 에 대해 새 quote apply 버튼을 disable 하거나 "기존 스케줄을 취소하고 대체" 확인 다이얼로그를 노출해 애초에 반복 실패 조작을 줄인다 (백엔드 조치와 별개로 UX 개선 트랙).

Monitoring#

Datadog 대시보드 위젯 (release verification) 용 쿼리 예시:

text
service:cupixworks-api status:error @error.class:Cupix::Errors::Billing
text
service:cupixworks-api status:error "Quote application failed"

Fix 배포 후 위 두 쿼리의 count 시계열이 0 으로 떨어지는 것을 확인. 동시에 warn 레벨은 유지되는지 다음 쿼리로 확인:

text
service:cupixworks-api status:warn "Quote application failed"

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial
  • 이유: 이미 준비된 fix 커밋(3줄 rescue 추가 + spec)이 존재하고, 코드 경로 변경 없이 로그 레벨만 조정. HTTP 응답 코드/에러 payload 는 그대로 유지되며 사용자 영향 없음.