ES /docs

CPUtils#fetchWithRetry per-attempt timeout too low — 1000ms

RCA: Cupix::NotificationService#subscribe returns Forbidden

Overview#

Cupix::NotificationService#subscribe가 downstream notification-service API Gateway로부터 HTTP 403을 받고 rescue 블록에서 Forbidden 에러를 로깅한 사건. 실제 원인은 notification-service의 Lambda authorizer가 Tesla /api/v1/me 호출을 timeoutMs = 1000으로 3 회 연속 abort한 뒤 { isAuthorized: false }를 반환한 것이다. 같은 request 안에서 3 건의 create_user_recipe도 동일한 403을 받아 총 4 건의 downstream 실패가 발생했다.

What Happened#

2026-07-03 17:43:31 KST 프로덕션 cupixworks-api (us-west-2)에서 pub/sub subscriber가 facility_permission 이벤트 처리 중 notification-service의 PUT /api/v1/subscriptionsPOST /api/recipes/v2를 호출했으나 모두 API Gateway 403을 받았다. 동시간대 notification-service Lambda authorizer 로그에서 Fetch attempt 1/3 ~ 3/3 failed: This operation was aborted가 관측되며, 최종적으로 Error authorizing: This operation was aborted가 남아 authorizer가 isAuthorized: false를 리턴했다. 같은 authorizer timeout 패턴은 최근 24 시간 동안 notification-service에서 반복적으로 관측된다.

Quick Facts#

Field Value
exception.class RestClient::Forbidden (RestClient::ExceptionWithResponse 서브클래스)
exception.message Forbidden (API Gateway 응답 본문 원문)
top_frame lib/cupix/notification_service.rb:89 (subscribe rescue)
runtime Ruby on Rails, tesla monolith
deploy production-us-west-2-20260703T0821Z0-13e7c827-cupixworks
env production, region:us-west-2
host ip-10-1-19-190.us-west-2.compute.internal (pid 14912)
downstream notification-service (Lambda + API Gateway HTTP API v2)
tesla request_id 735d1c43-3382-4ba9-8033-c2d59b416e84
authorizer request_id 0009e416-68a5-40cc-bc9b-6d7a5625ee88
facility_key p412bz

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (Cupix::NotificationService) 4 (1 subscribe + 3 recipe) Facility p412bz의 accessor user 알림 구독 및 3 종 이메일 레시피(record_preview_ready, record_processing_completed, facility_new_project) 생성 실패
notification-service (authorizer) 200+ (14 d) Tesla /api/v1/me fetch timeout으로 authorizer가 유효한 토큰을 거절, 다수 요청이 403으로 종료

Timeline#

  1. 2026-07-03 17:43:27 KST — notification-service authorizer fetchWithRetry 1 회차 abort (0009e416 Fetch attempt 1/3 failed: This operation was aborted)
  2. 2026-07-03 17:43:29 KST — retry 2 회차 abort
  3. 2026-07-03 17:43:30.211 KST — retry 3 회차 최종 실패, authorizer catch 블록이 Error authorizing: This operation was aborted 로깅 후 { isAuthorized: false } 반환 → API Gateway가 403 응답 생성
  4. 2026-07-03 17:43:31.099 KST — cupixworks-api Cupix::NotificationService#create_user_recipe × 3 실패 (recipe_name = record_preview_ready, record_processing_completed, facility_new_project), error_response: {"message":"Forbidden"}
  5. 2026-07-03 17:43:31.100 KST — cupixworks-api Cupix::NotificationService#subscribe 실패, rescue 블록에서 e.response를 로그 message로 남김 → cluster fingerprint 대상 로그

Error Log#

Datadog Logs

text
Forbidden

Datadog raw payload:

json
{
  "level": "error",
  "class": "Cupix::NotificationService",
  "function": "subscribe",
  "facility_key": "p412bz",
  "request_id": "735d1c43-3382-4ba9-8033-c2d59b416e84",
  "si_trace_id": "735d1c43-3382-4ba9-8033-c2d59b416e84",
  "environment": "production",
  "region": "us-west-2",
  "message": "Forbidden",
  "@timestamp": "2026-07-03T08:43:31.100Z",
  "dd.version": "production-us-west-2-20260703T0821Z0-13e7c827-cupixworks"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1 (cluster fingerprint 기준). 동일 request 안에서 recipe 실패 3 건이 함께 발생하여 실 downstream 실패는 4 건. 최근 14 일 service:cupixworks-api @function:subscribe status:error 기준 3 건 반복 (6/20, 6/20, 7/3)
  • 최초 발생: 2026-07-03 17:43:31 KST
  • 최근 발생: 2026-07-03 17:43:31 KST

subscribecreate_user_recipe는 모두 rescue 후 false를 반환하며 subscriber base의 rescue StandardError가 상위 예외를 삼킨다. 따라서 부모 API 요청은 200으로 종료되고, accessor user는 Facility 구독 및 3 종 이메일 알림이 조용히 미생성된다. 사용자와 caller 모두 실패를 인지하지 못하는 silent data-integrity 결함이다.

Root Cause Summary#

notification-service의 Lambda authorizer(applications/notification-service/src/lambda/authorizer.ts)는 요청 토큰을 Tesla /api/v1/me로 검증한다. 검증에 사용하는 CPUtils.fetchWithRetry(url, opts, retries=3, timeoutMs=1000)의 per-attempt timeout이 1 초로 매우 짧아, Lambda cold start·DNS resolution·TLS handshake 오버헤드가 겹치면 Tesla가 서버에서 200을 정상 반환하더라도 authorizer 쪽에서 AbortError가 발생한다. 3 회 모두 실패하면 authorizer가 isAuthorized: false를 반환하고 API Gateway HTTP API v2가 응답 본문 Forbidden으로 403을 리턴한다. cupixworks-api는 이를 RestClient::ExceptionWithResponse로 rescue 하여 에러 로그를 남기지만 실제 원인은 downstream 서비스의 aggressive timeout이지 인증 자격 문제가 아니다.

Technical Analysis#

Code Path#

Entry point (Ruby, cupixworks-api) — facility_permission 이벤트 subscriber:

lib/cupix/pub_sub/subscribers/subscription_generator.rb:11-22ruby
def _create_subscriptions_by_facility_permission(model)
  return false unless model.accessor_type == ::User.name

  user = model.accessor
  facility_key = model.facility.key

  _create_subscription(user: user, facility_key: facility_key)
end

def _create_subscription(opts = {})
  Cupix::NotificationService.new(user: opts[:user]).subscribe(facility_key: opts[:facility_key])
end

Failure point A (cluster의 대표 로그 소스):

lib/cupix/notification_service.rb:70-93ruby
def subscribe(facility_key: nil)
  return if @service_url.nil?

  begin
    response = Cupix::HttpClient.put(
      "#{@service_url}/api/v1/subscriptions",
      {
        model_type: 'Facility',
        model_id: facility_key
      }.to_json,
      {
        content_type: :json,
        'x-cupix-auth': @user.api_token
      }
    )
    Cupix::Logger.info("subscription of #{@user.email} on Facility #{facility_key} created", class: self.class.name, function: __method__, facility_key: facility_key)

    true
  rescue RestClient::ExceptionWithResponse => e
    Cupix::Logger.error(e.response, class: self.class.name, function: __method__, facility_key: facility_key)

    false
  end
end

e.responseRestClient::Response (String 서브클래스)이며 응답 본문을 문자열 값으로 갖는다. API Gateway 응답 본문이 Forbidden이므로 로그 message 필드에 Forbidden이 저장된다. HTTP status, upstream URL 등 진단 정보는 로그에 포함되지 않는다.

Retry 정책 — 403은 retry 대상이 아님:

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

Downstream authorizer (root cause 위치):

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

const startTime = Date.now();
console.log('Starting Tesla API fetch with retry...');

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

console.log(`Tesla API responded in ${Date.now() - startTime}ms`);
console.log(`response: ${JSON.stringify(response)}`);

if (response.status !== 200) {
  throw new Error('Unauthorized; permission denied');
}

const data = (await response.json())?.result?.data;
return { isAuthorized: true, context: { ...data } };
// catch:
console.log('Error authorizing: ', error.message);
return { isAuthorized: false };

CPUtils.fetchWithRetry의 default timeout:

applications/notification-service/src/libs/common/utils.ts:18-48typescript
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');
}

authorizer는 4 번째 인자 없이 fetchWithRetry를 호출하므로 default timeoutMs = 1000이 적용된다. 각 attempt는 1 초 + 100 ms/200 ms backoff으로 최악 약 2.5 초 안에 3 회 시도를 마친다. Lambda cold start (첫 invocation 200-800 ms), fresh TCP/TLS 세션(100-300 ms), Tesla 백엔드 latency (50-500 ms)를 합치면 1 초 budget이 부족하다. AbortController.abort()가 fetch를 취소하면 error.messageThis operation was aborted가 담긴다.

기대 동작: authorizer가 Tesla API 검증 결과를 정확히 판별해서 유효한 토큰만 통과시킨다.

실제 동작: authorizer 자체가 timeout으로 실패해 유효한 토큰도 isAuthorized: false로 처리되어 API Gateway가 403 응답을 반환한다.

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-api "Forbidden"
text
service:cupixworks-api @function:subscribe status:error
text
service:cupixworks-api "Recipe creation failed"
text
service:notification-service ("Fetch attempt" OR "Error authorizing")

같은 request 안 4 건의 downstream 실패 (모두 si_trace_id: 735d1c43-3382-4ba9-8033-c2d59b416e84, facility_key: p412bz, @timestamp: 2026-07-03T08:43:31.099-.100Z):

json
[
  {"function":"create_user_recipe","recipe_name":"record_preview_ready","error_response":"{\"message\":\"Forbidden\"}","message":"Recipe creation failed"},
  {"function":"create_user_recipe","recipe_name":"record_processing_completed","error_response":"{\"message\":\"Forbidden\"}","message":"Recipe creation failed"},
  {"function":"create_user_recipe","recipe_name":"facility_new_project","error_response":"{\"message\":\"Forbidden\"}","message":"Recipe creation failed"},
  {"function":"subscribe","message":"Forbidden"}
]

notification-service authorizer 원문 로그 (Lambda request id 0009e416-68a5-40cc-bc9b-6d7a5625ee88, 08:43:27-30 UTC):

text
2026-07-03T08:43:27.909Z  ERROR  Fetch attempt 1/3 failed: This operation was aborted
2026-07-03T08:43:29.010Z  ERROR  Fetch attempt 2/3 failed: This operation was aborted
2026-07-03T08:43:30.211Z  ERROR  Fetch attempt 3/3 failed: This operation was aborted
2026-07-03T08:43:30.211Z  INFO   Error authorizing:  This operation was aborted

같은 시각(±3 초) Tesla /api/v1/me는 정상 200 응답 (즉 백엔드 자체는 건강):

text
2026-07-03 17:43:26  info  [200] GET /api/v1/me (Api::V1::MeController#show)
2026-07-03 17:43:29  info  [200] GET /api/v1/me (Api::V1::MeController#show)
2026-07-03 17:43:32  info  [200] GET /api/v1/me (Api::V1::MeController#show)
2026-07-03 17:43:33  info  [200] GET /api/v1/me (Api::V1::MeController#show)

Authorizer timeout 규모 (service:notification-service 최근 24 h): Fetch attempt N/3 failed: This operation was aborted가 반복적으로 관측 (다수 건). 즉 이 클러스터는 broader 인프라 이슈의 개별 발현이다.

과거 재발 (service:cupixworks-api @function:subscribe status:error 최근 14 일, 총 3 건):

json
[
  {"timestamp":"2026-07-03 17:43:31","status":"error","message":"Forbidden","class":"Cupix::NotificationService","function":"subscribe"},
  {"timestamp":"2026-06-20 04:55:43","status":"error","message":"Forbidden","class":"Cupix::NotificationService","function":"subscribe"},
  {"timestamp":"2026-06-20 04:55:03","status":"error","message":"Forbidden","class":"Cupix::NotificationService","function":"subscribe"}
]

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 notification-service authorizer의 Tesla /api/v1/me 호출이 1 초 timeout으로 3 회 abort → API Gateway가 403 반환 동시간대 authorizer 로그 3 회 Fetch attempt X/3 failed: This operation was aborted (08:43:27, 29, 30 UTC), Error authorizing: This operation was aborted; applications/notification-service/src/libs/common/utils.ts:18-22timeoutMs = 1000 default; authorizer는 override 없이 호출; 같은 시각 Tesla /me는 200 정상 응답이므로 백엔드 문제 아님 없음 Confirmed
H2 accessor user의 api_token이 nil이거나 만료돼 authorizer가 즉시 거절 header 값이 nil이면 authorizer가 Unauthorized; token not found를 throw하는 경로가 존재 (authorizer.ts:12-14) 실제 authorizer 로그는 token not found가 아니라 This operation was aborted. 즉 헤더에는 값이 있었고 검증 자체가 timeout으로 종료됨 Rejected
H3 /me가 non-200 (예: 401)을 리턴해 authorizer가 Unauthorized; permission denied throw authorizer 코드 상 non-200이면 이 메시지가 남아야 함 (authorizer.ts:31-33) 실제 로그는 This operation was aborted이며 이 메시지는 AbortController.abort() 경로에서만 발생. 또한 같은 시각 Tesla /me는 200 정상 응답 로그가 다수 존재 Rejected
H4 notification-service 자체 outage / dependency 장애 status-board는 dep:* 스코프 없음, svc:cupixworks-api::unknown 스코프만 존재 dep 스코프 아님이 확인됨; API Gateway 응답 자체는 정상적으로 반환됨 (503 아님) Rejected
H5 사용자가 실제로 facility에 대한 subscribe 권한이 없어 정당한 403 없음 notification-service의 subscribe 엔드포인트는 authorizer 통과 후 별도 권한 검사 없이 create_or_update 수행 (applications/notification-service/src/services/watch.service.ts:38-48). 403은 오직 authorizer 실패 시에만 발생 Rejected
H6 Dev/QA에서 관측된 private method service_jwt called for class Cupix::NotificationService와 동일 원인 같은 클래스, 같은 subscriber(UserRecipeGenerator) 트리거 프로덕션 로그에는 service_jwt 관련 메시지가 없음. service_jwt는 dev 코드경로에서 발생하는 별개의 NoMethodError로 이 프로덕션 HTTP 403 인시던트와 독립적 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

notification-service authorizer의 fetch timeout 상향. applications/notification-service/src/lambda/authorizer.ts:19 호출부에서 timeoutMs를 명시적으로 3000-5000 ms로 넘기거나, applications/notification-service/src/libs/common/utils.ts:22의 default를 상향한다. 1 초는 Lambda cold start + TLS handshake + Tesla 요청 처리 latency를 감당하지 못한다.

applications/notification-service/src/lambda/authorizer.ts:19-26
     const response = await CPUtils.fetchWithRetry(       `https://api.${DOMAIN_NAME}/api/v1/me?fields=id,firstname,lastname,email,team`,       {         headers: {           'x-cupix-auth': token         }-      }+      },+      3,+      5000     );

단기 개선 (1주 이내)#

  1. Cupix::NotificationService#subscribe의 rescue 로그를 구조화한다. 현재 e.response만 로깅해서 진단이 어렵다. status code, http_code, request path, user id, facility_key를 필드로 남기고 403은 warn 레벨로 강등한다. downstream 인프라 이슈이므로 tesla 관점에서는 조작 가능한 에러가 아니다.
lib/cupix/notification_service.rb:88-92
   rescue RestClient::ExceptionWithResponse => e-    Cupix::Logger.error(e.response, class: self.class.name, function: __method__, facility_key: facility_key)+    log_method = e.http_code == 403 ? :warn : :error+    Cupix::Logger.public_send(+      log_method,+      "notification-service subscribe failed: #{e.http_code} #{e.response.body}",+      class: self.class.name,+      function: __method__,+      facility_key: facility_key,+      http_code: e.http_code,+      user_id: @user&.id+    )     false   end

동일 논리를 create_user_recipe, delete_user_recipe, unsubscribe rescue 블록에도 적용한다.

  1. authorizer 결과를 API Gateway의 authorizerResultTtlInSeconds로 캐시하여 Tesla /api/v1/me 호출 빈도와 timeout 노출을 줄인다. 동일 토큰에 대해 5-15 분 정도 캐시가 안전하다.

장기 개선 (재발 방지)#

  1. Lambda provisioned concurrency 또는 SnapStart 도입으로 cold start latency를 근본적으로 감축한다.
  2. authorizer의 실패 사유를 구분하는 응답 컨텍스트를 확장한다 (예: context.error_reason = "upstream_timeout"). downstream 서비스가 재시도 가능 여부를 판단할 수 있어야 한다.
  3. pub/sub subscriber의 rescue StandardError가 downstream 실패를 조용히 삼키는 구조에 dead-letter를 도입한다. Cupix::PubSub::Subscribers::Base#call이 재시도 큐 (Sidekiq) 또는 실패 테이블로 push하도록 개선하면 이번 사례처럼 authorizer 일시 실패로 인해 알림 세팅이 영구 소실되는 문제를 회복 가능하게 만든다.

Monitoring#

Dashboard timeseries widget용 쿼리 (모두 count/p95 aggregation, monitor-only 문법 없음):

  • notification-service authorizer timeout 발생률:
text
count:notification-service{@message:"Fetch attempt*aborted"}.as_count()
  • cupixworks-api subscribe/recipe 실패 카운트:
text
count:cupixworks-api{@class:"Cupix::NotificationService" status:error}.as_count()
  • Tesla /api/v1/me p95 latency (authorizer 예산 확인):
text
p95:trace.rack.request.duration{service:cupixworks-api resource_name:"api::v1::mecontroller#show"}

임계값 제안: authorizer timeout이 5 분 동안 10 회 이상이면 warn 알림, /api/v1/me p95가 800 ms 이상 지속되면 timeoutMs 상향 필요 신호.

Risk Assessment#

  • Risk level: medium — 부모 API 요청이 200으로 종료되므로 즉각적 사용자 대면 실패는 없지만, accessor user 알림 구독과 3 종 이메일 레시피가 조용히 누락되어 후속 이벤트 알림을 수신하지 못한다. 반복 재발 확인 (6/20 × 2, 7/3).
  • 예상 복잡도: standard — fix 위치가 notification-service 저장소의 timeout 상수와 tesla 로그 개선이라 blast radius가 제한적이다. 다만 timeoutMs를 과도하게 늘리면 API Gateway 통합 timeout(기본 29 초) 안에서 auth 지연이 사용자 요청 latency로 전가되므로 3-5 초 범위가 적절하다.