mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* fix: Rate limiter returns retry after None instead of a duration linter fix * review fixes * fix: rate limiter returns None for retry_after duration Add regression test to src/llm/retry.rs that verifies RateLimited errors always have a fallback duration (never None) due to the 60-second fallback applied in all rate limit error creation sites (nearai_chat.rs, anthropic_oauth.rs, embeddings.rs). The production code fix adds `.or(Some(Duration::from_secs(60)))` to ensure the error message never displays "retry after None" to the user. [skip-regression-check] Co-Authored-By: Claude Haiku 4.5 <[email protected]> --------- Co-authored-by: Claude Haiku 4.5 <[email protected]>
696 lines
20 KiB
Rust
696 lines
20 KiB
Rust
//! Embedding providers for semantic search.
|
|
//!
|
|
//! Embeddings convert text into dense vectors that capture semantic meaning.
|
|
//! Similar concepts have similar vectors, enabling semantic search.
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Error type for embedding operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum EmbeddingError {
|
|
#[error("HTTP request failed: {0}")]
|
|
HttpError(String),
|
|
|
|
#[error("Invalid response: {0}")]
|
|
InvalidResponse(String),
|
|
|
|
#[error("Rate limited, retry after {retry_after:?}")]
|
|
RateLimited {
|
|
retry_after: Option<std::time::Duration>,
|
|
},
|
|
|
|
#[error("Authentication failed")]
|
|
AuthFailed,
|
|
|
|
#[error("Text too long: {length} > {max}")]
|
|
TextTooLong { length: usize, max: usize },
|
|
}
|
|
|
|
impl From<reqwest::Error> for EmbeddingError {
|
|
fn from(e: reqwest::Error) -> Self {
|
|
EmbeddingError::HttpError(e.to_string())
|
|
}
|
|
}
|
|
|
|
/// Trait for embedding providers.
|
|
#[async_trait]
|
|
pub trait EmbeddingProvider: Send + Sync {
|
|
/// Get the embedding dimension.
|
|
fn dimension(&self) -> usize;
|
|
|
|
/// Get the model name.
|
|
fn model_name(&self) -> &str;
|
|
|
|
/// Maximum input length in characters.
|
|
fn max_input_length(&self) -> usize;
|
|
|
|
/// Generate an embedding for a single text.
|
|
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError>;
|
|
|
|
/// Generate embeddings for multiple texts (batched).
|
|
///
|
|
/// Default implementation calls embed() for each text.
|
|
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
let mut embeddings = Vec::with_capacity(texts.len());
|
|
for text in texts {
|
|
embeddings.push(self.embed(text).await?);
|
|
}
|
|
Ok(embeddings)
|
|
}
|
|
}
|
|
|
|
/// Default base URL for the OpenAI API.
|
|
const OPENAI_API_BASE_URL: &str = "https://api.openai.com";
|
|
|
|
/// OpenAI embedding provider using text-embedding-ada-002 or text-embedding-3-small.
|
|
///
|
|
/// Supports any OpenAI-compatible embedding endpoint via [`with_base_url`](Self::with_base_url).
|
|
pub struct OpenAiEmbeddings {
|
|
client: reqwest::Client,
|
|
api_key: String,
|
|
model: String,
|
|
dimension: usize,
|
|
base_url: String,
|
|
}
|
|
|
|
impl OpenAiEmbeddings {
|
|
/// Create a new OpenAI embedding provider with the default model.
|
|
///
|
|
/// Uses text-embedding-3-small which has 1536 dimensions.
|
|
pub fn new(api_key: impl Into<String>) -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
api_key: api_key.into(),
|
|
model: "text-embedding-3-small".to_string(),
|
|
dimension: 1536,
|
|
base_url: OPENAI_API_BASE_URL.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Use text-embedding-ada-002 model.
|
|
pub fn ada_002(api_key: impl Into<String>) -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
api_key: api_key.into(),
|
|
model: "text-embedding-ada-002".to_string(),
|
|
dimension: 1536,
|
|
base_url: OPENAI_API_BASE_URL.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Use text-embedding-3-large model.
|
|
pub fn large(api_key: impl Into<String>) -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
api_key: api_key.into(),
|
|
model: "text-embedding-3-large".to_string(),
|
|
dimension: 3072,
|
|
base_url: OPENAI_API_BASE_URL.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Use a custom model with specified dimension.
|
|
pub fn with_model(
|
|
api_key: impl Into<String>,
|
|
model: impl Into<String>,
|
|
dimension: usize,
|
|
) -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
api_key: api_key.into(),
|
|
model: model.into(),
|
|
dimension,
|
|
base_url: OPENAI_API_BASE_URL.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Set a custom base URL for OpenAI-compatible embedding providers.
|
|
///
|
|
/// The URL must use `http://` or `https://` scheme. If no scheme is present,
|
|
/// `https://` is prepended automatically. Trailing slashes are stripped.
|
|
pub fn with_base_url(mut self, base_url: &str) -> Self {
|
|
let url = base_url.trim();
|
|
|
|
// Auto-prepend https:// if no scheme is present.
|
|
let mut url = if !url.starts_with("http://") && !url.starts_with("https://") {
|
|
tracing::debug!(
|
|
"No scheme in embedding base URL '{}', prepending https://",
|
|
url
|
|
);
|
|
format!("https://{url}")
|
|
} else {
|
|
url.to_string()
|
|
};
|
|
|
|
while url.ends_with('/') {
|
|
url.pop();
|
|
}
|
|
|
|
self.base_url = url;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct OpenAiEmbeddingRequest<'a> {
|
|
model: &'a str,
|
|
input: &'a [String],
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OpenAiEmbeddingResponse {
|
|
data: Vec<OpenAiEmbeddingData>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OpenAiEmbeddingData {
|
|
embedding: Vec<f32>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EmbeddingProvider for OpenAiEmbeddings {
|
|
fn dimension(&self) -> usize {
|
|
self.dimension
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
&self.model
|
|
}
|
|
|
|
fn max_input_length(&self) -> usize {
|
|
// text-embedding-3-small/large: 8191 tokens (~32k chars)
|
|
// text-embedding-ada-002: 8191 tokens
|
|
32_000
|
|
}
|
|
|
|
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
if text.len() > self.max_input_length() {
|
|
return Err(EmbeddingError::TextTooLong {
|
|
length: text.len(),
|
|
max: self.max_input_length(),
|
|
});
|
|
}
|
|
|
|
let embeddings = self.embed_batch(&[text.to_string()]).await?;
|
|
embeddings
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string()))
|
|
}
|
|
|
|
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
if texts.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let request = OpenAiEmbeddingRequest {
|
|
model: &self.model,
|
|
input: texts,
|
|
};
|
|
|
|
let url = format!("{}/v1/embeddings", self.base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", self.api_key))
|
|
.json(&request)
|
|
.send()
|
|
.await?;
|
|
|
|
let status = response.status();
|
|
|
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
return Err(EmbeddingError::AuthFailed);
|
|
}
|
|
|
|
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)
|
|
.or(Some(std::time::Duration::from_secs(60)));
|
|
return Err(EmbeddingError::RateLimited { retry_after });
|
|
}
|
|
|
|
if !status.is_success() {
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
return Err(EmbeddingError::HttpError(format!(
|
|
"Status {}: {}",
|
|
status, error_text
|
|
)));
|
|
}
|
|
|
|
let result: OpenAiEmbeddingResponse = response.json().await.map_err(|e| {
|
|
EmbeddingError::InvalidResponse(format!("Failed to parse response: {}", e))
|
|
})?;
|
|
|
|
Ok(result.data.into_iter().map(|d| d.embedding).collect())
|
|
}
|
|
}
|
|
|
|
/// NEAR AI embedding provider using the NEAR AI API.
|
|
///
|
|
/// Uses the same session-based auth as the LLM provider.
|
|
pub struct NearAiEmbeddings {
|
|
client: reqwest::Client,
|
|
base_url: String,
|
|
session: std::sync::Arc<crate::llm::SessionManager>,
|
|
model: String,
|
|
dimension: usize,
|
|
}
|
|
|
|
impl NearAiEmbeddings {
|
|
/// Create a new NEAR AI embedding provider.
|
|
///
|
|
/// Uses the same session manager as the LLM provider for auth.
|
|
pub fn new(
|
|
base_url: impl Into<String>,
|
|
session: std::sync::Arc<crate::llm::SessionManager>,
|
|
) -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
base_url: base_url.into(),
|
|
session,
|
|
model: "text-embedding-3-small".to_string(),
|
|
dimension: 1536,
|
|
}
|
|
}
|
|
|
|
/// Use a specific model.
|
|
pub fn with_model(mut self, model: impl Into<String>, dimension: usize) -> Self {
|
|
self.model = model.into();
|
|
self.dimension = dimension;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct NearAiEmbeddingRequest<'a> {
|
|
model: &'a str,
|
|
input: &'a [String],
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NearAiEmbeddingResponse {
|
|
data: Vec<NearAiEmbeddingData>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct NearAiEmbeddingData {
|
|
embedding: Vec<f32>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EmbeddingProvider for NearAiEmbeddings {
|
|
fn dimension(&self) -> usize {
|
|
self.dimension
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
&self.model
|
|
}
|
|
|
|
fn max_input_length(&self) -> usize {
|
|
32_000
|
|
}
|
|
|
|
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
if text.len() > self.max_input_length() {
|
|
return Err(EmbeddingError::TextTooLong {
|
|
length: text.len(),
|
|
max: self.max_input_length(),
|
|
});
|
|
}
|
|
|
|
let embeddings = self.embed_batch(&[text.to_string()]).await?;
|
|
embeddings
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string()))
|
|
}
|
|
|
|
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
use secrecy::ExposeSecret;
|
|
|
|
if texts.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let request = NearAiEmbeddingRequest {
|
|
model: &self.model,
|
|
input: texts,
|
|
};
|
|
|
|
let token = self
|
|
.session
|
|
.get_token()
|
|
.await
|
|
.map_err(|_| EmbeddingError::AuthFailed)?;
|
|
|
|
let url = format!("{}/v1/embeddings", self.base_url);
|
|
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {}", token.expose_secret()))
|
|
.json(&request)
|
|
.send()
|
|
.await?;
|
|
|
|
let status = response.status();
|
|
|
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
return Err(EmbeddingError::AuthFailed);
|
|
}
|
|
|
|
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)
|
|
.or(Some(std::time::Duration::from_secs(60)));
|
|
return Err(EmbeddingError::RateLimited { retry_after });
|
|
}
|
|
|
|
if !status.is_success() {
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
return Err(EmbeddingError::HttpError(format!(
|
|
"Status {}: {}",
|
|
status, error_text
|
|
)));
|
|
}
|
|
|
|
let result: NearAiEmbeddingResponse = response.json().await.map_err(|e| {
|
|
EmbeddingError::InvalidResponse(format!("Failed to parse response: {}", e))
|
|
})?;
|
|
|
|
Ok(result.data.into_iter().map(|d| d.embedding).collect())
|
|
}
|
|
}
|
|
|
|
/// Ollama embedding provider using a local Ollama instance.
|
|
///
|
|
/// Ollama serves embedding models (e.g. `nomic-embed-text`, `mxbai-embed-large`)
|
|
/// via a REST API, typically at `http://localhost:11434`.
|
|
pub struct OllamaEmbeddings {
|
|
client: reqwest::Client,
|
|
base_url: String,
|
|
model: String,
|
|
dimension: usize,
|
|
}
|
|
|
|
impl OllamaEmbeddings {
|
|
/// Create a new Ollama embedding provider.
|
|
///
|
|
/// Defaults to `nomic-embed-text` (768 dimensions).
|
|
pub fn new(base_url: impl Into<String>) -> Self {
|
|
Self {
|
|
client: reqwest::Client::new(),
|
|
base_url: base_url.into(),
|
|
model: "nomic-embed-text".to_string(),
|
|
dimension: 768,
|
|
}
|
|
}
|
|
|
|
/// Use a specific model with a given dimension.
|
|
pub fn with_model(mut self, model: impl Into<String>, dimension: usize) -> Self {
|
|
self.model = model.into();
|
|
self.dimension = dimension;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct OllamaEmbedRequest<'a> {
|
|
model: &'a str,
|
|
input: &'a [String],
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OllamaEmbedResponse {
|
|
embeddings: Vec<Vec<f32>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EmbeddingProvider for OllamaEmbeddings {
|
|
fn dimension(&self) -> usize {
|
|
self.dimension
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
&self.model
|
|
}
|
|
|
|
fn max_input_length(&self) -> usize {
|
|
// Most Ollama embedding models support 8192 tokens (~32k chars)
|
|
32_000
|
|
}
|
|
|
|
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
if text.len() > self.max_input_length() {
|
|
return Err(EmbeddingError::TextTooLong {
|
|
length: text.len(),
|
|
max: self.max_input_length(),
|
|
});
|
|
}
|
|
|
|
let embeddings = self.embed_batch(&[text.to_string()]).await?;
|
|
embeddings
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string()))
|
|
}
|
|
|
|
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
|
|
if texts.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let request = OllamaEmbedRequest {
|
|
model: &self.model,
|
|
input: texts,
|
|
};
|
|
|
|
let url = format!("{}/api/embed", self.base_url);
|
|
|
|
let response = self.client.post(&url).json(&request).send().await?;
|
|
|
|
let status = response.status();
|
|
|
|
if !status.is_success() {
|
|
let error_text = response.text().await.unwrap_or_default();
|
|
return Err(EmbeddingError::HttpError(format!(
|
|
"Ollama returned HTTP {}: {}",
|
|
status, error_text
|
|
)));
|
|
}
|
|
|
|
let result: OllamaEmbedResponse = response.json().await.map_err(|e| {
|
|
EmbeddingError::InvalidResponse(format!("Failed to parse Ollama response: {}", e))
|
|
})?;
|
|
|
|
// Validate that returned embeddings match the configured dimension.
|
|
for (i, emb) in result.embeddings.iter().enumerate() {
|
|
if emb.len() != self.dimension {
|
|
return Err(EmbeddingError::InvalidResponse(format!(
|
|
"Ollama returned embedding of dimension {}, expected {} at index {}",
|
|
emb.len(),
|
|
self.dimension,
|
|
i
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(result.embeddings)
|
|
}
|
|
}
|
|
|
|
/// A mock embedding provider for testing.
|
|
///
|
|
/// Generates deterministic embeddings based on text hash.
|
|
/// Useful for unit and integration tests.
|
|
pub struct MockEmbeddings {
|
|
dimension: usize,
|
|
}
|
|
|
|
impl MockEmbeddings {
|
|
/// Create a new mock embeddings provider with the given dimension.
|
|
pub fn new(dimension: usize) -> Self {
|
|
Self { dimension }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl EmbeddingProvider for MockEmbeddings {
|
|
fn dimension(&self) -> usize {
|
|
self.dimension
|
|
}
|
|
|
|
fn model_name(&self) -> &str {
|
|
"mock-embedding"
|
|
}
|
|
|
|
fn max_input_length(&self) -> usize {
|
|
10_000
|
|
}
|
|
|
|
async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
|
|
// Generate a deterministic embedding based on text hash
|
|
use std::hash::{Hash, Hasher};
|
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
|
text.hash(&mut hasher);
|
|
let hash = hasher.finish();
|
|
|
|
let mut embedding = Vec::with_capacity(self.dimension);
|
|
let mut seed = hash;
|
|
for _ in 0..self.dimension {
|
|
// Simple LCG for deterministic random values
|
|
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
|
let value = (seed as f32 / u64::MAX as f32) * 2.0 - 1.0;
|
|
embedding.push(value);
|
|
}
|
|
|
|
// Normalize to unit length
|
|
let magnitude: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
if magnitude > 0.0 {
|
|
for x in &mut embedding {
|
|
*x /= magnitude;
|
|
}
|
|
}
|
|
|
|
Ok(embedding)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_embeddings() {
|
|
let provider = MockEmbeddings::new(128);
|
|
|
|
let embedding = provider.embed("hello world").await.unwrap();
|
|
assert_eq!(embedding.len(), 128);
|
|
|
|
// Check normalization (should be unit vector)
|
|
let magnitude: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
assert!((magnitude - 1.0).abs() < 0.001);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_embeddings_deterministic() {
|
|
let provider = MockEmbeddings::new(64);
|
|
|
|
let emb1 = provider.embed("test").await.unwrap();
|
|
let emb2 = provider.embed("test").await.unwrap();
|
|
|
|
// Same input should produce same embedding
|
|
assert_eq!(emb1, emb2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_mock_embeddings_batch() {
|
|
let provider = MockEmbeddings::new(64);
|
|
|
|
let texts = vec!["hello".to_string(), "world".to_string()];
|
|
let embeddings = provider.embed_batch(&texts).await.unwrap();
|
|
|
|
assert_eq!(embeddings.len(), 2);
|
|
assert_eq!(embeddings[0].len(), 64);
|
|
assert_eq!(embeddings[1].len(), 64);
|
|
|
|
// Different texts should produce different embeddings
|
|
assert_ne!(embeddings[0], embeddings[1]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_openai_embeddings_config() {
|
|
let provider = OpenAiEmbeddings::new("test-key");
|
|
assert_eq!(provider.dimension(), 1536);
|
|
assert_eq!(provider.model_name(), "text-embedding-3-small");
|
|
assert_eq!(provider.base_url, OPENAI_API_BASE_URL);
|
|
|
|
let provider = OpenAiEmbeddings::large("test-key");
|
|
assert_eq!(provider.dimension(), 3072);
|
|
assert_eq!(provider.model_name(), "text-embedding-3-large");
|
|
assert_eq!(provider.base_url, OPENAI_API_BASE_URL);
|
|
}
|
|
|
|
#[test]
|
|
fn test_openai_with_base_url_valid() {
|
|
let provider =
|
|
OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com");
|
|
assert_eq!(provider.base_url, "https://custom.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn test_openai_with_base_url_strips_trailing_slashes() {
|
|
let provider =
|
|
OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com///");
|
|
assert_eq!(provider.base_url, "https://custom.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn test_openai_with_base_url_http_scheme() {
|
|
let provider = OpenAiEmbeddings::new("test-key").with_base_url("http://localhost:8080");
|
|
assert_eq!(provider.base_url, "http://localhost:8080");
|
|
}
|
|
|
|
#[test]
|
|
fn test_openai_with_base_url_schemeless_prepends_https() {
|
|
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)
|
|
.or(Some(std::time::Duration::from_secs(60)))
|
|
}
|
|
}
|