ES /docs

[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#

  1. 2026-03-13 03:24:22 UTC — Integration(988)의 access_token 만료 (expired_at)
  2. 2026-05-03 23:34:11 UTC — Token refresh 시도 → Revizto API가 "Access denied" 반환
  3. 2026-05-03 23:34:11 UTC — Integration 상태 activefailed로 전환

Error Log#

Datadog Logs

text
[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:90expired_at < 10.minutes.since 조건으로 refresh 트리거
app/repositories/integration_repository.rb:76-101ruby
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:128ReviztoOperation.refresh_token 호출
app/repositories/integration_repository.rb:123-143ruby
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) 반환
app/operations/revizto_operation.rb:89-122ruby
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로 변경
app/models/concerns/statable/integration.rb:12ruby
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#

사용한 쿼리:

text
service:cupixworks-api "revizto" "988"
Time range: 2026-05-03T22:00:00Z to 2026-05-04T01:00:00Z

Info 로그 (refresh 시도 직전):

text
[Integration] refresh token for revizto integration(988) - state: active, expired_at: 2026-03-13 03:24:22 UTC, refresh_token_expired_at:

Error 로그 (refresh 실패):

text
[Integration] failed to refresh token for revizto integration(988) - state: failed, error_message: Revizto Authentication failed: Access denied

14일 내 동일 패턴 (다른 integration):

text
service:cupixworks-api status:error "revizto"
Time range: 2026-04-20T00:00:00Z to 2026-05-04T23:59:00Z
text
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: activestate: 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 모니터:
text
service:cupixworks-api status:error "failed to refresh token" "revizto"
  • Integration 상태 failed 전환 건수를 카운트하는 메트릭:
text
service:cupixworks-api "failed_state!" @class:IntegrationRepository

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial — 단일 사용자의 외부 서비스 인증 만료. 시스템 전체에 영향 없음. 사용자 재인증으로 해결 가능.