HTTP 요청 하나에 timeout을 씌우는 것만으로는 검사기의 시간과 부하를 제한할 수 없습니다. 시도별 제한 시간, 재시도를 포함한 전체 deadline, 재시도 대상, backoff, 동시에 진행할 endpoint 수를 하나의 정책으로 묶어야 합니다. 서로 다른 제한이 같은 시각에 끝날 때 무엇이 이기는지도 계약에 포함됩니다.
이 글은 Rust 2024 edition, rustc·Cargo 1.98.1, Tokio 1.53.1을 기준으로 합니다. 외부 네트워크 대신 async Transport를 주입하고 paused time으로 정책 경계를 검사합니다. 여기서 다루는 상태 코드와 재시도 횟수는 예제의 application policy이며 일반적인 HTTP 운영 규칙으로 확대하지 않습니다.
1. 시도 시간과 전체 deadline을 분리합니다
attempt_timeout은 한 번의 전송에 허용하는 시간입니다. overall_timeout은 최초 시도부터 backoff와 모든 재시도까지 포함한 hard deadline입니다. 시도마다 새 timeout만 만들면 재시도 횟수만큼 총 시간이 늘어나므로 두 값은 같은 역할을 하지 않습니다.
1.1. typed config로 생성 경계를 닫습니다
Policy는 두 timeout과 재시도 횟수, 최초·최대 backoff를 구분합니다. Checker::new는 task를 만들기 전에 concurrency 0, Semaphore::MAX_PERMITS 초과, 0인 시도 timeout, Instant + Duration overflow, 31을 넘는 재시도 지수를 typed BuildError로 거부합니다. 전체 timeout 0은 잘못된 설정이 아니라 이미 소진된 유효한 budget입니다.
pub struct Policy {
pub attempt_timeout: Duration,
pub overall_timeout: Duration,
pub max_retries: usize,
pub initial_backoff: Duration,
pub max_backoff: Duration,
}
pub enum BuildError {
ZeroConcurrency,
ConcurrencyTooLarge,
ZeroAttemptTimeout,
AttemptDeadlineOverflow,
OverallDeadlineOverflow,
RetryExponentTooLarge,
BackoffDeadlineOverflow,
}
생성자에서 호출하는 Instant::checked_add는 그 시점의 preflight일 뿐입니다. check가 실행되기 전, semaphore permit을 기다리는 동안, attempt 사이에 Tokio 시간이 흐를 수 있습니다. 따라서 실행 중 덧셈은 매번 실패할 수 있는 연산으로 다룹니다. policy deadline overflow는 FinalOutcome::PolicyTimeOverflow가 되고, 응답에서 온 과도한 Retry-After는 기존처럼 OverallDeadline으로 제한됩니다.
1.2. 같은 시각에는 전체 deadline이 이깁니다
예제는 attempt를 poll하기 전에 now >= deadline을 검사합니다. 그다음 biased;인 select!에서 overall deadline, attempt deadline, transport 순으로 branch를 둡니다. transport가 전체 deadline과 정확히 같은 시각에 완료되면 OverallDeadline이고 그보다 엄격히 먼저 끝났을 때만 성공할 수 있습니다. overall deadline이 아닌 attempt timer와 transport만 동률이면 AttemptTimeout이 우선합니다.
let outcome = tokio::select! {
biased;
_ = tokio::time::sleep_until(deadline) => FinalOutcome::OverallDeadline,
_ = tokio::time::sleep_until(attempt_deadline) => FinalOutcome::AttemptTimeout,
result = &mut attempt => match result {
Ok(response) => FinalOutcome::Response(response),
Err(error) => FinalOutcome::Transport(error),
},
};
timeout은 wrapped future를 먼저 poll한 뒤 timeout을 확인합니다. 따라서 이 예제의 동률 우선순위는 timeout 일반 동작을 옮겨 적은 것이 아니라, select! branch 순서로 별도 정의한 정책입니다.
2. 재시도 가능성을 method와 결과로 제한합니다
RFC 9110은 같은 요청을 여러 번 적용해도 의도한 서버 효과가 한 번과 같은 method를 idempotent로 정의합니다. 응답을 읽기 전 communication failure가 나면 idempotent request를 자동 반복할 수 있다고 설명하지만 non-idempotent method의 자동 재시도와 실패한 자동 재시도의 반복에는 SHOULD NOT 조건을 둡니다.
2.1. RFC와 application policy를 구분합니다
예제는 Get, Put, Delete만 자동 재시도 대상으로 보고 Post는 재시도하지 않습니다. 408, 429, 500, 502, 503, 504, 연결 실패, AttemptTimeout을 retryable로 분류합니다. 이 상태 집합과 bounded multi-retry는 RFC의 의무가 아니라 이 예제의 application policy입니다. 표준에 보수적으로 맞추려면 max_retries <= 1로 두고 더 큰 값은 application-specific knowledge가 있을 때만 명시해야 합니다.
let retryable = request.method.is_idempotent()
&& attempts <= self.policy.max_retries
&& match outcome {
FinalOutcome::Response(ref response) => {
matches!(response.status, 408 | 429 | 500 | 502 | 503 | 504)
}
FinalOutcome::Transport(TransportError::Connect)
| FinalOutcome::AttemptTimeout => true,
_ => false,
};
max_retries는 첫 시도 뒤에 허용할 추가 시도 수입니다. 값이 1이면 최대 두 번 시도합니다. binary 예제에서도 GET 요청 alpha는 503 뒤 200을 받고 POST 요청 beta는 첫 503에서 멈춥니다.
2.2. Retry-After는 delta-seconds만 읽습니다
RFC 9110의 Retry-After는 HTTP-date 또는 delay-seconds를 허용합니다. 이 예제가 구현한 범위는 delay-seconds = 1*DIGIT, 즉 0 이상의 10진 정수 초뿐입니다. 빈 문자열, 부호, 소수점, 공백이 섞인 값, u64 overflow, HTTP-date는 무시하고 계산된 backoff를 사용합니다. HTTP-date 해석은 이 글의 범위 밖입니다.
pub fn parse_retry_after_delta(value: &str) -> Option<Duration> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
value.parse::<u64>().ok().map(Duration::from_secs)
}
유효한 delta-seconds도 hard deadline을 늘리지 못합니다. 선택한 delay를 더한 retry_at과 다음 시도의 전체 attempt_timeout이 deadline보다 엄격히 앞에서 끝날 수 있을 때만 sleep하고 재시도합니다.
3. backoff 계산과 sleep을 같은 budget 안에 둡니다
지수 backoff는 initial_backoff * 2^(attempts-1)로 계산하되 checked_mul을 사용하고 max_backoff로 제한합니다. max_retries <= 31 검사는 shift 지수도 제한합니다. 곱셈이 표현 범위를 넘으면 최대 backoff를 선택하므로 wrapping된 짧은 delay로 바뀌지 않습니다.
3.1. overflow는 빠른 재시도로 바꾸지 않습니다
let factor = 1_u32 << (attempts - 1);
let backoff = self
.policy
.initial_backoff
.checked_mul(factor)
.unwrap_or(self.policy.max_backoff)
.min(self.policy.max_backoff);
response의 delta-seconds와 계산한 backoff 가운데 실제로 선택된 delay는 다시 now.checked_add(delay)로 검사합니다. 응답 delay가 overflow하거나 유한한 attempt가 deadline 안에 온전히 들어가지 않으면 OverallDeadline을 반환합니다. 시간이 흐른 뒤 설정된 attempt timeout 자체를 더할 수 없다면 report에는 PolicyTimeOverflow(AttemptDeadline)을 넣습니다. 어느 경로에서도 sleep이나 새 transport call은 시작하지 않습니다.
3.2. permit은 backoff가 끝날 때까지 유지합니다
Checker::check는 시작할 때 semaphore permit을 얻고 report를 반환할 때까지 _permit을 scope에 보관합니다. 전송이 끝나고 backoff 중이라고 해서 concurrency slot을 반납하지 않습니다. 한 endpoint의 retry workflow 전체가 permit 하나를 점유합니다.
let _permit = self
.semaphore
.acquire()
.await
.expect("checker semaphore is never closed");
let started = Instant::now();
let Some(deadline) = started.checked_add(self.policy.overall_timeout) else {
return Report {
id: request.id,
attempts: 0,
outcome: FinalOutcome::PolicyTimeOverflow(
PolicyTimeOverflow::OverallDeadline,
),
retry_delays: Vec::new(),
};
};
이 선택은 retry storm이 새 요청의 concurrency 제한을 우회하지 못하게 합니다. 동시에 overall deadline 계산은 대기가 끝난 뒤 일어나므로 생성자 preflight만으로 실행 시점의 산술을 보장할 수 없습니다. 동시에 진행되는 transport call만 따로 제한하려는 정책과는 의미가 다르므로, 어느 범위를 permit이 덮는지 API 계약에 밝혀야 합니다.
4. JoinSet을 bounded sliding window로 사용합니다
입력 전체를 한꺼번에 spawn한 뒤 semaphore에서 기다리게 하면 실행 중인 transport는 제한돼도 대기 task 수는 입력 크기만큼 늘어납니다. check_all은 처음에 max_concurrency개만 spawn하고 tracked task 하나가 끝날 때마다 다음 입력 하나를 추가합니다. 빈 입력은 task를 만들지 않고 즉시 빈 결과를 반환합니다.
4.1. 완료 순서와 출력 순서를 분리합니다
JoinSet은 task를 완료 순서로 돌려주며 순서를 보장하는 collection이 아닙니다. 각 task에 입력 index를 붙이고 모든 성공 report를 받은 뒤 index로 정렬합니다. 따라서 실행은 concurrent여도 결과는 입력 순서입니다.
while let Some(joined) = tasks.join_next().await {
match joined {
Ok(report) => reports.push(report),
Err(error) => join_errors.push(error.to_string()),
}
if let Some((index, request)) = pending.next() {
let checker = self.clone();
tasks.spawn(async move { (index, checker.check(request).await) });
}
}
sliding window의 live task 수는 max_concurrency를 넘지 않습니다. semaphore는 각 endpoint workflow의 실행 상한을 지키고 spawn window는 아직 시작하지 않은 입력이 task로 쌓이지 않게 합니다.
4.2. JoinError가 나도 완전히 drain합니다
join_next가 JoinError를 돌려줘도 즉시 return하지 않습니다. 오류 문자열을 보관한 뒤 JoinSet이 빌 때까지 계속 join하고 정렬한 오류를 CheckAllError로 반환합니다. 먼저 시작된 다른 task가 정리되기 전에 error를 노출하지 않습니다.
JoinSet을 drop하면 안의 task가 즉시 abort됩니다. 이 예제는 drop을 cleanup으로 간주하지 않습니다. 모든 tracked task를 drain한 뒤 오류를 반환하는 동작을 channel과 release event로 검사합니다.
5. async transport와 paused time으로 경계를 검사합니다
Transport::send는 Send인 future를 반환합니다. 중요한 점은 future를 만드는 호출 자체도 async attempt body 안에 있다는 것입니다. synchronous setup이 timeout select 밖에서 먼저 실행되지 않습니다.
5.1. future 생성도 timed polling 안에 둡니다
let attempt = async { self.transport.send(request.clone()).await };
tokio::pin!(attempt);
scripted transport도 call count 증가와 outcome queue 접근을 async move body 안에서 수행합니다. 전체 budget이 0이면 transport는 한 번도 poll되지 않고 calls는 0입니다. 별도 회귀 테스트는 transport future construction이 timed polling 안에서 시작되는지 고정합니다.
이 trait은 실제 HTTP client를 숨긴 최소 경계입니다. socket, DNS, connection pool의 일반적인 취소 효과까지 보장한다는 뜻은 아닙니다. 이 글의 결과는 예제에 주입한 transport와 명시한 policy에 한정됩니다.
5.2. virtual time은 관찰 뒤에 전진시킵니다
테스트는 #[tokio::test(start_paused = true)]와 time::advance를 사용합니다. 먼저 channel event나 call count로 attempt가 시작됐는지 관찰하고 필요한 시간만 전진합니다. wall-clock sleep이나 scheduler가 특정 순간에 poll할 것이라는 추측에 기대지 않습니다.
Tokio의 paused runtime은 실행할 다른 일이 없으면 다음 timer로 시간을 자동 전진할 수 있습니다. 따라서 독립적으로 ready인 task의 세부 poll 순서를 주장하지 않고 deadline 결과와 call 수, release 전후 상태처럼 정책이 보장하는 값만 검사합니다.
6. 실행 결과와 정책의 한계를 확인합니다
프로젝트 디렉터리에서 다음 명령을 실행합니다.
cd examples/article-26-http-timeout-retry-concurrency
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
Rust 1.98.1, Cargo 1.98.1, Tokio 1.53.1에서 다섯 명령은 종료 코드 0을 반환해야 합니다. debug와 release 테스트는 각각 26개이며 binary의 정확한 출력은 다음과 같습니다.
alpha status=200 attempts=2 retry_delays_ms=[10]
beta status=503 attempts=1 retry_delays_ms=[]
all_joined=2 max_concurrency=2
6.1. 테스트가 고정하는 계약
26개 테스트는 config의 0·상한·overflow, 시간이 흐르거나 semaphore를 기다린 뒤 생기는 runtime policy-time overflow, hard overall deadline과 두 동률 우선순위, idempotency-aware bounded retry, delta-seconds 문법, capped backoff, retry 중 permit 유지, bounded sliding window, 입력 순서, empty input, JoinError 뒤 complete drain, async transport poll boundary를 다룹니다. time 관련 테스트 17개는 paused runtime을 사용합니다.
all_joined=2는 binary 예제에서 두 checker 호출이 끝났다는 출력입니다. 모든 입력을 하나의 JoinSet에서 처리했다는 뜻은 아닙니다. bounded window와 complete drain은 check_all 통합 테스트가 별도로 검증합니다.
6.2. 적용 전에 정책 소유권을 정합니다
운영 코드로 옮길 때는 어떤 method와 failure를 재시도할지 application이 소유해야 합니다. 인증 갱신, request body 재생 가능성, 서버가 이미 적용한 부수 효과, 실제 transport future를 drop했을 때의 효과는 이 예제가 다루지 않습니다. Retry-After HTTP-date가 필요하다면 clock과 parser 정책도 추가해야 합니다.
hard deadline을 우선할지 마지막 성공을 받아들일지, backoff가 concurrency slot을 계속 점유할지도 먼저 정하십시오. 이 글의 구현은 전체 deadline 우선, complete-attempt budget, workflow 단위 permit을 택합니다. 요구사항이 다르면 branch 순서와 permit scope부터 달라져야 합니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 26 프로젝트에서 확인할 수 있습니다.
답글 남기기