resilience_units.rs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. //! `test(resilience)` unit suite: retry budget, fail-closed policy, failover
  2. //! accounting, and slot guards without containers.
  3. //!
  4. //! Every test name contains `resilience` so
  5. //! `cargo nextest run -E 'test(resilience)'` selects exactly them alongside
  6. //! `tests/resilience.rs`. Bodies use `Result` + `?` (`unwrap`/`expect`/
  7. //! `panic` are denied workspace-wide).
  8. use std::{
  9. sync::{
  10. Arc,
  11. atomic::{AtomicU32, Ordering},
  12. },
  13. time::Duration,
  14. };
  15. use d_oauth::{
  16. config::FailurePolicy,
  17. resilience::{
  18. FailoverEpisode, MAX_RETRIES, ResilienceConfig, ResilienceLayer, RetryBudget, SECURITY_PATHS,
  19. dependency_unavailable, fail_closed_for_path, hash_tag, is_security_path, is_single_slot,
  20. is_transient_redis, map_store_error, may_retry_redis, retry_idempotent,
  21. },
  22. store::{OpClass, StoreError},
  23. };
  24. /// Test body result (no `unwrap`/`expect` under the deny lints).
  25. type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
  26. fn transient_redis_error() -> redis::RedisError {
  27. redis::RedisError::from((redis::ErrorKind::Io, "connection reset"))
  28. }
  29. fn permanent_redis_error() -> redis::RedisError {
  30. redis::RedisError::from((redis::ErrorKind::AuthenticationFailed, "bad password"))
  31. }
  32. #[tokio::test]
  33. async fn resilience_retry_budget_caps_retries_at_two() -> TestResult {
  34. let budget = RetryBudget::new(1_000);
  35. let attempts = Arc::new(AtomicU32::new(0));
  36. let probe = Arc::clone(&attempts);
  37. let outcome = retry_idempotent(
  38. move || {
  39. let probe = Arc::clone(&probe);
  40. async move {
  41. probe.fetch_add(1, Ordering::SeqCst);
  42. Err::<(), redis::RedisError>(transient_redis_error())
  43. }
  44. },
  45. is_transient_redis,
  46. &budget,
  47. usize::MAX,
  48. )
  49. .await;
  50. if outcome.is_ok() {
  51. return Err("always-failing op must stay failed".into());
  52. }
  53. // One initial attempt plus at most MAX_RETRIES retries.
  54. if attempts.load(Ordering::SeqCst) != 1 + MAX_RETRIES as u32 {
  55. return Err(format!("expected {} attempts", 1 + MAX_RETRIES).into());
  56. }
  57. Ok(())
  58. }
  59. #[tokio::test]
  60. async fn resilience_retry_exhausted_budget_means_one_attempt() -> TestResult {
  61. let budget = RetryBudget::new(0);
  62. let attempts = Arc::new(AtomicU32::new(0));
  63. let probe = Arc::clone(&attempts);
  64. let outcome = retry_idempotent(
  65. move || {
  66. let probe = Arc::clone(&probe);
  67. async move {
  68. probe.fetch_add(1, Ordering::SeqCst);
  69. Err::<(), redis::RedisError>(transient_redis_error())
  70. }
  71. },
  72. is_transient_redis,
  73. &budget,
  74. usize::MAX,
  75. )
  76. .await;
  77. if outcome.is_ok() {
  78. return Err("always-failing op must stay failed".into());
  79. }
  80. if attempts.load(Ordering::SeqCst) != 1 {
  81. return Err("exhausted budget must allow exactly one attempt".into());
  82. }
  83. Ok(())
  84. }
  85. #[tokio::test]
  86. async fn resilience_retry_never_retries_permanent_errors() -> TestResult {
  87. let budget = RetryBudget::new(1_000);
  88. let attempts = Arc::new(AtomicU32::new(0));
  89. let probe = Arc::clone(&attempts);
  90. let outcome = retry_idempotent(
  91. move || {
  92. let probe = Arc::clone(&probe);
  93. async move {
  94. probe.fetch_add(1, Ordering::SeqCst);
  95. Err::<(), redis::RedisError>(permanent_redis_error())
  96. }
  97. },
  98. is_transient_redis,
  99. &budget,
  100. usize::MAX,
  101. )
  102. .await;
  103. if outcome.is_ok() {
  104. return Err("always-failing op must stay failed".into());
  105. }
  106. if attempts.load(Ordering::SeqCst) != 1 {
  107. return Err("permanent errors must never be retried".into());
  108. }
  109. if budget.remaining() != 1_000 {
  110. return Err("untaken retries must not consume the budget".into());
  111. }
  112. Ok(())
  113. }
  114. #[tokio::test]
  115. async fn resilience_retry_never_retries_non_idempotent_ops() -> TestResult {
  116. // The op-class gate sits below the retry helper: non-idempotent writes
  117. // are ineligible even for transient errors.
  118. if may_retry_redis(OpClass::NonIdempotent, &transient_redis_error()) {
  119. return Err("non-idempotent writes must never be retried".into());
  120. }
  121. if !may_retry_redis(OpClass::IdempotentRead, &transient_redis_error()) {
  122. return Err("idempotent reads must be retryable on transient errors".into());
  123. }
  124. if !may_retry_redis(OpClass::ConditionalWrite, &transient_redis_error()) {
  125. return Err("SET NX / Lua CAS must be retryable on transient errors".into());
  126. }
  127. if may_retry_redis(OpClass::IdempotentRead, &permanent_redis_error()) {
  128. return Err("permanent errors must never be retried".into());
  129. }
  130. Ok(())
  131. }
  132. #[test]
  133. fn resilience_failover_episode_allows_single_caller_error() -> TestResult {
  134. let episode = FailoverEpisode::new();
  135. if !episode.within_budget() {
  136. return Err("a fresh episode must be within budget".into());
  137. }
  138. episode.record_caller_error();
  139. if !episode.within_budget() {
  140. return Err("one caller-visible error per episode must be allowed".into());
  141. }
  142. episode.record_caller_error();
  143. if episode.within_budget() {
  144. return Err("two caller-visible errors must breach the episode budget".into());
  145. }
  146. Ok(())
  147. }
  148. #[test]
  149. fn resilience_single_slot_guards_cross_slot_splits() -> TestResult {
  150. let token_keys = [
  151. "oauth:{state}:refresh:aaa",
  152. "oauth:{state}:access:bbb",
  153. "oauth:{state}:grant:g1:tokens",
  154. ];
  155. if !is_single_slot(&token_keys) {
  156. return Err("token keyspace keys must share one slot".into());
  157. }
  158. let split = ["oauth:{state}:refresh:aaa", "oauth:{rl}:ratelimit:bbb"];
  159. if is_single_slot(&split) {
  160. return Err("token + rate-limit keys must never share a slot op".into());
  161. }
  162. if is_single_slot(&[]) {
  163. return Err("an empty key set is not a valid atomic op".into());
  164. }
  165. if hash_tag("oauth:refresh:aaa").is_some() {
  166. return Err("untagged keys must report no hash tag".into());
  167. }
  168. Ok(())
  169. }
  170. #[test]
  171. fn resilience_fail_closed_policy_matrix() -> TestResult {
  172. for path in SECURITY_PATHS {
  173. if !fail_closed_for_path(path) {
  174. return Err(format!("{path} must fail closed").into());
  175. }
  176. if !is_security_path(path) {
  177. return Err(format!("{path} must be classified security-critical").into());
  178. }
  179. }
  180. for path in ["/login", "/login/totp/confirm", "/register/some-client"] {
  181. if !fail_closed_for_path(path) {
  182. return Err(format!("{path} must fail closed").into());
  183. }
  184. }
  185. if fail_closed_for_path("/health/live") {
  186. return Err("/health/live is dependency-free and must not fail closed".into());
  187. }
  188. if !fail_closed_for_path("/health/ready") {
  189. return Err("/health/ready must fail closed".into());
  190. }
  191. Ok(())
  192. }
  193. #[test]
  194. fn resilience_config_clamps_retry_ceiling_to_two() -> TestResult {
  195. let policy = FailurePolicy {
  196. retry_budget: 6,
  197. request_timeout: Duration::from_secs(10),
  198. ..FailurePolicy::default()
  199. };
  200. let tuned = ResilienceConfig::from_policy(&policy);
  201. if tuned.max_retries != MAX_RETRIES {
  202. return Err("policy budgets above the ceiling must clamp to 2".into());
  203. }
  204. // Layers construct (compile-time proof the tower features are wired).
  205. let _ = tuned.timeout_layer();
  206. let _ = tuned.concurrency_limit_layer();
  207. let _ = ResilienceConfig::load_shed_layer();
  208. Ok(())
  209. }
  210. #[tokio::test]
  211. async fn resilience_dependency_failures_render_503_retry_after() -> TestResult {
  212. use axum::http::header;
  213. use axum::response::IntoResponse as _;
  214. use http_body_util::BodyExt as _;
  215. for error in [
  216. map_store_error(StoreError::Durability),
  217. map_store_error(StoreError::Redis(transient_redis_error())),
  218. dependency_unavailable(),
  219. ] {
  220. let response = error.into_response();
  221. if response.status() != axum::http::StatusCode::SERVICE_UNAVAILABLE {
  222. return Err("dependency failures must render 503".into());
  223. }
  224. if response.headers().get(header::RETRY_AFTER).is_none() {
  225. return Err("503 responses must carry Retry-After".into());
  226. }
  227. let bytes = response
  228. .into_body()
  229. .collect()
  230. .await
  231. .map_err(|_| "body collection failed")?
  232. .to_bytes();
  233. let body = String::from_utf8_lossy(&bytes);
  234. if !body.contains("temporarily_unavailable") {
  235. return Err("503 body must carry temporarily_unavailable".into());
  236. }
  237. }
  238. Ok(())
  239. }
  240. #[tokio::test]
  241. async fn resilience_probe_stack_service() -> TestResult {
  242. let tuned = ResilienceConfig {
  243. request_timeout: Duration::from_secs(10),
  244. max_in_flight: 8,
  245. max_retries: MAX_RETRIES,
  246. };
  247. let router = axum::Router::new().route("/", axum::routing::get(|| async { "ok" }));
  248. let router = router.layer(ResilienceLayer::from_config(&tuned));
  249. let request = axum::http::Request::builder()
  250. .uri("/")
  251. .body(axum::body::Body::empty())?;
  252. let response = tower::ServiceExt::oneshot(router, request)
  253. .await
  254. .map_err(|_| "router error")?;
  255. if response.status() != axum::http::StatusCode::OK {
  256. return Err("request through the stack must be 200".into());
  257. }
  258. Ok(())
  259. }