ES /docs

recipe deletion failed: {"message":"Forbidden"}

RCA: recipe deletion failed: {"message":"Forbidden"}

Overview#

What Happened#

2026-07-18 10:01 KST 부터 약 2분간 cupixworks-worker 에서 Cupix::NotificationService#delete_user_recipe 가 notification-service 로부터 HTTP 403 Forbidden 을 받아 204건의 error 로그가 폭발적으로 기록되었다. 같은 시간대에 subscribe/unsubscribe/create_user_recipe 도 동일한 403 을 반환했다. notification-service Lambda authorizer 가 Tesla API /api/v1/me 호출을 위해 사용하는 fetchWithRetry (retries=3, timeoutMs=1000) 가 3회 모두 abort 되면서 isAuthorized: false 를 반환한 것이 원인이다.

Quick Facts#

Field Value
exception.class Cupix::NotificationService
exception.message recipe deletion failed: {"message":"Forbidden"}
top_frame lib/cupix/notification_service.rb:65
runtime Rails / Sidekiq worker (tesla)
deploy production-us-west-2-20260718T0100Z0-f2b18e95-cupixworks
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
cupixworks-worker (notification recipe cleanup) 204 facility permission destroy 시 recipe/subscription 정리 실패 — orphan 알림 recipe 잔존 가능
notification-service (authorizer) 다수 (Tesla /api/v1/me abort 3/3 반복) 모든 recipe/subscription API 호출이 인가 실패

Timeline#

  1. 2026-07-18 10:00 KST — 배포 태그 20260718T0100Z0 로 cupixworks 배포가 시작됨 (버전 문자열 상 배포 시점).
  2. 2026-07-18 10:01 KST — Tesla API /api/v1/me 응답이 1초 timeout 을 초과, notification-service authorizer 의 fetchWithRetry 첫 실패 발생 (Datadog: Fetch attempt 1/3 failed: This operation was aborted).
  3. 2026-07-18 10:01 KST — first_seen (2026-07-18T01:01:02.071Z). delete_user_recipe 가 403 을 받고 "recipe deletion failed: {message:Forbidden}" 을 로그. 이후 2분간 204건 누적.
  4. 2026-07-18 10:02 KST — last_seen (2026-07-18T01:02:45.859Z). 이후 로그 정지 — Tesla API 응답성이 회복되어 authorizer 성공.

Error Log#

Datadog Logs

text
recipe deletion failed: {"message":"Forbidden"}

Impact#

  • Service: cupixworks-worker
  • 발생 횟수: 204
  • 최초 발생: 2026-07-18 10:01 KST
  • 최근 발생: 2026-07-18 10:02 KST

facility permission 이 destroy 되거나 full_permission_disabled 이벤트가 발생했을 때 사용자별 email recipe 3종(record_preview_ready, record_processing_completed, facility_new_project) 를 정리하는 파이프라인이 실패했다. cleanup 실패 시 recipe 는 notification-service 에 남아 있어, 권한이 해제된 사용자가 계속 이메일 알림을 받을 잠재적 위험이 있다. worker 에서 예외를 삼키고 false 를 반환하기 때문에 상위 작업 자체는 실패로 마킹되지 않는다.

Root Cause Summary#

notification-service 의 Lambda authorizer (applications/notification-service/src/lambda/authorizer.ts) 는 요청 인가를 위해 Tesla API https://api.<DOMAIN_NAME>/api/v1/meCPUtils.fetchWithRetry 로 호출한다. 이 함수는 attempt 당 1000ms AbortController timeout 을 사용하며, 세 번의 시도가 모두 abort 되면 authorizer 는 catch 절에서 isAuthorized: false 를 반환한다. 2026-07-18 10:00 KST 배포(20260718T0100Z0) 직후 Tesla API 응답이 1초 SLA 를 벗어나면서 authorizer 가 대량으로 403 을 리턴했고, 이로 인해 Cupix::NotificationService#delete_user_recipe(그리고 subscribe/unsubscribe/create_user_recipe) 가 일제히 RestClient::ExceptionWithResponse403 Forbidden 을 catch 하며 "recipe deletion failed: {message:Forbidden}" 을 error 로 로깅했다. 사용자 권한 자체는 유효했으나 인가 요청의 타임아웃이 곧 "Forbidden" 으로 표면화된 셈이다.

Technical Analysis#

Code Path#

  • Entry point (worker side): lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:5 (full_permission_disabled / destroyed 이벤트)
  • Recipe cleanup 루프: lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:34-42
  • Failure point (Tesla): lib/cupix/notification_service.rb:65
  • Failure origin (notification-service): applications/notification-service/src/lambda/authorizer.ts:19-33
  • Timeout mechanism: applications/notification-service/src/libs/common/utils.ts:18-53

Tesla — recipe 삭제 호출은 3개의 recipe 를 순차 호출한다. HTTP 예외가 발생하면 로그만 남기고 false 를 반환하므로 개별 실패는 상위로 전파되지 않는다:

lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:34-42ruby
def _delete_recipes(opts = {})
  %w[
    record_preview_ready
    record_processing_completed
    facility_new_project
  ].each do |recipe_name|
    Cupix::NotificationService.new(user: opts[:user]).delete_user_recipe(recipe_name, team_id: opts[:team_id], facility_key: opts[:facility_key])
  end
end
lib/cupix/notification_service.rb:48-68ruby
def delete_user_recipe(recipe_name, team_id: nil, facility_key: nil)
  return if @service_url.nil?

  recipe = find_email_recipe(recipe_name, facility_key)

  return if recipe.nil?

  response = Cupix::HttpClient.delete(
    "#{@service_url}/api/recipes/v2/#{recipe['id']}",
    {
      content_type: :json,
      'x-cupix-auth': @user.api_token
    }
  )

  Cupix::Logger.info("recipe deleted: #{response.body}", class: self.class.name, function: __method__, recipe_name: recipe_name, facility_key: facility_key)
rescue RestClient::ExceptionWithResponse => e
  Cupix::Logger.error("recipe deletion failed: #{e.response.body}", class: self.class.name, function: __method__, recipe_name: recipe_name, facility_key: facility_key)

  false
end

notification-service — authorizer 는 Tesla /api/v1/me 호출이 실패하면 isAuthorized: false 를 반환하고, API Gateway 는 이를 403 Forbidden 으로 매핑한다:

applications/notification-service/src/lambda/authorizer.ts:11-43typescript
const token = event.headers['x-cupix-auth'];
if (!token) {
  throw new Error('Unauthorized; token not found');
}

const response = await CPUtils.fetchWithRetry(
  `https://api.${DOMAIN_NAME}/api/v1/me?fields=id,firstname,lastname,email,team`,
  {
    headers: {
      'x-cupix-auth': token
    }
  }
);

if (response.status !== 200) {
  throw new Error('Unauthorized; permission denied');
}
// ...
} catch (error: any) {
  console.log('Error authorizing: ', error.message);
  return { isAuthorized: false };
}

fetchWithRetry 는 attempt 당 1000ms 로 abort 되며, 3회 실패 시 마지막 에러를 throw 한다. 백오프는 100ms/200ms 로 매우 짧아 backend 회복 시간을 실질적으로 벌어주지 못한다:

applications/notification-service/src/libs/common/utils.ts:18-53typescript
static async fetchWithRetry(
  url: string,
  options: RequestInit,
  retries = 3,
  timeoutMs = 1000
): Promise<Response> {
  for (let i = 0; i < retries; i++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });

      clearTimeout(timeoutId);
      return response;
    } catch (error: any) {
      const attempt = i + 1;
      console.error(`Fetch attempt ${attempt}/${retries} failed:`, error.message);

      if (i === retries - 1) {
        throw error;
      }

      const backoffMs = 100 * Math.pow(2, i);
      console.log(`Retrying in ${backoffMs}ms...`);
      await new Promise(resolve => setTimeout(resolve, backoffMs));
    }
  }

  throw new Error('All retry attempts failed');
}

기대 동작: 정상 시 /api/v1/me 는 200 을 반환하여 authorizer 가 isAuthorized: true 를 리턴 → recipe DELETE 는 성공. 실제 동작: 배포 직후 Tesla API 응답 지연으로 1000ms timeout 을 3회 연속 초과 → authorizer 가 isAuthorized: false → API Gateway 가 403 Forbidden 반환 → tesla 워커가 이를 "recipe deletion failed" 로 로깅.

Log Evidence#

Datadog query (재현):

text
service:cupixworks-worker "recipe deletion failed"

대표 로그 (2026-07-18T01:02:45.859Z, region us-west-2, tenant cupix, facility 7l1mz2, deploy production-us-west-2-20260718T0100Z0-f2b18e95-cupixworks):

json
{
  "message": "recipe deletion failed: {\"message\":\"Forbidden\"}",
  "class": "Cupix::NotificationService",
  "function": "delete_user_recipe",
  "recipe_name": "facility_new_project",
  "facility_key": "7l1mz2",
  "tenant": "cupix",
  "environment": "production",
  "version": "production-us-west-2-20260718T0100Z0-f2b18e95-cupixworks"
}

같은 window 에서 unsubscribe 도 동일 401/403 패턴을 보였다 (@facility_key:7l1mz2 로 조회):

text
2026-07-18 10:02:45  Cupix::NotificationService  unsubscribe  Forbidden
2026-07-18 10:02:45  Cupix::NotificationService  delete_user_recipe  recipe deletion failed: {"message":"Forbidden"}

notification-service Lambda authorizer 로그 (동일 window, Datadog query service:notification-service "operation was aborted"):

text
2026-07-18T01:02:26.563Z ERROR  Fetch attempt 1/3 failed: This operation was aborted
2026-07-18T01:02:27.664Z ERROR  Fetch attempt 2/3 failed: This operation was aborted
2026-07-18T01:02:28.865Z ERROR  Fetch attempt 3/3 failed: This operation was aborted
2026-07-18T01:02:28.865Z INFO   Error authorizing:  This operation was aborted

세 attempt 가 1초 간격으로 실패한 후 authorizer 가 Error authorizing 를 남기고 isAuthorized: false 를 반환하는 흐름이 그대로 재현됐다.

관련 API 호출도 같은 시간 window 에서 403 을 받았다 (Datadog service:cupixworks-api status:error, 2026-07-18 09:59:37 KST 이후):

text
Cupix::NotificationService  subscribe            Forbidden
Cupix::NotificationService  create_user_recipe   Recipe creation failed

즉, 클러스터의 "recipe deletion failed" 는 개별 recipe 문제라기 보다 notification-service authorizer 가 광범위하게 403 을 리턴한 상황의 한 단면이다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 notification-service authorizer 의 fetchWithRetry 가 Tesla /api/v1/me 요청을 1s timeout 으로 3회 abort → isAuthorized: false → API Gateway 403 authorizer.ts:19-33 + utils.ts:18-53 코드, notification-service 로그의 Fetch attempt 1..3/3 failed: This operation was abortedError authorizing: This operation was aborted (2026-07-18 10:02 KST), tesla 측 403 timestamp 와 정확히 정렬 Confirmed
H2 사용자 api_token 이 만료되어 403 을 유발 x-cupix-auth: @user.api_token 을 사용 (notification_service.rb:59) 같은 window 에 subscribe/create_user_recipe/unsubscribe 등 여러 endpoint 가 모두 403 을 받았고, 다양한 facility 의 recipe 이름이 섞여 있음. 사용자 개별 만료라면 다중 사용자에 걸친 광역 실패는 설명 불가. authorizer 실패 로그가 이미 존재 Rejected
H3 notification-service 가 recipe id 소유권 검증에서 403 을 반환 (recipe 가 다른 team 소속) delete_user_recipefind_email_recipe 로 조회 후 삭제 (notification_service.rb:51-56), 잘못된 id 이면 개별 403 가능 실패가 recipe 별이 아니라 모든 이름 (facility_new_project, record_processing_completed) 에 걸쳐 발생하고, find_email_recipe (GET) 도 authorizer 를 통과해야 하는데 GET/DELETE 모두 실패. 광역 authorizer 실패로 설명됨 Rejected
H4 Tesla API /api/v1/me 자체가 5xx 를 반환하여 authorizer 가 403 처리 authorizer 코드는 response.status !== 200 시 throw → catch → isAuthorized: false notification-service 로그가 5xx status 코드가 아니라 This operation was aborted (client-side timeout) 를 남김. cupixworks-api 쪽에 동시 5xx 폭증 흔적 없음. 단, Tesla API 가 느려진 근본 원인 (배포 직후 부팅/자원 재조정 등)은 별도 확인 필요 — uncertain -- needs verification Rejected (as direct cause), 하위 원인 별도 추적 필요

Fix Recommendation#

즉시 조치 (Critical)#

  • applications/notification-service/src/libs/common/utils.ts:22fetchWithRetry 의 기본 timeoutMs 를 1000ms → 최소 3000~5000ms 로 상향. Tesla /api/v1/me 는 authenticated user 조회로 팀/권한 조인이 포함되어 P99 가 1s 를 상회할 수 있음. 현재 값은 정상 대역폭에서도 tail latency 를 컷하기 쉬움.
  • applications/notification-service/src/libs/common/utils.ts:46 — 백오프를 지수적으로 확장 (100ms → 250ms/500ms/1000ms 등) 하거나 최소 300ms 부터 시작. 100ms/200ms 백오프는 slow downstream 회복 시간을 실질적으로 벌어주지 못함.
  • applications/notification-service/src/lambda/authorizer.ts:39-43 — 인가 요청 자체가 timeout/네트워크 오류로 실패한 경우와 실제 권한 거부(response.status !== 200)를 구분해서, transient failure 는 명시적으로 AuthorizationTransientError 같은 다른 응답/로그 레벨로 처리하도록 방향 조정. 지금은 두 경우 모두 isAuthorized: false 로 뭉뚱그려져 있어 tesla 측 로그를 오해하기 쉬움.

단기 개선 (1주 이내)#

  • lib/cupix/notification_service.rb:65 — 403 이 실제 권한 실패인지 upstream authorizer 실패인지 tesla 측에서 구분할 방법이 없으므로, RestClient::Forbiddenwarn 으로 낮추고 나머지는 error 로 유지. 현재 error 로그만 204건 폭증하면 노이즈가 크다. (Rescue 를 RestClient::Forbidden 으로 narrow 하고, 그 외 RestClient::ExceptionWithResponse 는 계속 error 로 남길 것.)
  • lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:34-42 — recipe cleanup 은 idempotent 하므로, transient 403 발생 시 Sidekiq retry 로 재시도할 수 있는 경로를 검토. 현재는 false 만 반환하고 끝나서 실패한 cleanup 이 영구 누락됨.
  • notification-service authorizer 에 Tesla /api/v1/me 응답 캐시 (짧은 TTL, 예: 60s) 를 도입하여 동일 토큰에 대한 반복 조회를 줄이면 배포 직후처럼 Tesla 가 느릴 때에도 실패 확산을 완화할 수 있음.

장기 개선 (재발 방지)#

  • Tesla API /api/v1/me 의 P95/P99 지연을 모니터링하고, notification-service 인가 SLA(현재 실질 3s = 1s×3) 와 명시적으로 맞물리도록 대시보드/알람 정의.
  • 배포 직후 warm-up 문제로 Tesla API tail latency 가 튀는지 원인 추적 (deploy timestamp 20260718T0100Z0 과 오류 시작이 정확히 일치). Rolling/blue-green, connection pool preloading 등 배포 전략 재검토.
  • notification-service authorizer 대신 API Gateway JWT authorizer 로 이전을 검토하면 매 호출 Tesla API 왕복이 사라져 인가 latency/실패의 강한 의존성을 제거할 수 있다.

Monitoring#

  • notification-service authorizer 실패율 (Error authorizing log rate).
text
sum:logs.hits{service:notification-service,status:error,@message:"Error authorizing"}.as_count()
  • Tesla /api/v1/me 응답 지연 P95 (Rails).
text
p95:trace.rack.request.duration{service:cupixworks-api,resource_name:"GET /api/v1/me"}
  • tesla NotificationService 403 폭증 감지.
text
sum:logs.hits{service:cupixworks-worker,@class:Cupix::NotificationService,status:error}.as_count()
  • notification-service fetchWithRetry 3/3 실패 카운트.
text
sum:logs.hits{service:notification-service,@message:"Fetch attempt 3/3 failed"}.as_count()

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard

Timeout/backoff 튜닝과 로그 레벨 조정이 핵심으로 pure-code 변경이나, notification-service 배포와 tesla 배포가 각각 필요하고 인가 경로 변경은 정밀 테스트가 요구되므로 trivial 은 아니다. 사용자에게 즉시 노출되는 장애는 없었지만, cleanup 실패로 인한 orphan recipe/subscription 잔존 위험이 있어 medium 으로 판단.