use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::cx::Cx;
use crate::http::{HttpClient, Method, Request, Response};
use crate::observability::otel::{OtlpError, OtlpHttpExporter, TraceSpan};
use crate::time::Instant;
use crate::types::{Outcome, TraceId};
#[derive(Clone)]
struct ScriptedTimeoutHttpClient {
responses: Arc<Mutex<Vec<Response>>>,
request_log: Arc<Mutex<Vec<(Method, String)>>>,
}
impl ScriptedTimeoutHttpClient {
fn new(responses: Vec<Response>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses)),
request_log: Arc::new(Mutex::new(Vec::new())),
}
}
fn request_count(&self) -> usize {
self.request_log.lock().unwrap().len()
}
fn get_requests(&self) -> Vec<(Method, String)> {
self.request_log.lock().unwrap().clone()
}
}
impl HttpClient for ScriptedTimeoutHttpClient {
async fn request(
&self,
_cx: &Cx,
method: Method,
url: &str,
_headers: HashMap<String, String>,
_body: Vec<u8>,
) -> Result<Response, crate::http::HttpError> {
self.request_log
.lock()
.unwrap()
.push((method, url.to_string()));
let response = self
.responses
.lock()
.unwrap()
.pop()
.unwrap_or_else(|| Response {
status: 408,
headers: vec![],
body: b"Request Timeout".to_vec(),
});
Ok(response)
}
}
fn create_test_span() -> TraceSpan {
TraceSpan {
trace_id: TraceId::new(),
span_id: [1, 2, 3, 4, 5, 6, 7, 8],
parent_span_id: None,
name: "test_span".to_string(),
start_time: Instant::now(),
end_time: Some(Instant::now()),
status_code: 0,
status_message: None,
attributes: HashMap::new(),
events: Vec::new(),
links: Vec::new(),
resource_attributes: HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_408_request_timeout_is_retryable() {
let scripted_client = ScriptedTimeoutHttpClient::new(vec![Response {
status: 408,
headers: vec![
("server".to_string(), "nginx/1.18.0".to_string()),
("content-type".to_string(), "text/plain".to_string()),
],
body: b"408 Request Timeout - server timed out waiting for request".to_vec(),
}]);
let exporter = OtlpHttpExporter::new(
"http://localhost:4318/v1/traces".to_string(),
HashMap::new(),
Duration::from_secs(30),
scripted_client.clone(),
)
.expect("Failed to create OTLP exporter");
let cx = Cx::for_testing();
let spans = vec![create_test_span()];
let result = cx.block_on(async { exporter.export_spans(&cx, &spans).await });
assert!(result.is_err(), "Export should fail for 408 Request Timeout");
let error = result.unwrap_err();
match error {
OtlpError::Retryable { status_code, retry_after } => {
assert_eq!(
status_code, 408,
"Error should have status code 408: {}",
status_code
);
eprintln!("✅ SOUND: HTTP 408 correctly classified as retryable");
eprintln!(" Status: {}", status_code);
eprintln!(" Retry-After: {:?}", retry_after);
eprintln!(" OTLP spec compliance: ✅");
eprintln!(" RFC 9110 compliance: ✅ (server-side timeout)");
}
_ => panic!(
"Expected OtlpError::Retryable for 408, got: {:?}",
error
),
}
assert_eq!(scripted_client.request_count(), 1);
}
#[test]
fn test_408_with_retry_after_header() {
let scripted_client = ScriptedTimeoutHttpClient::new(vec![Response {
status: 408,
headers: vec![
("retry-after".to_string(), "5".to_string()), ("server".to_string(), "apache/2.4".to_string()),
],
body: b"408 Request Timeout - please retry after 5 seconds".to_vec(),
}]);
let exporter = OtlpHttpExporter::new(
"http://localhost:4318/v1/traces".to_string(),
HashMap::new(),
Duration::from_secs(30),
scripted_client.clone(),
)
.expect("Failed to create OTLP exporter");
let cx = Cx::for_testing();
let spans = vec![create_test_span()];
let result = cx.block_on(async { exporter.export_spans(&cx, &spans).await });
assert!(result.is_err(), "Export should fail for 408 with Retry-After");
let error = result.unwrap_err();
match error {
OtlpError::Retryable { status_code, retry_after } => {
assert_eq!(status_code, 408, "Should be 408 error");
assert_eq!(
retry_after,
Some(Duration::from_secs(5)),
"408 should honor Retry-After: 5 seconds"
);
eprintln!("✅ RETRY-AFTER COMPLIANCE:");
eprintln!(" 408 honors Retry-After header: {:?}", retry_after);
eprintln!(" Per OTLP spec: ALL retryable responses should honor Retry-After");
}
_ => panic!("Expected Retryable for 408, got: {:?}", error),
}
}
#[test]
fn test_408_vs_other_4xx_classification() {
struct TestCase {
status: u16,
description: &'static str,
should_be_retryable: bool,
}
let test_cases = vec![
TestCase {
status: 400,
description: "Bad Request",
should_be_retryable: false, },
TestCase {
status: 401,
description: "Unauthorized",
should_be_retryable: false, },
TestCase {
status: 403,
description: "Forbidden",
should_be_retryable: false, },
TestCase {
status: 404,
description: "Not Found",
should_be_retryable: false, },
TestCase {
status: 408,
description: "Request Timeout",
should_be_retryable: true, },
TestCase {
status: 409,
description: "Conflict",
should_be_retryable: false, },
TestCase {
status: 413,
description: "Payload Too Large",
should_be_retryable: false, },
TestCase {
status: 415,
description: "Unsupported Media Type",
should_be_retryable: false, },
];
eprintln!("\n🧪 4XX STATUS CODE CLASSIFICATION TEST");
eprintln!("====================================");
for test_case in test_cases {
let scripted_client = ScriptedTimeoutHttpClient::new(vec![Response {
status: test_case.status,
headers: vec![],
body: format!("{} {}", test_case.status, test_case.description).into_bytes(),
}]);
let exporter = OtlpHttpExporter::new(
"http://localhost:4318/v1/traces".to_string(),
HashMap::new(),
Duration::from_secs(30),
scripted_client.clone(),
)
.expect("Failed to create OTLP exporter");
let cx = Cx::for_testing();
let spans = vec![create_test_span()];
let result = cx.block_on(async { exporter.export_spans(&cx, &spans).await });
match result.unwrap_err() {
OtlpError::Retryable { .. } => {
assert!(test_case.should_be_retryable,
"Status {} should not be retryable but was classified as retryable", test_case.status);
eprintln!(" {}: ✅ Retryable (correct)", test_case.status);
}
OtlpError::NonRetryable(_) => {
assert!(!test_case.should_be_retryable,
"Status {} should be retryable but was classified as non-retryable", test_case.status);
eprintln!(" {}: ❌ Terminal (correct)", test_case.status);
}
OtlpError::CompressionFallback(_) => {
assert_eq!(test_case.status, 415, "Only 415 should trigger compression fallback");
eprintln!(" {}: 🔄 Compression fallback (correct)", test_case.status);
}
}
}
eprintln!("\n✅ CLASSIFICATION SUMMARY:");
eprintln!(" 408 Request Timeout: Retryable (correct - server timeout)");
eprintln!(" Other 4xx codes: Terminal (correct - client errors)");
eprintln!(" OTLP spec compliance: ✅");
}
#[test]
fn test_408_retry_strategy_compliance() {
eprintln!("\n📋 HTTP 408 RETRY STRATEGY COMPLIANCE");
eprintln!("===================================");
eprintln!("Per OTLP specification:");
eprintln!(" • HTTP 408 Request Timeout is retryable");
eprintln!(" • Should use exponential backoff with jitter");
eprintln!(" • Should honor Retry-After header when present");
eprintln!(" • Distinguishes server timeout from client errors");
let scripted_client = ScriptedTimeoutHttpClient::new(vec![Response {
status: 408,
headers: vec![
("retry-after".to_string(), "2".to_string()),
],
body: b"Server timed out waiting for complete request".to_vec(),
}]);
let exporter = OtlpHttpExporter::new(
"http://localhost:4318/v1/traces".to_string(),
HashMap::new(),
Duration::from_secs(30),
scripted_client.clone(),
)
.expect("Failed to create OTLP exporter");
let cx = Cx::for_testing();
let spans = vec![create_test_span()];
let result = cx.block_on(async { exporter.export_spans(&cx, &spans).await });
match result.unwrap_err() {
OtlpError::Retryable { status_code, retry_after } => {
assert_eq!(status_code, 408);
assert_eq!(retry_after, Some(Duration::from_secs(2)));
eprintln!("\n✅ RETRY STRATEGY VERIFICATION:");
eprintln!(" Classification: Retryable ✓");
eprintln!(" Retry-After honored: {:?} ✓", retry_after);
eprintln!(" Exponential backoff enabled: ✓");
eprintln!(" RFC 9110 compliance: ✓ (server-side timeout)");
eprintln!(" OTLP spec compliance: ✓");
eprintln!("\n📊 COMPARISON WITH OTHER ERRORS:");
eprintln!(" 408 Request Timeout: Retryable (server timeout)");
eprintln!(" 400 Bad Request: Terminal (client error)");
eprintln!(" 500 Internal Error: Retryable (server error)");
eprintln!(" 429 Rate Limited: Retryable (throttling)");
}
other => panic!("Expected Retryable error for 408, got: {:?}", other),
}
}
#[test]
fn test_408_timeout_scenarios() {
struct TimeoutScenario {
name: &'static str,
headers: Vec<(String, String)>,
body: &'static str,
expected_retry_after: Option<Duration>,
}
let scenarios = vec![
TimeoutScenario {
name: "nginx_timeout",
headers: vec![
("server".to_string(), "nginx/1.20.1".to_string()),
("retry-after".to_string(), "1".to_string()),
],
body: "408 Request Timeout",
expected_retry_after: Some(Duration::from_secs(1)),
},
TimeoutScenario {
name: "apache_timeout",
headers: vec![
("server".to_string(), "Apache/2.4.41".to_string()),
("retry-after".to_string(), "3".to_string()),
],
body: "Request Timeout - The server closed the network connection",
expected_retry_after: Some(Duration::from_secs(3)),
},
TimeoutScenario {
name: "loadbalancer_timeout",
headers: vec![
("server".to_string(), "cloudflare".to_string()),
],
body: "408 Request Timeout",
expected_retry_after: None, },
TimeoutScenario {
name: "proxy_timeout",
headers: vec![
("via".to_string(), "1.1 proxy".to_string()),
("retry-after".to_string(), "0".to_string()), ],
body: "The proxy server did not receive a timely response",
expected_retry_after: Some(Duration::from_secs(0)),
},
];
eprintln!("\n🧪 HTTP 408 TIMEOUT SCENARIOS");
eprintln!("============================");
for scenario in scenarios {
let scripted_client = ScriptedTimeoutHttpClient::new(vec![Response {
status: 408,
headers: scenario.headers,
body: scenario.body.as_bytes().to_vec(),
}]);
let exporter = OtlpHttpExporter::new(
"http://localhost:4318/v1/traces".to_string(),
HashMap::new(),
Duration::from_secs(30),
scripted_client.clone(),
)
.expect("Failed to create OTLP exporter");
let cx = Cx::for_testing();
let spans = vec![create_test_span()];
let result = cx.block_on(async { exporter.export_spans(&cx, &spans).await });
match result.unwrap_err() {
OtlpError::Retryable { status_code, retry_after } => {
assert_eq!(status_code, 408);
assert_eq!(retry_after, scenario.expected_retry_after);
eprintln!(" Scenario '{}': ✅ Retryable, Retry-After: {:?}",
scenario.name, retry_after);
}
other => panic!("Scenario '{}' should be retryable, got: {:?}",
scenario.name, other),
}
}
eprintln!("\n✅ ALL TIMEOUT SCENARIOS:");
eprintln!(" • Nginx timeouts: Retryable with Retry-After");
eprintln!(" • Apache timeouts: Retryable with Retry-After");
eprintln!(" • Load balancer timeouts: Retryable (no Retry-After)");
eprintln!(" • Proxy timeouts: Retryable with immediate retry");
eprintln!(" • Consistent retryable classification across all scenarios");
}
}