[Integration] failed to refresh token for revizto integration(988) - state: failed, error_message: R
RCA: [Integration] failed to refresh token for revizto integration(988)
Overview#
What Happened#
2026-05-04 08:34 KST에 cupixworks-api 서비스의 ap-southeast-2 리전에서 Revizto integration(988)의 OAuth token refresh가 실패했다. Revizto API가 "Access denied"를 반환하여 integration 상태가 failed로 전환되었다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Cupix::Errors::Parameter |
| exception.message | Revizto Authentication failed: Access denied |
| top_frame | app/operations/revizto_operation.rb:108-111 |
| env | production, ap-southeast-2 |
Timeline#
- 2026-03-13 03:24:22 UTC — Integration(988)의 access_token 만료 (expired_at)
- 2026-05-03 23:34:11 UTC — Token refresh 시도 → Revizto API가 "Access denied" 반환
- 2026-05-03 23:34:11 UTC — Integration 상태
active→failed로 전환
Error Log#
[Integration] failed to refresh token for revizto integration(988) - state: failed, error_message: Revizto Authentication failed: Access denied
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-05-03T23:34:11.225Z
- 최근 발생: 2026-05-03T23:34:11.225Z
Root Cause Summary#
Revizto integration(988)의 refresh token이 Revizto 측에서 만료되거나 revoke되어 token refresh 요청 시 "Access denied"가 반환되었다. access_token은 2026-03-13에 이미 만료되었고, refresh_token_expired_at이 nil이었기 때문에 cron 자동 갱신 대상에서 제외되었다. 이후 사용자가 API를 통해 access_token을 요청(access_token 엔드포인트)할 때 expired_at < 10.minutes.since 조건에 의해 refresh가 트리거되었으나, 약 2개월간 사용되지 않은 refresh token이 Revizto 서버에서 이미 무효화된 상태였다.
이 패턴은 Revizto integration에서 반복 발생하고 있다 (integration 981, 6765, 988 — 14일 내 3건 확인).
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/integrations_controller.rb:35-38— 사용자가 access token을 요청 - Token 만료 체크:
app/repositories/integration_repository.rb:90—expired_at < 10.minutes.since조건으로 refresh 트리거
def access_token
check_failed_state('Failed to get access token')
# OPC uses 2-step token retrieval process
if @model.provider == 'opc'
return opc_access_token
end
# Skydio uses API token directly, no refresh needed
if @model.provider == 'skydio'
return skydio_api_token
end
# Existing logic for other providers
if @model.expired_at < 10.minutes.since
refresh_token
else
{
provider: @model.provider,
access_token: @model.access_token,
token_type: @model.token_type,
expired_at: @model.expired_at,
region: @model.region
}
end
end
- Refresh 시도:
app/repositories/integration_repository.rb:128—ReviztoOperation.refresh_token호출
begin
unless check_refresh_request
raise Cupix::Errors::Parameter.new(code: 'ARG10060', reason: 'Refresh access token request is too many')
end
token = operation_class.refresh_token(@model.refresh_token, @model.region)
uncheck_refresh_request
rescue StandardError => e
uncheck_refresh_request
raise e if e.code == 'ARG10060'
@model.refresh_token_failed_at = DateTime.now
@model.refresh_token_expired_at = nil
@model.refresh_token_response_body = token
@model.failed_state!
Cupix::Logger.error("[Integration] failed to refresh token for #{@model.provider} integration(#{@model.id}) - state: #{@model.state}, error_message: #{e.message}")
raise e
end
- Failure point:
app/operations/revizto_operation.rb:104-111— Revizto API가RestClient::Exception(Access denied) 반환
def self.refresh_token(refresh_token, region)
data = {
grant_type: 'refresh_token',
refresh_token: refresh_token
}
begin
url = "https://api.#{region}.revizto.com#{$OAUTH[:revizto][:refresh_url]}"
response = RestClient.post(url, data)
raise RestClient::Exception, response unless response.body.include?('"token_type"') &&
response.body.include?('"access_token"') &&
response.body.include?('"refresh_token"') &&
response.body.include?('"expires_in"')
rescue RestClient::Exception => e
response = JSON.parse(e.response)
Cupix::Logger.warn("[ARG10000] Revizto refresh_token failed: #{response['message']} - error: #{e.message}", class: self.name, function: __method__)
raise Cupix::Errors::Parameter.new(
code: 'ARG10000',
reason: "Revizto Authentication failed: #{response['message']}"
)
rescue StandardError => e
Cupix::Logger.error("Revizto Authentication failed: #{e.message}", class: self.name, function: __method__)
raise Cupix::Errors::BadGateway.new(
code: 'BG10001',
reason: "Revizto Authentication failed: #{e.message}",
message: e.message
)
end
JSON.parse(response)
end
- 상태 전환:
app/repositories/integration_repository.rb:138—@model.failed_state!로 integration 상태를failed로 변경
scope :refresh_token_due_to_expire, -> { not_failed.where.not(refresh_token_expired_at: nil).where('refresh_token_expired_at < ?', 1.days.since) }
기대 동작: Revizto API가 새로운 access_token과 refresh_token을 반환 실제 동작: Revizto API가 "Access denied"를 반환 — refresh token이 서버 측에서 무효화됨
Log Evidence#
사용한 쿼리:
service:cupixworks-api "revizto" "988"
Time range: 2026-05-03T22:00:00Z to 2026-05-04T01:00:00Z
Info 로그 (refresh 시도 직전):
[Integration] refresh token for revizto integration(988) - state: active, expired_at: 2026-03-13 03:24:22 UTC, refresh_token_expired_at:
Error 로그 (refresh 실패):
[Integration] failed to refresh token for revizto integration(988) - state: failed, error_message: Revizto Authentication failed: Access denied
14일 내 동일 패턴 (다른 integration):
service:cupixworks-api status:error "revizto"
Time range: 2026-04-20T00:00:00Z to 2026-05-04T23:59:00Z
2026-05-04 08:34:11 KST - [Integration] failed to refresh token for revizto integration(988) - state: failed, error_message: Revizto Authentication failed: Access denied
2026-04-29 23:07:47 KST - [Integration] failed to refresh token for revizto integration(6765) - state: failed, error_message: Revizto Authentication failed: Access denied
2026-04-22 10:44:14 KST - [Integration] failed to refresh token for revizto integration(981) - state: failed, error_message: Revizto Authentication failed: Access denied
핵심 관찰:
expired_at: 2026-03-13— access token이 약 2개월 전에 이미 만료refresh_token_expired_at:(nil) — cron 자동 갱신 대상에서 제외됨state: active→state: failed— 즉시 failed로 전환
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Revizto 측에서 오랫동안 사용되지 않은 refresh token을 만료/revoke 처리 | access_token이 2026-03-13에 만료되어 약 2개월간 미사용; Revizto가 "Access denied" 반환; 다른 integration(981, 6765)에서도 동일 패턴 반복 | — | Confirmed |
| H2 | Revizto API의 일시적 장애 (transient failure) | — | 14일 내 3건이 서로 다른 날짜에 발생 (일시적 장애라면 같은 시간대에 집중됨); 각 integration이 개별적으로 "Access denied" 반환 | Rejected |
| H3 | Cron job이 refresh를 제때 수행하지 못해 token 만료 | refresh_token_expired_at이 nil이어서 cron 대상에서 제외됨 (코드: not_failed.where.not(refresh_token_expired_at: nil)) |
Revizto는 TOKEN_REFRESH_REQUIRED_PROVIDERS에 포함되지 않아 refresh_token_expired_at이 설정되지 않음 — 설계에 의한 것 |
Confirmed (contributing factor) |
Fix Recommendation#
즉시 조치 (Critical)#
- 이 에러는 사용자 조치 필요 사안이다. Integration(988)의 사용자가 Revizto에서 재인증해야 한다.
- 에러 로그 레벨을
error에서warn으로 변경 검토: 외부 서비스의 인증 만료는 시스템 버그가 아닌 운영 상황이다.- 파일:
app/repositories/integration_repository.rb:140
- 파일:
단기 개선 (1주 이내)#
- Revizto를
TOKEN_REFRESH_REQUIRED_PROVIDERS에 추가하여refresh_token_expired_at을 설정하고, cron이 만료 전에 자동 갱신하도록 한다. 이렇게 하면 장기 미사용으로 인한 refresh token 만료를 방지할 수 있다.- 파일:
app/repositories/integration_repository.rb:145-149
- 파일:
- Integration 상태가
failed로 전환될 때 사용자에게 알림(이메일 또는 in-app notification)을 보내도록 한다. 현재는 silent fail이어서 사용자가 다시 시도할 때까지 모른다.
장기 개선 (재발 방지)#
- 모든 OAuth integration provider에 대해 proactive token refresh 정책을 통일한다. 현재 bim360만
TOKEN_REFRESH_REQUIRED_PROVIDERS에 포함되어 있고, 다른 provider(revizto, procore, plangrid)는 사용자 요청 시점에만 refresh가 발생한다. - 장기 미사용 integration에 대한 health check 메커니즘을 도입하여, token 만료 위험이 있는 integration을 사전에 감지하고 사용자에게 알린다.
Monitoring#
- Revizto token refresh 실패 추이를 추적하는 Datadog 모니터:
service:cupixworks-api status:error "failed to refresh token" "revizto"
- Integration 상태
failed전환 건수를 카운트하는 메트릭:
service:cupixworks-api "failed_state!" @class:IntegrationRepository
Risk Assessment#
- Risk level: low
- 예상 복잡도: trivial — 단일 사용자의 외부 서비스 인증 만료. 시스템 전체에 영향 없음. 사용자 재인증으로 해결 가능.