[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::Billing 이 rescue 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#
- 2026-07-22 07:38 KST — 대표 이벤트: quote
VGSHCL0IAG(본 클러스터) apply 시도, 실패. - 2026-07-22 07:38 KST — HTTP 400 로그 (
PUT /api/v1/admin/quotes/888/apply) 동시 기록. - 2026-07-22 07:45 KST — 동일 사유로 quote
RNXKJ5M77Vapply 실패 (별도 클러스터057dcafd-4b1a-4af1-82a9-ee1ad24bc692). - 2026-07-22 01:06 ~ 07:45 KST — 같은 billable 에 대해 quote 872, 874, 876, 878, 880, 884, 886, 888, 890 순차 apply 시도, 모두 400 응답.
- 2026-07-22 03:29 KST — TSLA-13689 브랜치 (
feature/TSLA-13689) 에Cupix::Errors::Billing을warn으로 다운그레이드하는 커밋9ae8d896c이 존재 (아직 develop/master 병합 여부는 미확인).
Error Log#
[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.rb 의 rescue_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::Billingraise - Log point:
app/models/quote.rb(origin/master)rescue StandardError→Cupix::Logger.error(...) - HTTP mapping:
app/controllers/concerns/client_error_controller.rb:12-14(Cupix::Errors::Billing→ 400)
def apply
@model = repository_instance.apply
show
end
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 된다:
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
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::Billing 은 StandardError 하위이므로 rescue Cupix::Errors::Parameter 에 걸리지 않고 아래 rescue StandardError 에 잡혀 error 레벨로 기록된다. 기대 동작은 "관리자 입력 검증 실패"이므로 warn 이하가 자연스럽다.
HTTP 응답이 500 이 아닌 400 인 이유는 다음 rescue 매핑 때문이다:
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 으로 로깅한다:
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 eLog Evidence#
Datadog 쿼리:
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 레벨 로그가 함께 남았다.
{
"timestamp": "2026-07-22 07:38 KST",
"status": "error",
"message": "[Quote][VGSHCL0IAG] Quote application failed: Billable already has a scheduled quote: 870"
}
{
"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 시도) 이 이어지는 사실이 재확인된다:
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 StandardError 가 Cupix::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.rbapply!의 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#update의rescue StandardError,check_appliable의 다양한Cupix::Errors::Billingraise 지점) 에서도 client 400 응답으로 매핑되는 예외가error로 로깅되는지 grep 으로 점검.- 대상:
Cupix::Errors::Billing,Cupix::Errors::Parameter,Cupix::Errors::InvalidState,Cupix::Errors::Entity,Cupix::Errors::Session,Cupix::Errors::Resource등client_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) 용 쿼리 예시:
service:cupixworks-api status:error @error.class:Cupix::Errors::Billing
service:cupixworks-api status:error "Quote application failed"
Fix 배포 후 위 두 쿼리의 count 시계열이 0 으로 떨어지는 것을 확인. 동시에 warn 레벨은 유지되는지 다음 쿼리로 확인:
service:cupixworks-api status:warn "Quote application failed"
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial
- 이유: 이미 준비된 fix 커밋(3줄 rescue 추가 + spec)이 존재하고, 코드 경로 변경 없이 로그 레벨만 조정. HTTP 응답 코드/에러 payload 는 그대로 유지되며 사용자 영향 없음.