fix: staging CI triage — consolidate retry parsing, fix flaky tests, add docs (#1427)

* fix: consolidate retry-after parsing and fix flaky OAuth env tests (#1288, #1280)

- Extract shared `parse_retry_after()` into `src/llm/retry.rs` supporting
  both delay-seconds and RFC2822 formats, replacing duplicated inline parsing
  in anthropic_oauth.rs, nearai_chat.rs, and embeddings.rs
- Fix flaky `bind_rejects_wildcard_*` tests in oauth_helpers.rs by adding
  `tokio::sync::Mutex` to serialize env var access (matching the ENV_MUTEX
  pattern in oauth_defaults.rs)
- Add regression tests for parse_retry_after edge cases

Closes #1288, #1280

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* docs: add comments explaining CLI_ENABLED=false in service templates (#990)

Clarify that CLI_ENABLED=false is needed in daemon mode (launchd/systemd)
to prevent blocking on stdin when running as a background service.

Closes #990

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review comments on retry-after consolidation

- Change parse_retry_after() return type from Option<Duration> to Duration
  (it never returns None due to the 60s fallback)
- Fix doc comment: reference RFC 7231 §7.1.1 for HTTP-date, not RFC 2822
- Add parse_retry_after_http_date test for the RFC 2822 date parsing branch
- Remove stale per-file test helpers (parse_retry_after_*_for_test) that
  duplicated old inline logic instead of testing the shared function
- Remove unnecessary comments above #[cfg(test)] imports
- Use crate-wide ENV_MUTEX instead of local tokio::sync::Mutex in
  oauth_helpers tests to prevent cross-module env-var races

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: reword await_holding_lock safety comment

Drop runtime-flavor assumption; justify by short-lived awaited operation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-03-19 18:33:15 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6b0f84bbe0
commit 8920322589
6 changed files with 201 additions and 302 deletions
@@ -0,0 +1,87 @@
# Staging CI Review Issues Triage
**Date:** 2026-03-18
**Branch:** staging (HEAD `b7a1edf`)
**Total open issues:** 50
---
## Batch 1 — Critical & 100-confidence issues
| # | Title | Severity | Verdict | File(s) | Action |
|---|-------|----------|---------|---------|--------|
| 1281 | Logic inversion in Telegram auto-verification | CRITICAL:100 | **FALSE POSITIVE** (closed) | `src/channels/web/server.rs` | Different handlers with intentional different SSE behavior |
| 908 | Missing consecutive_failures reset | CRITICAL:100 | **STALE** | `src/llm/circuit_breaker.rs` | Close — `record_success()` already resets to 0 |
| 1282 | Variable shadowing fallback notification | HIGH:100 | **STALE** | `src/agent/agent_loop.rs` | Close — fixed in commit `bcc38ce` |
| 1283 | Inconsistent fallback logic DRY | HIGH:75 | **STALE** | `src/agent/agent_loop.rs` | Close — fixed in commit `bcc38ce` |
| 1178 | Workflow linting bypass for test code | CRITICAL:75 | **FALSE POSITIVE** | `.github/workflows/code_style.yml` | Close — script reads full file, not hunk headers |
---
## Remaining Batches (queued)
### Batch 2 — Retry/DRY + CI workflow issues (completed)
| # | Title | Severity | Verdict | Action |
|---|-------|----------|---------|--------|
| 1288 | DRY violation: retry-after parsing | HIGH:95 | **LEGIT** | Fixed: extracted shared `parse_retry_after()` |
| 1289 | Semantic mismatch in RFC2822 test helpers | MEDIUM:85 | **DUPLICATE** (closed) | Duplicate of #1288 |
| 1290 | Unnecessary eager `chrono::Utc::now()` call | LOW:85 | **FALSE POSITIVE** (closed) | Already deferred inside successful parse branch |
| 963 | Logical equivalence bug in workflow conditions | HIGH:100 | **FALSE POSITIVE** (closed) | Refactored condition correctly handles `workflow_call` |
| 1280 | Flaky OAuth wildcard callback tests | Flaky | **LEGIT** | Fixed: added `tokio::sync::Mutex` for env var serialization |
### Batch 3 — Routine engine + notification routing
- #1365 — too_many_arguments on RoutineEngine::new()
- #1371 — Discovery schema regeneration on every tool_info call
- #1364 — Prompt injection via unescaped channel/user in lightweight routines
- #1284 — notification_target_for_channel() assumes channel owner
### Batch 4 — Telegram/Extension Manager webhook group
- #1247 — Synchronous 120-second blocking poll in HTTP handler
- #1248 — Hardcoded channel-specific logic violates architecture
- #1249 — Telegram-specific business logic bloats ExtensionManager
- #1250 — Response success/failure logic mismatch in chat auth
- #1251 — Channel-specific configuration mappings lack extensibility
### Batch 5 — HMAC/Auth/Security
- #1034 — Signature verification not constant-time
- #1035 — Incorrect order of operations in HMAC verification
- #1036 — Double opt-in lacks runtime validation consistency
- #1037 — API breaking change: auth() signature
- #1038 — CSP policy allows CDN scripts with risky fallback
### Batch 6 — Webhook handler + config
- #1039 — Per-request HTTP client creation in hot path
- #1040 — Complex nested auth logic in webhook_handler
- #1041 — Redundant JSON deserialization in webhook handler
- #1042 — Implicit state mutation in config conversion
- #1005 — Inconsistent double opt-in enforcement
### Batch 7 — Tool schema validation / WASM bounds
- #974 — Unbounded recursion in resolve_nested()
- #975 — Unbounded recursion in validate_tool_schema()
- #976 — Unbounded description string in CapabilitiesFile
- #977 — Unbounded parameters schema JSON
- #978 — Unnecessary clone of large JSON in hot path
### Batch 8 — Tool schema + config + security
- #979 — No size limits on JSON files read
- #980 — Misleading warning condition for missing parameters
- #988 — Hardcoded CLI_ENABLED env var in systemd template
- #990 — Configuration semantics unclear for daemon mode
- #1103 — SSRF risk via configurable embedding base URL
### Batch 9 — Agent loop / job worker
- #870 — Unbounded loop without cancellation token
- #871 — Stringly-typed unsupported parameter filtering
- #873 — RwLock overhead on hot path
- #892 — JobDelegate::check_signals() treats non-terminal as terminal
- #1252 — String concatenation in hot polling loop
### Batch 10 — Agent loop perf + CI scripts
- #893 — Unnecessary parameter cloning on every tool execution
- #894 — truncate_for_preview allocates for non-truncated strings
- #895 — Tool definitions fetched every iteration without caching
- #1179 — AWK state machine never resets between hunks
- #1180 — Code fence detection logic flawed in extract_suggestions()
- #1181 — Unsafe .unwrap() in production code manifest.rs
+3 -91
View File
@@ -22,8 +22,6 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
@@ -144,15 +142,9 @@ impl AnthropicOAuthProvider {
if !status.is_success() {
// Parse Retry-After header before consuming the body.
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
let response_text = response
.text()
@@ -709,84 +701,4 @@ mod tests {
// Subsequent reads see the updated token
assert_eq!(token.read().unwrap().expose_secret(), "new_token");
}
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format is parsed correctly
let header_value = "45";
let duration = parse_retry_after_anthropic_for_test(header_value);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(45)),
"Should parse delay-seconds format"
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_anthropic_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_fallback_invalid_format() {
// Regression test: When Retry-After header is in unexpected format,
// should fall back to 60s instead of None
let invalid_formats = vec![
"invalid",
"not-a-number",
"30.5", // float instead of int
"abc123",
"Mon, 02 Mar 2026 18:00:00 GMT", // RFC2822 not supported in anthropic version
];
for format in invalid_formats {
let duration = parse_retry_after_anthropic_for_test(format);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Invalid format '{}' should fallback to 60s",
format
);
}
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_anthropic_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
assert_eq!(
duration,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic for Anthropic
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
fn parse_retry_after_anthropic_for_test(header_value: &str) -> Option<std::time::Duration> {
header_value
.trim()
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
+4 -144
View File
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
use crate::llm::{costs, session::SessionManager};
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -243,30 +243,9 @@ impl NearAiChatProvider {
let status = response.status();
// Extract Retry-After header before consuming the response body.
// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats.
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
let retry_after_header = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
return Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
}
None
})
.or(Some(std::time::Duration::from_secs(60)));
let retry_after_header = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to read response body: {}", e),
@@ -2218,123 +2197,4 @@ mod tests {
"http://example.com/api/proxy/v1/chat/completions"
);
}
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format (most common) is parsed correctly
let header_value = "30";
let duration = parse_retry_after_for_test(header_value);
assert_eq!(duration, Some(std::time::Duration::from_secs(30)));
}
#[test]
fn test_retry_after_parsing_rfc2822_date() {
// Verify HTTP-date (RFC 2822) format is parsed correctly
// Use a date 60 seconds in the future
let now = chrono::Utc::now();
let future = now + chrono::Duration::seconds(60);
let date_str = future.to_rfc2822();
let duration = parse_retry_after_for_test(&date_str);
assert!(duration.is_some());
let d = duration.unwrap();
// Allow ±5 seconds of drift due to processing time
assert!(
d.as_secs() >= 55 && d.as_secs() <= 65,
"Expected ~60s, got {}s",
d.as_secs()
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_fallback_invalid_format() {
// Regression test: When Retry-After header is in unexpected format,
// should fall back to 60s instead of None
let invalid_formats = vec![
"invalid",
"not-a-number",
"30.5", // float instead of int
"abc123",
];
for format in invalid_formats {
let duration = parse_retry_after_for_test(format);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Invalid format '{}' should fallback to 60s",
format
);
}
}
#[test]
fn test_retry_after_past_date_returns_zero() {
// When HTTP-date is in the past, should return Duration::ZERO
// (not None, which would trigger immediate retry)
let past = chrono::Utc::now() - chrono::Duration::seconds(60);
let past_date_str = past.to_rfc2822();
let duration = parse_retry_after_for_test(&past_date_str);
assert_eq!(
duration,
Some(std::time::Duration::ZERO),
"Past date should return Duration::ZERO, not None"
);
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
#[test]
fn test_retry_after_large_number() {
// Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_for_test("3600"); // 1 hour
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
let huge = parse_retry_after_for_test("18446744073709551615");
assert_eq!(
huge,
Some(std::time::Duration::from_secs(
crate::llm::retry::MAX_RETRY_AFTER_SECS
))
);
}
/// Helper function to test Retry-After header parsing logic
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
Some(cap_retry_after(std::time::Duration::from_secs(
delta.num_seconds().max(0) as u64,
)))
} else {
None
};
// Apply fallback to 60s if parsing failed (matches actual code behavior)
parsed.or(Some(std::time::Duration::from_secs(60)))
}
}
+25 -4
View File
@@ -361,6 +361,7 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::helpers::ENV_MUTEX;
#[test]
fn loopback_detection() {
@@ -385,12 +386,22 @@ mod tests {
assert!(!is_wildcard_host("localhost"));
}
// Lock held across await to serialize env-var mutation; the awaited op is a quick local TCP bind.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn bind_rejects_wildcard_ipv4() {
// SAFETY: test is single-threaded; env var is restored immediately after.
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "0.0.0.0") };
let result = bind_callback_listener().await;
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
match &original {
Some(v) => std::env::set_var("OAUTH_CALLBACK_HOST", v),
None => std::env::remove_var("OAUTH_CALLBACK_HOST"),
}
}
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
@@ -399,12 +410,22 @@ mod tests {
);
}
// Lock held across await to serialize env-var mutation; the awaited op is a quick local TCP bind.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn bind_rejects_wildcard_ipv6() {
// SAFETY: test is single-threaded; env var is restored immediately after.
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "::") };
let result = bind_callback_listener().await;
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
match &original {
Some(v) => std::env::set_var("OAUTH_CALLBACK_HOST", v),
None => std::env::remove_var("OAUTH_CALLBACK_HOST"),
}
}
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
+76
View File
@@ -78,6 +78,33 @@ pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
}
/// Parse a `Retry-After` header value into a capped `Duration`.
///
/// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats (RFC 7231
/// §7.1.1 / IMF-fixdate). The implementation uses `chrono::DateTime::parse_from_rfc2822`,
/// which also accepts RFC 2822-style dates.
/// Returns `DEFAULT_RETRY_AFTER` (60 s) if the header is missing or unparseable.
pub(crate) fn parse_retry_after(header: Option<&reqwest::header::HeaderValue>) -> Duration {
header
.and_then(|v| v.to_str().ok())
.and_then(|v| {
if let Ok(secs) = v.trim().parse::<u64>() {
return Some(cap_retry_after(Duration::from_secs(secs)));
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
return Some(cap_retry_after(Duration::from_secs(
delta.num_seconds().max(0) as u64,
)));
}
None
})
.unwrap_or(Duration::from_secs(DEFAULT_RETRY_AFTER_SECS))
}
const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
@@ -444,4 +471,53 @@ mod tests {
Duration::from_secs(0)
);
}
#[test]
fn parse_retry_after_delay_seconds() {
let val = reqwest::header::HeaderValue::from_static("30");
assert_eq!(parse_retry_after(Some(&val)), Duration::from_secs(30));
}
#[test]
fn parse_retry_after_missing_header() {
assert_eq!(
parse_retry_after(None),
Duration::from_secs(DEFAULT_RETRY_AFTER_SECS)
);
}
#[test]
fn parse_retry_after_unparseable() {
let val = reqwest::header::HeaderValue::from_static("not-a-number");
assert_eq!(
parse_retry_after(Some(&val)),
Duration::from_secs(DEFAULT_RETRY_AFTER_SECS)
);
}
#[test]
fn parse_retry_after_clamps_large_value() {
let val = reqwest::header::HeaderValue::from_static("999999");
assert_eq!(
parse_retry_after(Some(&val)),
Duration::from_secs(MAX_RETRY_AFTER_SECS)
);
}
#[test]
fn parse_retry_after_http_date() {
let future = chrono::Utc::now() + chrono::Duration::seconds(30);
let date_str = future.to_rfc2822();
let val = reqwest::header::HeaderValue::from_str(&date_str).unwrap();
let parsed = parse_retry_after(Some(&val));
let diff = if parsed > Duration::from_secs(30) {
parsed - Duration::from_secs(30)
} else {
Duration::from_secs(30) - parsed
};
assert!(
diff <= Duration::from_secs(2),
"expected ~30s, got {parsed:?} (diff {diff:?}) from header {date_str:?}"
);
}
}
+6 -63
View File
@@ -6,8 +6,6 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::llm::retry::cap_retry_after;
/// Error type for embedding operations.
#[derive(Debug, thiserror::Error)]
pub enum EmbeddingError {
@@ -228,14 +226,9 @@ impl EmbeddingProvider for OpenAiEmbeddings {
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -371,14 +364,9 @@ impl EmbeddingProvider for NearAiEmbeddings {
}
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let retry_after = Some(crate::llm::retry::parse_retry_after(
response.headers().get("retry-after"),
));
return Err(EmbeddingError::RateLimited { retry_after });
}
@@ -652,49 +640,4 @@ mod tests {
let provider = OpenAiEmbeddings::new("test-key").with_base_url("custom.example.com/v1");
assert_eq!(provider.base_url, "https://custom.example.com/v1");
}
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
#[test]
fn test_retry_after_parsing_delay_seconds() {
// Verify delay-seconds format is parsed correctly
let header_value = "120";
let duration = parse_retry_after_embeddings_for_test(header_value);
assert_eq!(
duration,
Some(std::time::Duration::from_secs(120)),
"Should parse delay-seconds format"
);
}
#[test]
fn test_retry_after_fallback_missing_header() {
// Regression test: When Retry-After header is missing,
// should fall back to 60s instead of None
let duration = parse_retry_after_embeddings_for_test("");
assert_eq!(
duration,
Some(std::time::Duration::from_secs(60)),
"Missing header should fallback to 60s"
);
}
#[test]
fn test_retry_after_zero_seconds_accepted() {
// Verify zero seconds is a valid retry delay
let duration = parse_retry_after_embeddings_for_test("0");
assert_eq!(duration, Some(std::time::Duration::ZERO));
}
/// Helper function to test Retry-After header parsing logic for embeddings
/// (simulates the parsing done in embed without actual HTTP, including fallback)
fn parse_retry_after_embeddings_for_test(header_value: &str) -> Option<std::time::Duration> {
header_value
.trim()
.parse::<u64>()
.ok()
.map(std::time::Duration::from_secs)
.map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}