네트워크 요청이 실패했다는 한 문장만으로는 운영 결정을 내릴 수 없습니다. DNS 해석이 실패한 요청과 서버가 503을 보낸 요청은 관찰 방법도, 재시도 조건도 다릅니다. 사용자가 중단한 요청을 timeout으로 기록하면 더 심각합니다. 정상적인 취소가 지연 장애로 집계되기 때문입니다.
이 글은 17편의 typed error 경계와 26편의 timeout 정책을 이어서 다룹니다. Rust 2024 edition, rustc·Cargo 1.98.1, Tokio 1.53.1을 사용하며 외부 네트워크 대신 scripted transport로 실패 경계를 재현합니다. 목표는 DNS, connection, TLS, HTTP status, timeout, cancellation을 안정적인 도메인 오류로 바꾸면서 한 endpoint의 실패가 다른 결과를 지우지 않게 하는 것입니다.
1. 실패가 발생한 단계를 보존합니다
DNS, TCP connection, TLS handshake는 모두 응답을 받기 전 단계이지만 같은 실패는 아닙니다. HTTP status는 더 다릅니다. 서버가 응답했고 status code까지 전달했다는 뜻입니다. transport 라이브러리의 단일 오류 타입만 위로 올리면 이 경계가 호출부의 문자열 검사로 밀려납니다.
1.1. 공개 계약에는 안정적인 enum을 둡니다
예제의 DomainError는 application이 판단에 사용하는 여섯 범주만 공개합니다. 특정 HTTP client의 오류 타입을 variant에 넣지 않았습니다. client를 교체해도 match arm과 metric label을 유지하려는 선택입니다.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DomainError {
Dns,
Connect,
Tls,
HttpStatus { status: u16 },
Timeout,
Cancelled,
}
HttpStatus에는 원래 status를 남깁니다. 404와 503을 모두 status failure로 묶어 집계할 수 있으면서 필요할 때 서로 다른 정책을 적용할 수 있습니다. 반대로 DNS와 TLS를 하나의 Transport variant로 합치면 안정적인 enum을 만든 의미가 줄어듭니다.
1.2. 진단 문자열과 분류값의 책임을 나눕니다
안정적인 분류가 저수준 문맥을 버려야 한다는 뜻은 아닙니다. EndpointReport는 DomainError와 transport가 준 diagnostic을 별도 필드에 보관합니다. application은 enum으로 분기하고 로그는 원인을 기록할 수 있습니다. production adapter라면 std::error::Error::source chain도 같은 위치에서 수집할 수 있습니다.
진단 문자열을 재분류 입력으로 다시 사용하지는 않습니다. 문자열은 dependency 버전이나 platform에 따라 바뀔 수 있습니다. DNS와 TLS처럼 client의 공개 판별 API만으로 구분할 수 없는 단계는 adapter가 그 단계를 수행하는 자리에서 RawError로 표시해야 합니다.
2. 응답과 transport 실패를 같은 것으로 만들지 않습니다
HTTP client에 따라 error_for_status를 호출해야 4xx·5xx가 오류 타입으로 바뀌기도 합니다. 예제는 이 동작에 기대지 않고 Result<u16, RawError>를 domain boundary에서 직접 분류합니다.
2.1. 성공 status 범위를 policy로 고정합니다
pub fn classify(outcome: &Result<u16, RawError>) -> Result<u16, DomainError> {
match outcome {
Ok(status) if (200..300).contains(status) => Ok(*status),
Ok(status) => Err(DomainError::HttpStatus { status: *status }),
Err(RawError::Dns(_)) => Err(DomainError::Dns),
Err(RawError::Connect(_)) => Err(DomainError::Connect),
Err(RawError::Tls(_)) => Err(DomainError::Tls),
}
}
여기서는 2xx만 성공입니다. redirect를 따라갈지, 304를 성공으로 볼지, 어떤 status를 재시도할지는 이 함수 밖의 application policy입니다. RFC의 status class 정의가 곧 특정 서비스의 재시도 규칙은 아닙니다.
2.2. library helper의 범위를 과대평가하지 않습니다
reqwest 0.12.28의 공개 Error API에는 is_timeout, is_connect, is_status, status가 있습니다. 이 정보는 유용하지만 is_connect 하나만으로 DNS와 TCP connection과 TLS를 안정적으로 세분할 수 있다는 계약은 없습니다. source chain의 concrete type이나 오류 문구를 추측해 분류하면 dependency 내부 구조에 결합됩니다.
경계가 중요하다면 adapter가 resolution, connect, TLS 단계를 명시적으로 소유해야 합니다. 그렇지 못한 client를 쓸 때는 공개 API가 보장하는 더 넓은 범주를 domain model에 채택하고, 지원하지 않는 세분화를 가장하지 않는 편이 낫습니다.
3. timeout과 cancellation의 우선순위를 코드로 정합니다
timeout은 budget 소진이고 cancellation은 외부 의사 표현입니다. 둘 다 in-flight future를 drop할 수 있지만 결과의 의미는 다릅니다. 예제는 cancellation, timeout, transport 순으로 poll하는 biased; select를 사용합니다.
3.1. 동시에 ready라면 cancellation을 선택합니다
let (outcome, diagnostic) = tokio::select! {
biased;
() = cancelled.wait() => (Err(DomainError::Cancelled), None),
() = &mut timeout => (Err(DomainError::Timeout), None),
raw = &mut attempt => {
let diagnostic = raw.as_ref().err().map(|error| error.diagnostic().to_owned());
(classify(&raw), diagnostic)
},
};
biased;는 위에서 아래 순서로 branch를 poll합니다. cancellation과 완료가 같은 poll에서 ready라면 이 예제는 Cancelled를 기록합니다. 서비스가 마지막 성공을 더 중요하게 본다면 순서를 바꿔야 합니다. 어느 쪽이든 우선순위가 우연한 scheduler 순서에 맡겨져서는 안 됩니다.
3.2. drop의 외부 효과는 transport 계약입니다
Tokio timeout은 내부 future를 drop해서 취소할 수 있습니다. select!도 이긴 branch 외의 future를 취소합니다. 하지만 future drop이 이미 전송된 request나 서버의 부수 효과를 되돌리지는 않습니다. 26편의 retry 판단과 마찬가지로 request body 재생 가능성, method idempotency, pool과 socket의 cleanup은 실제 transport adapter에서 확인해야 합니다.
Cancelled를 retryable transport failure로 자동 변환하지 않는 이유도 여기에 있습니다. 상위 작업이 중단을 요청했다면 새 시도를 만드는 대신 그 의도를 report에 보존하는 것이 기본값입니다.
4. 부분 실패는 batch 전체 오류가 아닙니다
여러 endpoint를 검사할 때 하나의 DNS 오류 때문에 먼저 완료한 성공과 다른 실패를 버리면 관찰 결과가 왜곡됩니다. check_all은 JoinSet의 task마다 EndpointReport를 만들고 모든 task를 join한 뒤 input index로 정렬합니다.
4.1. 결과 cardinality를 입력과 같게 유지합니다
while let Some(joined) = tasks.join_next().await {
let (index, report) = joined.expect("scripted check task must not panic");
reports.push((index, report));
}
reports.sort_by_key(|(index, _)| *index);
reports.into_iter().map(|(_, report)| report).collect()
completion order와 output order를 분리했기 때문에 빠르게 실패한 endpoint가 먼저 끝나도 반환 순서는 입력과 같습니다. 호출자는 index를 다시 맞추거나 첫 오류 이후 누락된 결과를 추측할 필요가 없습니다.
이 예제에서 task panic은 fixture bug라서 expect로 드러냅니다. 운영용 API에서는 join failure도 endpoint report의 별도 infrastructure variant로 만들거나 batch-level 오류와 partial reports를 함께 반환해야 합니다. panic을 DNS 같은 정상 도메인 실패로 위장해서는 안 됩니다.
4.2. exhaustive match가 새 정책 누락을 막습니다
DomainError에 cancellation을 추가하고 기존 match를 고치지 않으면 Rust는 E0004를 냅니다. compile-fail fixture는 이 진단을 고정합니다.
error[E0004]: non-exhaustive patterns: `DomainError::Cancelled` not covered
wildcard arm을 넣으면 컴파일은 되지만 새 variant의 metric과 retry 정책이 조용히 기존 범주로 들어갈 수 있습니다. service 경계에서는 명시적인 arm이 더 안전합니다.
5. scripted transport로 실패 경계를 재현합니다
실제 DNS resolver나 TLS endpoint에 의존한 테스트는 환경과 시간에 따라 흔들립니다. ScriptedTransport는 delay와 raw outcome을 queue에 넣고 async send에서 하나씩 꺼냅니다. 외부 네트워크 없이 여섯 분류를 모두 검사할 수 있습니다.
5.1. wall clock 대신 paused time을 사용합니다
#[tokio::test(start_paused = true)] 테스트는 5초 timeout과 6초 response를 즉시 진행해 Timeout을 확인합니다. cancellation tie test는 attempt가 시작된 뒤 virtual time을 이동하고 cancel signal을 보냅니다. 테스트 시간은 짧지만 production과 같은 select! 경계를 통과합니다.
분류 테스트는 DNS, connection, TLS diagnostic과 503, 204를 직접 대조합니다. partial-failure 테스트는 success, connection failure, status failure가 한 vector에 모두 남고 input order를 유지하는지 검사합니다.
5.2. compile-fail도 같은 toolchain에서 확인합니다
compile-fail harness는 rustc --edition=2024 --color=never를 실행하고 stderr를 checked artifact와 byte 단위로 비교합니다. compiler 버전을 올리면 진단 문구가 바뀔 수 있으므로 fixture와 기대 파일을 함께 검토해야 합니다. 단순히 non-zero exit만 확인하면 엉뚱한 syntax error도 성공으로 오인할 수 있습니다.
6. fixture를 실행하고 경계를 확인합니다
프로젝트 root에서 다음 명령을 실행합니다.
cd examples/article-27-network-partial-failure
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
binary는 각 범주 하나씩과 성공 하나를 집계합니다.
ok=1 dns=1 connect=1 tls=1 status=1 timeout=1 cancelled=1
이 출력은 분류표의 smoke test입니다. 실제 network stack을 호출했다는 뜻은 아닙니다. adapter가 어느 단계에서 어떤 RawError를 만드는지는 사용하는 resolver, TLS backend, HTTP client에 맞춰 별도로 통합 테스트해야 합니다.
6.1. production adapter가 소유할 계약
먼저 DNS, connect, TLS를 구분할 수 있는 공개 API가 있는지 확인해야 합니다. 없다면 더 넓은 안정 범주를 택하거나 단계별 adapter를 둡니다. 이어서 status success 범위, timeout과 cancellation tie, partial report의 순서, join failure 표현을 결정합니다.
마지막으로 retry policy는 분류와 분리합니다. Dns나 503이라는 이름만으로 안전한 재시도가 결정되지는 않습니다. method, 이미 발생한 부수 효과, 전체 deadline, cancellation 의도를 함께 봐야 합니다. 26편에서 만든 retry budget이 이 여섯 결과를 입력으로 받도록 연결하면 경계가 선명해집니다.
6.2. 이 fixture가 보장하지 않는 것
scripted transport는 운영 client의 source chain, socket cleanup, TLS backend별 오류를 검증하지 않습니다. Error::source는 저수준 원인을 제공할 수 있지만 domain category를 자동으로 정해 주는 표준은 아닙니다. adapter integration test가 필요한 부분입니다.
부분 실패를 보존하는 것과 무제한으로 task를 만드는 것도 별개입니다. 26편의 bounded sliding window나 semaphore를 함께 적용해야 합니다. 여기서는 실패 분류와 report 보존에 초점을 맞췄습니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 27 프로젝트에서 확인할 수 있습니다.
답글 남기기