본문으로 건너뛰기

ADR-008: 네트워크 회복 탄력성 패턴

상태: Accepted (2026-04-20 Proposed → Accepted 승격. map_reqwest_error / extract_retry_after / circuit breaker / backoff 모두 ship 완료. maekon-network/src/{http_client,resilience,sync/remote_transport,integration/http_transport/mod,ai_llm_client/request}.rs + gRPC 에러 매핑에 사용 중) 날짜: 2026-03-09 범위: maekon-network crate, 네트워크 대응 모든 어댑터

예시 스니펫의 CoreError 문법 안내: 아래 예시는 ADR-019 이전 문법 CoreError::RateLimit { retry_after_secs } 을 사용합니다. ADR-019 이후에는 typed code 필드를 가진 struct variant 로 작성해야 합니다:

CoreError::RateLimit {
code: maekon_core::error_codes::NetworkCode::RateLimit,
retry_after_secs,
}

회복 탄력성 패턴 자체(retry/backoff/circuit-breaker/retry-after 파싱)는 ADR-019 로 변경되지 않습니다.


배경

데스크톱 에이전트는 연결된 서버와 HTTP REST, SSE, WebSocket, gRPC 로 통신합니다. 데스크톱 환경은 서버 프로세스가 절대 보지 못하는 네트워크 장애를 만들어냅니다 — WiFi 단절, VPN 재연결, sleep/wake 사이클, 롤링 서버 배포. 에이전트는 버퍼링된 데이터를 잃거나 복구 중인 서버를 압도하지 않으면서 이를 처리해야 합니다.

본 ADR 이 막는 gap 을 드러낸 점진적 수정 3건:

Pivot commit날짜경로발견 사항
b13a46b2026-02-28http_client.rsRequestTimeout + is_retryable: backoff 존재, jitter 부재
ffa24782026-03-01batch_uploader.rsQueue OOM 수정. flush retry 추가했으나 circuit breaker 부재
50ac66b2026-03-08sse_client.rsSSE reconnect 루프 추가. jitter 부재

결정

1. Jitter 가 적용된 지수 백오프

규칙: 모든 재시도 루프는 jitter 가 적용된 지수 백오프를 반드시 사용해야 합니다. 설정 가능한 maximum 으로 cap 합니다.

// 지수 백오프 + 지터 계산
fn backoff_delay(attempt: u32, base_ms: u64, max_ms: u64) -> Duration {
let exp = base_ms.saturating_mul(2u64.saturating_pow(attempt.min(10)));
let jitter = rand::thread_rng().gen_range(0..=(exp / 4));
Duration::from_millis((exp + jitter).min(max_ms))
}

현재 상태:

위치상태액션
HttpApiClient::execute_with_retry()Backoff 있음, jitter 없음backoff_delay() 사용
SseStreamClient::connect()Backoff (retry_delay * 2), jitter 없음backoff_delay() 사용
BatchUploader::flush()Backoff 있음, jitter 없음backoff_delay() 사용

기본 cap: SSE/HTTP 30 초, batch flush 60 초. jitter 가 없으면 동시에 끊긴 모든 클라이언트가 동일 timestamp 에 재연결되어 복구 중인 서버 부하를 spike 시킵니다.


2. Token Refresh 중복 제거

규칙: refresh 요청은 동시에 1건만 in-flight 가능합니다. needs_refresh = true 를 본 동시 호출자들은 진행 중인 refresh 를 반드시 기다려야 합니다.

auth.rs 의 현재 문제: 모든 호출자가 RwLock guard 를 release 한 뒤 개별적으로 refresh() 를 호출해 N 개의 병렬 POST 요청을 발사합니다.

요구되는 패턴 — AtomicBool + Notify:

pub struct TokenManager {
state: Arc<RwLock<Option<TokenState>>>,
refreshing: AtomicBool, // 리프레시 진행 중 여부
refresh_notify: Arc<Notify>, // 완료 시 대기 태스크 일괄 깨움
client: reqwest::Client,
base_url: String,
}

pub async fn get_token(&self) -> Result<String, CoreError> {
if self.refreshing.load(Ordering::Acquire) {
self.refresh_notify.notified().await;
}

let needs_refresh = { /* expiry check via RwLock */ };
if needs_refresh {
if self.refreshing
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
let result = self.do_refresh().await;
self.refreshing.store(false, Ordering::Release);
self.refresh_notify.notify_waiters();
result?;
} else {
self.refresh_notify.notified().await; // 다른 태스크가 리프레시 중
}
}
// state RwLock에서 토큰 반환
}

refresh_notifyArc<Notify> — 모든 TokenManager clone 이 공유합니다.


3. 서킷 브레이커

규칙: 반복적 실패를 겪는 네트워크 클라이언트는 복구 중인 서버를 압도하지 않도록 서킷 브레이커를 반드시 구현해야 합니다.

상태: Closed (정상) → Open (요청 차단) → Half-Open (probe).

/// 서킷 브레이커 — 연속 장애 시 요청 차단
pub struct CircuitBreaker {
state: AtomicU8, // 0=Closed, 1=Open, 2=HalfOpen
failure_count: AtomicU32,
failure_threshold: u32, // 기본값: 5
recovery_timeout: Duration, // 기본값: 30 s
last_failure_ms: AtomicU64, // Unix ms 타임스탬프
}

범위 (2026-03-09 원안): BatchUploader 에 적용. flush 경로는 현재 호출당 max_retries 회 재시도하며 스케줄러 tick 간 메모리가 없어, 영구 장애인 서버를 5초마다 두드릴 가능성이 있음.

HttpApiClient::execute_with_retry() 는 이미 호출당 bound 가 있어 면제됨.

범위 업데이트 2026-04-20 (D7 broadening): 브레이커가 이제 RemoteEmbeddingProvider, AnalysisClient, RemoteOcrProvider, RemoteLlmProvider, HttpApiSession 까지 보호합니다. 이 5개 어댑터는 공유 CircuitBreakerRegistry (key: scheme://host:port) 를 통해 endpoint 별 브레이커를 resolve 하므로, 동일 endpoint 를 타게팅하는 다중 어댑터(예: 다른 모델을 쓰는 두 OpenAI 클라이언트)가 하나의 브레이커로 수렴합니다. 원본 circuit-breaker broadening 설계는 내부 기획 artifact 로 보관되며 public-minimal export 에는 포함되지 않습니다.

분류는 resilience::classify_for_breaker 에 중앙화:

  • 5xx / transport / 401 / 429 → Failure (endpoint 건강도)
  • 2xx → Success
  • 그 외 4xx (400, 404, 422) → Neutral — 호출자 버그. 동일 endpoint 의 다른 호출자를 위해 공유 브레이커를 trip 시켜서는 안 됨

스트리밍 세션(HttpApiSession)은 3-tier 의미론을 사용: 초기 HTTP status 가 브레이커를 구동하고, 스트림 중간 disconnect 는 기록하지 않음. 이는 BatchUploader 의 "서버가 ack 했음" = success 패턴과 일치.

ai_ocr_client::ensure_runtime_ocr_model_ready 의 Ollama 모델 capability probe 는 의도적으로 wrap 하지 않음 — 요청당 1회 발사되는 sidecar 호출은 범위 외이며, main OCR 전송이 브레이커 상태를 구동.

Integration transport (sync/remote_transport, integration/http_transport) 는 deferred 상태 — breaker 배치 결정(어댑터 레이어 vs port-trait 레이어)은 port-trait 라운드 대기 중인 자체 follow-up.


4. Rate Limit Header 파싱

규칙: HTTP 429 응답은 Retry-After 헤더를 반드시 파싱해야 합니다. 헤더가 부재할 때만 하드코딩 fallback 이 허용됩니다.

http_client.rs 의 현재 문제:

// 현재: Retry-After 헤더 무시, 60초 하드코딩
429 => Err(CoreError::RateLimit { retry_after_secs: 60 }),

요구되는 교체:

/// 429 응답의 Retry-After 헤더를 파싱한다. 부재/파싱 실패 시 60초 기본값 반환.
fn extract_retry_after(response: &reqwest::Response) -> u64 {
response.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(60)
}

429 => Err(CoreError::RateLimit { retry_after_secs: extract_retry_after(&resp) }),

execute_with_retry() 는 이미 retry_after_secs 로 delay 를 override 하므로 그쪽은 추가 변경 불필요.


결과

Must do (신규 네트워크 코드 머지 차단 기준):

  1. backoff_delay()maekon-network/src/resilience.rs 에 도입되어 모든 인라인 delay 계산을 교체.
  2. extract_retry_after()check_response 의 하드코딩 60 을 교체.
  3. TokenManagerAtomicBool + Arc<Notify> 를 추가해 refresh 를 중복 제거.

Should do (다음 sprint):

  1. CircuitBreakerresilience.rs 에 구현되고 BatchUploader 에 wire.
  2. 각 패턴별 unit test: jitter 범위, single-refresh assertion, circuit 상태 전이, 헤더 fallback.

제약: 새로운 워크스페이스 의존성 불필요. rand 는 이미 maekon-vision 을 통해 존재. 모든 변경은 maekon-network 내에 한정 — ADR-001 §6 의 crate 의존성 규칙과 일관.