1use std::time::Duration;
33
34use async_trait::async_trait;
35use graph_storage_sdk::models::EmbeddingSpaceId;
36use graph_storage_sdk::plugin_api::{
37 EmbedRequest, EmbedResponse, EmbeddingProviderError, EmbeddingProviderV1,
38};
39use secrecy::{ExposeSecret, SecretString};
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42use tracing::{debug, warn};
43use url::Url;
44
45#[derive(Clone, Debug)]
47pub struct RemoteProviderConfig {
48 pub base_url: String,
51 pub model: String,
53 pub api_key: Option<SecretString>,
56 pub dimension: u32,
59 pub request_dimensions: bool,
65 pub normalize: bool,
67 pub batch_size: usize,
70 pub timeout: Duration,
72 pub max_retries: u32,
81}
82
83impl RemoteProviderConfig {
84 #[must_use]
87 pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
88 Self {
89 base_url: base_url.into(),
90 model: model.into(),
91 api_key: None,
92 dimension: 384,
93 request_dimensions: true,
94 normalize: true,
95 batch_size: 64,
96 timeout: Duration::from_mins(1),
97 max_retries: 2,
98 }
99 }
100
101 #[must_use]
103 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
104 self.api_key = Some(SecretString::from(api_key.into()));
105 self
106 }
107}
108
109#[derive(Debug, Error)]
110#[non_exhaustive]
111pub enum RemoteConfigError {
112 #[error("base_url {0:?} is not an absolute http(s) URL")]
113 BaseUrl(String),
114 #[error(
115 "base_url carries credentials in its userinfo; put the key in the environment variable \
116 `embedding_remote_api_key_env` names instead, where it is not part of a URL that gets \
117 logged, echoed in an error, or copied into a bug report"
118 )]
119 CredentialsInUrl,
120 #[error(
121 "an api_key is configured and base_url is plain http to {host}, which puts the bearer \
122 token on the wire in clear for every proxy and log in between; use https, or a \
123 loopback host if this is a local endpoint"
124 )]
125 CredentialOverPlainHttp { host: String },
126 #[error("model must not be empty")]
127 Model,
128 #[error("model must be at most {max} characters, and this one is {got}")]
129 ModelTooLong { max: usize, got: usize },
130 #[error(
131 "model must not carry control characters: it becomes part of the embedding-space \
132 identity, which is compared for equality and written to the operator log"
133 )]
134 ModelControlCharacters,
135 #[error("dimension must be positive")]
136 Dimension,
137 #[error("batch_size must be positive")]
138 BatchSize,
139 #[error("the HTTP client could not be built: {0}")]
140 Client(String),
141}
142
143fn is_loopback(url: &Url) -> bool {
150 match url.host() {
151 Some(url::Host::Ipv4(address)) => address.is_loopback(),
152 Some(url::Host::Ipv6(address)) => address.is_loopback(),
153 Some(url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
154 None => false,
155 }
156}
157
158const MAX_MODEL_LEN: usize = 200;
165
166const EMPTY_INPUT_PLACEHOLDER: &str = "(empty)";
171
172pub struct RemoteEmbeddingProvider {
174 http: reqwest::Client,
175 endpoint: Url,
177 config: RemoteProviderConfig,
178 space: EmbeddingSpaceId,
179}
180
181impl RemoteEmbeddingProvider {
182 pub fn new(config: RemoteProviderConfig) -> Result<Self, RemoteConfigError> {
193 let base = Url::parse(config.base_url.trim_end_matches('/'))
194 .ok()
195 .filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
196 .ok_or_else(|| RemoteConfigError::BaseUrl(config.base_url.clone()))?;
197 if !base.username().is_empty() || base.password().is_some() {
202 return Err(RemoteConfigError::CredentialsInUrl);
203 }
204 if config.api_key.is_some() && base.scheme() == "http" && !is_loopback(&base) {
216 return Err(RemoteConfigError::CredentialOverPlainHttp {
217 host: base.host_str().unwrap_or_default().to_owned(),
218 });
219 }
220 let model = config.model.trim();
228 if model.is_empty() {
229 return Err(RemoteConfigError::Model);
230 }
231 if model.chars().count() > MAX_MODEL_LEN {
232 return Err(RemoteConfigError::ModelTooLong {
233 max: MAX_MODEL_LEN,
234 got: model.chars().count(),
235 });
236 }
237 if model.chars().any(char::is_control) {
238 return Err(RemoteConfigError::ModelControlCharacters);
239 }
240 if config.dimension == 0 {
241 return Err(RemoteConfigError::Dimension);
242 }
243 if config.batch_size == 0 {
244 return Err(RemoteConfigError::BatchSize);
245 }
246
247 let mut endpoint = base.clone();
248 endpoint.set_path(&format!("{}/embeddings", base.path().trim_end_matches('/')));
249 endpoint.set_query(None);
250
251 let http = reqwest::Client::builder()
252 .timeout(config.timeout)
253 .build()
254 .map_err(|error| RemoteConfigError::Client(error.to_string()))?;
255
256 let space = EmbeddingSpaceId::new(
260 format!("{}@{}", config.model.trim(), endpoint_name(&endpoint)),
261 "provider-managed",
262 serde_json::json!({
263 "protocol": "openai-embeddings-v1",
264 "requested_dimensions": config.request_dimensions.then_some(config.dimension),
265 "empty_input": EMPTY_INPUT_PLACEHOLDER,
266 }),
267 serde_json::json!({ "strategy": "provider" }),
268 serde_json::json!({ "l2": config.normalize }),
269 config.dimension,
270 );
271
272 Ok(Self {
273 http,
274 endpoint,
275 config,
276 space,
277 })
278 }
279
280 #[must_use]
282 pub fn endpoint(&self) -> &Url {
283 &self.endpoint
284 }
285}
286
287fn endpoint_name(endpoint: &Url) -> String {
289 let scheme = endpoint.scheme();
294 let host = endpoint.host_str().unwrap_or("unknown-host");
295 match endpoint.port() {
296 Some(port) => format!("{scheme}://{host}:{port}{}", endpoint.path()),
297 None => format!("{scheme}://{host}{}", endpoint.path()),
298 }
299}
300
301#[derive(Serialize)]
302struct EmbeddingsRequest<'a> {
303 model: &'a str,
304 input: Vec<&'a str>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 dimensions: Option<u32>,
307}
308
309#[derive(Deserialize)]
310struct EmbeddingsResponse {
311 #[serde(default)]
312 data: Vec<EmbeddingDatum>,
313}
314
315#[derive(Deserialize)]
316struct EmbeddingDatum {
317 index: usize,
318 embedding: Vec<f32>,
319}
320
321#[async_trait]
322impl EmbeddingProviderV1 for RemoteEmbeddingProvider {
323 fn embedding_space(&self) -> &EmbeddingSpaceId {
324 &self.space
325 }
326
327 fn dimension(&self) -> u32 {
328 self.config.dimension
329 }
330
331 async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, EmbeddingProviderError> {
332 if req.cancel.is_cancelled() {
333 return Err(EmbeddingProviderError::Cancelled);
334 }
335 if req.budget.is_exhausted() {
336 return Err(EmbeddingProviderError::Deadline);
337 }
338
339 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(req.inputs.len());
340 for chunk in req.inputs.chunks(self.config.batch_size) {
341 if req.cancel.is_cancelled() {
344 return Err(EmbeddingProviderError::Cancelled);
345 }
346 let remaining = req.budget.remaining();
347 if remaining.is_zero() {
348 return Err(EmbeddingProviderError::Deadline);
349 }
350 let batch = self.embed_chunk_with_retries(chunk, &req).await?;
351 vectors.extend(batch);
352 }
353
354 Ok(EmbedResponse {
355 vectors,
356 space: self.space.clone(),
357 })
358 }
359
360 async fn health(&self) -> Result<(), EmbeddingProviderError> {
363 self.embed_chunk(
364 &["health".to_owned()],
365 AttemptWindow::provider_bound(self.config.timeout.min(Duration::from_secs(10))),
368 )
369 .await
370 .map(drop)
371 .map_err(|refusal| refusal.error)
372 }
373}
374
375#[derive(Clone, Copy)]
387struct AttemptWindow {
388 timeout: Duration,
389 caller_bound: bool,
390}
391
392impl AttemptWindow {
393 fn for_attempt(remaining: Duration, configured: Duration) -> Self {
394 Self {
395 timeout: remaining.min(configured),
396 caller_bound: remaining <= configured,
397 }
398 }
399
400 fn provider_bound(timeout: Duration) -> Self {
402 Self {
403 timeout,
404 caller_bound: false,
405 }
406 }
407}
408
409impl RemoteEmbeddingProvider {
410 async fn embed_chunk_with_retries(
421 &self,
422 chunk: &[String],
423 req: &EmbedRequest,
424 ) -> Result<Vec<Vec<f32>>, EmbeddingProviderError> {
425 let mut backoff = Duration::from_millis(200);
426 for attempt in 0..=self.config.max_retries {
427 let remaining = req.budget.remaining();
428 if remaining.is_zero() {
429 return Err(EmbeddingProviderError::Deadline);
430 }
431 let call = self.embed_chunk(
432 chunk,
433 AttemptWindow::for_attempt(remaining, self.config.timeout),
434 );
435 let outcome = tokio::select! {
436 () = req.cancel.cancelled() => return Err(EmbeddingProviderError::Cancelled),
437 result = call => result,
438 };
439 let refusal = match outcome {
440 Ok(vectors) => return Ok(vectors),
441 Err(refusal) => refusal,
442 };
443 if attempt == self.config.max_retries || !refusal.retryable {
448 return Err(refusal.error);
449 }
450 let wait = backoff.min(req.budget.remaining());
451 if wait.is_zero() {
452 return Err(refusal.error);
453 }
454 warn!(
455 endpoint = %endpoint_name(&self.endpoint),
456 attempt = attempt + 1,
457 wait_ms = wait.as_millis(),
458 "the embeddings endpoint answered transiently; retrying"
459 );
460 tokio::select! {
461 () = req.cancel.cancelled() => return Err(EmbeddingProviderError::Cancelled),
462 () = tokio::time::sleep(wait) => {}
463 }
464 backoff = backoff.saturating_mul(2);
465 }
466 Err(EmbeddingProviderError::Internal(
469 "the retry loop ended without an attempt".to_owned(),
470 ))
471 }
472
473 async fn embed_chunk(
476 &self,
477 inputs: &[String],
478 window: AttemptWindow,
479 ) -> Result<Vec<Vec<f32>>, Refusal> {
480 let body = EmbeddingsRequest {
481 model: self.config.model.trim(),
482 input: inputs
483 .iter()
484 .map(|text| {
485 if text.trim().is_empty() {
486 EMPTY_INPUT_PLACEHOLDER
487 } else {
488 text.as_str()
489 }
490 })
491 .collect(),
492 dimensions: self
493 .config
494 .request_dimensions
495 .then_some(self.config.dimension),
496 };
497
498 let mut request = self
499 .http
500 .post(self.endpoint.clone())
501 .timeout(window.timeout)
502 .json(&body);
503 if let Some(key) = &self.config.api_key {
504 request = request.bearer_auth(key.expose_secret());
505 }
506
507 let response = request.send().await.map_err(|error| {
508 if error.is_timeout() && window.caller_bound {
509 Refusal::permanent(EmbeddingProviderError::Deadline)
512 } else {
513 Refusal::transient(EmbeddingProviderError::Unavailable {
514 reason: format!("{}: {error}", endpoint_name(&self.endpoint)),
515 })
516 }
517 })?;
518
519 let status = response.status();
520 if !status.is_success() {
521 let body_bytes = response.bytes().await.map_or(0, |b| b.len());
525 warn!(
526 endpoint = %endpoint_name(&self.endpoint),
527 status = status.as_u16(),
528 body_bytes,
529 "the embeddings endpoint refused the request"
530 );
531 return Err(classify_status(status));
532 }
533
534 let parsed: EmbeddingsResponse = response.json().await.map_err(|error| {
535 Refusal::permanent(EmbeddingProviderError::Internal(format!(
536 "unparseable embeddings response: {error}"
537 )))
538 })?;
539 self.align(inputs.len(), parsed.data)
540 .map_err(Refusal::permanent)
541 }
542
543 fn align(
546 &self,
547 expected: usize,
548 data: Vec<EmbeddingDatum>,
549 ) -> Result<Vec<Vec<f32>>, EmbeddingProviderError> {
550 let width = self.config.dimension as usize;
551 let mut slots: Vec<Option<Vec<f32>>> = vec![None; expected];
552 for datum in data {
553 let Some(slot) = slots.get_mut(datum.index) else {
554 return Err(EmbeddingProviderError::Internal(format!(
555 "the endpoint returned index {} for a batch of {expected}",
556 datum.index
557 )));
558 };
559 if slot.is_some() {
560 return Err(EmbeddingProviderError::Internal(format!(
561 "the endpoint returned index {} twice",
562 datum.index
563 )));
564 }
565 if datum.embedding.len() != width {
566 debug!(
567 got = datum.embedding.len(),
568 want = width,
569 "the endpoint returned a vector of another width"
570 );
571 return Err(EmbeddingProviderError::SpaceMismatch);
572 }
573 if !datum.embedding.iter().all(|lane| lane.is_finite()) {
574 return Err(EmbeddingProviderError::Internal(format!(
575 "vector {} carries a non-finite lane",
576 datum.index
577 )));
578 }
579 *slot = Some(if self.config.normalize {
580 normalize(datum.embedding)
581 } else {
582 datum.embedding
583 });
584 }
585 slots
586 .into_iter()
587 .enumerate()
588 .map(|(index, slot)| {
589 slot.ok_or_else(|| {
590 EmbeddingProviderError::Internal(format!(
591 "the endpoint returned no vector for input {index} of {expected}"
592 ))
593 })
594 })
595 .collect()
596 }
597}
598
599struct Refusal {
609 error: EmbeddingProviderError,
610 retryable: bool,
611}
612
613impl Refusal {
614 const fn transient(error: EmbeddingProviderError) -> Self {
617 Self {
618 error,
619 retryable: true,
620 }
621 }
622
623 const fn permanent(error: EmbeddingProviderError) -> Self {
627 Self {
628 error,
629 retryable: false,
630 }
631 }
632}
633
634fn classify_status(status: reqwest::StatusCode) -> Refusal {
642 match status.as_u16() {
643 401 | 403 => Refusal::permanent(EmbeddingProviderError::Unavailable {
644 reason: format!("the endpoint refused the credential (HTTP {status})"),
645 }),
646 408 | 429 | 500 | 502 | 503 | 504 => {
655 Refusal::transient(EmbeddingProviderError::Unavailable {
656 reason: format!("HTTP {status}"),
657 })
658 }
659 501 | 505 => Refusal::permanent(EmbeddingProviderError::Unavailable {
662 reason: format!("the endpoint cannot serve this request at all (HTTP {status})"),
663 }),
664 _ => Refusal::permanent(EmbeddingProviderError::Internal(format!(
665 "the endpoint answered HTTP {status}"
666 ))),
667 }
668}
669
670#[expect(
673 clippy::cast_possible_truncation,
674 reason = "narrowing to f32 is the destination: pgvector stores single precision"
675)]
676fn normalize(vector: Vec<f32>) -> Vec<f32> {
677 let norm = vector
678 .iter()
679 .map(|lane| f64::from(*lane).powi(2))
680 .sum::<f64>()
681 .sqrt();
682 if norm == 0.0 {
683 return vector;
684 }
685 vector
686 .into_iter()
687 .map(|lane| (f64::from(lane) / norm) as f32)
688 .collect()
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn provider(base_url: &str, model: &str) -> RemoteEmbeddingProvider {
696 RemoteEmbeddingProvider::new(RemoteProviderConfig::new(base_url, model))
697 .unwrap_or_else(|error| panic!("a valid configuration must build: {error}"))
698 }
699
700 #[test]
701 fn the_endpoint_is_the_base_url_plus_embeddings() {
702 assert_eq!(
703 provider("https://api.openai.com/v1", "m")
704 .endpoint()
705 .as_str(),
706 "https://api.openai.com/v1/embeddings"
707 );
708 assert_eq!(
709 provider("https://api.openai.com/v1/", "m")
710 .endpoint()
711 .as_str(),
712 "https://api.openai.com/v1/embeddings"
713 );
714 assert_eq!(
715 provider("http://ollama:11434/v1?x=1", "m")
716 .endpoint()
717 .as_str(),
718 "http://ollama:11434/v1/embeddings"
719 );
720 }
721
722 #[test]
723 fn a_trailing_slash_or_query_does_not_change_the_identity() {
724 let one = provider("https://api.openai.com/v1", "text-embedding-3-small");
725 let other = provider(
726 "https://api.openai.com/v1/?trace=1",
727 "text-embedding-3-small",
728 );
729 assert_eq!(
730 one.embedding_space().identity_hash,
731 other.embedding_space().identity_hash
732 );
733 }
734
735 #[test]
736 fn model_endpoint_and_width_are_each_part_of_the_identity() {
737 let base = provider("https://api.openai.com/v1", "text-embedding-3-small");
738 let other_model = provider("https://api.openai.com/v1", "text-embedding-3-large");
739 let other_host = provider("https://eu.api.example.com/v1", "text-embedding-3-small");
740 let mut narrow =
741 RemoteProviderConfig::new("https://api.openai.com/v1", "text-embedding-3-small");
742 narrow.dimension = 256;
743 let narrow = RemoteEmbeddingProvider::new(narrow)
744 .unwrap_or_else(|error| panic!("a valid configuration must build: {error}"));
745
746 let hash = |p: &RemoteEmbeddingProvider| p.embedding_space().identity_hash.clone();
747 assert_ne!(hash(&base), hash(&other_model));
748 assert_ne!(hash(&base), hash(&other_host));
749 assert_ne!(hash(&base), hash(&narrow));
750 }
751
752 #[test]
759 fn a_base_url_carrying_credentials_is_refused() {
760 for url in [
761 "https://user:secret@embeddings.test/v1",
762 "https://tokenonly@embeddings.test/v1",
763 ] {
764 let error = RemoteEmbeddingProvider::new(RemoteProviderConfig::new(url, "m"))
765 .err()
766 .expect("a URL with userinfo is refused");
767 assert!(
768 matches!(error, RemoteConfigError::CredentialsInUrl),
769 "{url}: {error}"
770 );
771 }
772 let ok = provider("https://embeddings.test/v1", "m");
774 assert_eq!(ok.endpoint().username(), "");
775 }
776
777 #[test]
784 fn the_transport_is_part_of_the_identity() {
785 let secure = provider("https://embeddings.test/v1", "m");
786 let plain = provider("http://embeddings.test/v1", "m");
787 assert_ne!(
788 secure.embedding_space().identity_hash,
789 plain.embedding_space().identity_hash,
790 "http and https must not share an embedding space"
791 );
792 }
793
794 #[test]
795 fn a_relative_or_non_http_base_url_is_refused() {
796 for bad in ["api.openai.com/v1", "ftp://x/v1", "", "https://"] {
797 let error = RemoteEmbeddingProvider::new(RemoteProviderConfig::new(bad, "m"))
798 .err()
799 .unwrap_or_else(|| panic!("{bad:?} must be refused"));
800 assert!(
801 matches!(error, RemoteConfigError::BaseUrl(_)),
802 "{bad:?}: {error}"
803 );
804 }
805 }
806
807 #[test]
808 fn the_credential_does_not_render_in_debug() {
809 let config = RemoteProviderConfig::new("https://api.openai.com/v1", "m")
810 .with_api_key("sk-this-must-not-leak");
811 let rendered = format!("{config:?}");
812 assert!(!rendered.contains("sk-this-must-not-leak"), "{rendered}");
813 }
814
815 #[test]
816 fn normalization_yields_unit_length_and_leaves_zero_alone() {
817 let unit = normalize(vec![3.0, 4.0]);
818 let norm: f64 = unit
819 .iter()
820 .map(|x| f64::from(*x).powi(2))
821 .sum::<f64>()
822 .sqrt();
823 assert!((norm - 1.0).abs() < 1e-6, "{norm}");
824 assert_eq!(normalize(vec![0.0, 0.0]), vec![0.0, 0.0]);
825 }
826
827 fn classify(code: u16) -> Refusal {
828 classify_status(reqwest::StatusCode::from_u16(code).unwrap_or_default())
829 }
830
831 #[test]
838 fn a_credential_is_not_sent_in_clear_to_another_host() {
839 let build = |url: &str, key: Option<&str>| {
840 let mut config = RemoteProviderConfig::new(url, "text-embedding-3-small");
841 if let Some(key) = key {
842 config = config.with_api_key(key);
843 }
844 RemoteEmbeddingProvider::new(config)
845 };
846
847 for url in [
848 "http://embeddings.internal/v1",
849 "http://10.0.0.7:8080/v1",
850 "http://example.test/v1",
851 ] {
852 let refused = build(url, Some("sk-secret"))
853 .err()
854 .expect("a credential over plain http to another host must be refused");
855 assert!(
856 matches!(refused, RemoteConfigError::CredentialOverPlainHttp { .. }),
857 "{url} must be refused for the scheme, got {refused}"
858 );
859 assert!(
860 !refused.to_string().contains("sk-secret"),
861 "and the refusal must not quote the credential: {refused}"
862 );
863 }
864
865 for url in ["http://embeddings.internal/v1", "http://10.0.0.7:8080/v1"] {
868 assert!(
869 build(url, None).is_ok(),
870 "{url} carries no credential and stays allowed"
871 );
872 }
873
874 for url in [
877 "http://127.0.0.1:11434/v1",
878 "http://localhost:11434/v1",
879 "http://[::1]:11434/v1",
880 ] {
881 assert!(
882 build(url, Some("sk-secret")).is_ok(),
883 "{url} is the local machine and stays allowed"
884 );
885 }
886
887 assert!(
889 build("https://api.openai.com/v1", Some("sk-secret")).is_ok(),
890 "https with a credential is the point of the feature"
891 );
892 }
893
894 #[test]
899 fn a_model_that_is_not_a_model_name_is_refused() {
900 let refused = |model: &str| {
903 RemoteEmbeddingProvider::new(RemoteProviderConfig::new(
904 "https://example.test/v1",
905 model,
906 ))
907 .err()
908 .expect("this model must be refused")
909 };
910
911 assert!(
912 matches!(refused(" "), RemoteConfigError::Model),
913 "a blank model keeps its own error"
914 );
915 assert!(
916 matches!(
917 refused(&"m".repeat(MAX_MODEL_LEN + 1)),
918 RemoteConfigError::ModelTooLong { .. }
919 ),
920 "a model past the bound must be refused"
921 );
922 for model in ["text-embedding\n3-small", "text\u{0}embedding", "a\tb"] {
923 assert!(
924 matches!(refused(model), RemoteConfigError::ModelControlCharacters),
925 "{model:?} must be refused"
926 );
927 }
928 }
929
930 #[test]
933 fn a_real_model_name_is_accepted() {
934 for model in [
935 "text-embedding-3-small",
936 " text-embedding-3-small ",
937 "sentence-transformers/all-MiniLM-L6-v2",
938 &"m".repeat(MAX_MODEL_LEN),
939 ] {
940 assert!(
941 RemoteEmbeddingProvider::new(RemoteProviderConfig::new(
942 "https://example.test/v1",
943 model,
944 ))
945 .is_ok(),
946 "{model:?} must be accepted"
947 );
948 }
949 }
950
951 #[test]
952 fn statuses_split_into_unavailable_and_internal() {
953 let unavailable = |code: u16| {
954 matches!(
955 classify(code).error,
956 EmbeddingProviderError::Unavailable { .. }
957 )
958 };
959 assert!(unavailable(401));
960 assert!(unavailable(429));
961 assert!(unavailable(503));
962 assert!(!unavailable(400));
963 assert!(!unavailable(404));
964 }
965
966 #[test]
972 fn a_refused_credential_is_unavailable_but_not_retried() {
973 for code in [401, 403] {
974 let refusal = classify(code);
975 assert!(
976 matches!(refusal.error, EmbeddingProviderError::Unavailable { .. }),
977 "HTTP {code} is still an unavailable provider"
978 );
979 assert!(
980 !refusal.retryable,
981 "HTTP {code} must not be retried: the credential will be refused again, and \
982 retrying every chunk of every batch is how a rotated key becomes a rate limit"
983 );
984 }
985 }
986
987 #[test]
991 fn a_permanent_5xx_is_not_retried() {
992 for code in [501, 505] {
993 let refusal = classify(code);
994 assert!(
995 !refusal.retryable,
996 "HTTP {code} describes a fixed capability, so repeating the request repeats \
997 the answer"
998 );
999 assert!(
1000 matches!(refusal.error, EmbeddingProviderError::Unavailable { .. }),
1001 "HTTP {code} still leaves the vector arm down, so it is still unavailable"
1002 );
1003 }
1004 }
1005
1006 #[test]
1007 fn the_statuses_a_retry_can_fix_are_still_retried() {
1008 for code in [408, 429, 500, 502, 503, 504] {
1009 assert!(
1010 classify(code).retryable,
1011 "HTTP {code} is the endpoint saying `not now`, which is what retrying is for"
1012 );
1013 }
1014 for code in [400, 404, 422] {
1015 assert!(
1016 !classify(code).retryable,
1017 "HTTP {code} is about the request, and repeating it repeats the request"
1018 );
1019 }
1020 }
1021}