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/subscriptions 와 POST /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#
- 2026-07-03 17:43:27 KST — notification-service authorizer
fetchWithRetry1 회차 abort (0009e416Fetch attempt 1/3 failed: This operation was aborted) - 2026-07-03 17:43:29 KST — retry 2 회차 abort
- 2026-07-03 17:43:30.211 KST — retry 3 회차 최종 실패, authorizer catch 블록이
Error authorizing: This operation was aborted로깅 후{ isAuthorized: false }반환 → API Gateway가 403 응답 생성 - 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"} - 2026-07-03 17:43:31.100 KST — cupixworks-api
Cupix::NotificationService#subscribe실패, rescue 블록에서e.response를 로그 message로 남김 → cluster fingerprint 대상 로그
Error Log#
Forbidden
Datadog raw payload:
{
"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
subscribe와 create_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:
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의 대표 로그 소스):
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.response는 RestClient::Response (String 서브클래스)이며 응답 본문을 문자열 값으로 갖는다. API Gateway 응답 본문이 Forbidden이므로 로그 message 필드에 Forbidden이 저장된다. HTTP status, upstream URL 등 진단 정보는 로그에 포함되지 않는다.
Retry 정책 — 403은 retry 대상이 아님:
RETRIABLE_STATUS_CODES = [429, 502, 503, 504].freeze
MAX_RETRIES = 3
Downstream authorizer (root cause 위치):
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:
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.message에 This operation was aborted가 담긴다.
기대 동작: authorizer가 Tesla API 검증 결과를 정확히 판별해서 유효한 토큰만 통과시킨다.
실제 동작: authorizer 자체가 timeout으로 실패해 유효한 토큰도 isAuthorized: false로 처리되어 API Gateway가 403 응답을 반환한다.
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-api "Forbidden"
service:cupixworks-api @function:subscribe status:error
service:cupixworks-api "Recipe creation failed"
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):
[
{"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):
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 응답 (즉 백엔드 자체는 건강):
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 건):
[
{"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-22에 timeoutMs = 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를 감당하지 못한다.
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주 이내)#
Cupix::NotificationService#subscribe의 rescue 로그를 구조화한다. 현재e.response만 로깅해서 진단이 어렵다. status code, http_code, request path, user id, facility_key를 필드로 남기고 403은 warn 레벨로 강등한다. downstream 인프라 이슈이므로 tesla 관점에서는 조작 가능한 에러가 아니다.
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 블록에도 적용한다.
- authorizer 결과를 API Gateway의
authorizerResultTtlInSeconds로 캐시하여 Tesla/api/v1/me호출 빈도와 timeout 노출을 줄인다. 동일 토큰에 대해 5-15 분 정도 캐시가 안전하다.
장기 개선 (재발 방지)#
- Lambda provisioned concurrency 또는 SnapStart 도입으로 cold start latency를 근본적으로 감축한다.
- authorizer의 실패 사유를 구분하는 응답 컨텍스트를 확장한다 (예:
context.error_reason = "upstream_timeout"). downstream 서비스가 재시도 가능 여부를 판단할 수 있어야 한다. - 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 발생률:
count:notification-service{@message:"Fetch attempt*aborted"}.as_count()
- cupixworks-api subscribe/recipe 실패 카운트:
count:cupixworks-api{@class:"Cupix::NotificationService" status:error}.as_count()
- Tesla
/api/v1/mep95 latency (authorizer 예산 확인):
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 초 범위가 적절하다.