diff --git a/crates/uv-client/src/cached_client.rs b/crates/uv-client/src/cached_client.rs index ee3314d1c..f888ea5f1 100644 --- a/crates/uv-client/src/cached_client.rs +++ b/crates/uv-client/src/cached_client.rs @@ -196,16 +196,18 @@ impl + std::error::Error + 'static> From> for } #[derive(Debug, Clone, Copy)] -pub enum CacheControl { +pub enum CacheControl<'a> { /// Respect the `cache-control` header from the response. None, /// Apply `max-age=0, must-revalidate` to the request. MustRevalidate, /// Allow the client to return stale responses. AllowStale, + /// Override the cache control header with a custom value. + Override(&'a str), } -impl From for CacheControl { +impl From for CacheControl<'_> { fn from(value: Freshness) -> Self { match value { Freshness::Fresh => Self::None, @@ -259,7 +261,7 @@ impl CachedClient { &self, req: Request, cache_entry: &CacheEntry, - cache_control: CacheControl, + cache_control: CacheControl<'_>, response_callback: Callback, ) -> Result> { let payload = self @@ -292,7 +294,7 @@ impl CachedClient { &self, req: Request, cache_entry: &CacheEntry, - cache_control: CacheControl, + cache_control: CacheControl<'_>, response_callback: Callback, ) -> Result> { let fresh_req = req.try_clone().expect("HTTP request must be cloneable"); @@ -469,7 +471,7 @@ impl CachedClient { async fn send_cached( &self, mut req: Request, - cache_control: CacheControl, + cache_control: CacheControl<'_>, cached: DataWithCachePolicy, ) -> Result { // Apply the cache control header, if necessary. @@ -481,6 +483,13 @@ impl CachedClient { http::HeaderValue::from_static("no-cache"), ); } + CacheControl::Override(value) => { + req.headers_mut().insert( + http::header::CACHE_CONTROL, + http::HeaderValue::from_str(value) + .map_err(|_| ErrorKind::InvalidCacheControl(value.to_string()))?, + ); + } } Ok(match cached.cache_policy.before_request(&mut req) { BeforeRequest::Fresh => { @@ -488,7 +497,7 @@ impl CachedClient { CachedResponse::FreshCache(cached) } BeforeRequest::Stale(new_cache_policy_builder) => match cache_control { - CacheControl::None | CacheControl::MustRevalidate => { + CacheControl::None | CacheControl::MustRevalidate | CacheControl::Override(_) => { debug!("Found stale response for: {}", req.url()); self.send_cached_handle_stale(req, cached, new_cache_policy_builder) .await? @@ -599,7 +608,7 @@ impl CachedClient { &self, req: Request, cache_entry: &CacheEntry, - cache_control: CacheControl, + cache_control: CacheControl<'_>, response_callback: Callback, ) -> Result> { let payload = self @@ -623,7 +632,7 @@ impl CachedClient { &self, req: Request, cache_entry: &CacheEntry, - cache_control: CacheControl, + cache_control: CacheControl<'_>, response_callback: Callback, ) -> Result> { let mut past_retries = 0; diff --git a/crates/uv-client/src/error.rs b/crates/uv-client/src/error.rs index 754237fe2..035cdea71 100644 --- a/crates/uv-client/src/error.rs +++ b/crates/uv-client/src/error.rs @@ -259,6 +259,9 @@ pub enum ErrorKind { "Network connectivity is disabled, but the requested data wasn't found in the cache for: `{0}`" )] Offline(String), + + #[error("Invalid cache control header: `{0}`")] + InvalidCacheControl(String), } impl ErrorKind { diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index afa1b03ae..1d12c5adf 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -511,11 +511,17 @@ impl RegistryClient { format!("{package_name}.rkyv"), ); let cache_control = match self.connectivity { - Connectivity::Online => CacheControl::from( - self.cache - .freshness(&cache_entry, Some(package_name), None) - .map_err(ErrorKind::Io)?, - ), + Connectivity::Online => { + if let Some(header) = self.index_urls.simple_api_cache_control_for(index) { + CacheControl::Override(header) + } else { + CacheControl::from( + self.cache + .freshness(&cache_entry, Some(package_name), None) + .map_err(ErrorKind::Io)?, + ) + } + } Connectivity::Offline => CacheControl::AllowStale, }; @@ -571,7 +577,7 @@ impl RegistryClient { package_name: &PackageName, url: &DisplaySafeUrl, cache_entry: &CacheEntry, - cache_control: CacheControl, + cache_control: CacheControl<'_>, ) -> Result, Error> { let simple_request = self .uncached_client(url) @@ -783,11 +789,17 @@ impl RegistryClient { format!("{}.msgpack", filename.cache_key()), ); let cache_control = match self.connectivity { - Connectivity::Online => CacheControl::from( - self.cache - .freshness(&cache_entry, Some(&filename.name), None) - .map_err(ErrorKind::Io)?, - ), + Connectivity::Online => { + if let Some(header) = self.index_urls.artifact_cache_control_for(index) { + CacheControl::Override(header) + } else { + CacheControl::from( + self.cache + .freshness(&cache_entry, Some(&filename.name), None) + .map_err(ErrorKind::Io)?, + ) + } + } Connectivity::Offline => CacheControl::AllowStale, }; @@ -853,11 +865,25 @@ impl RegistryClient { format!("{}.msgpack", filename.cache_key()), ); let cache_control = match self.connectivity { - Connectivity::Online => CacheControl::from( - self.cache - .freshness(&cache_entry, Some(&filename.name), None) - .map_err(ErrorKind::Io)?, - ), + Connectivity::Online => { + if let Some(index) = index { + if let Some(header) = self.index_urls.artifact_cache_control_for(index) { + CacheControl::Override(header) + } else { + CacheControl::from( + self.cache + .freshness(&cache_entry, Some(&filename.name), None) + .map_err(ErrorKind::Io)?, + ) + } + } else { + CacheControl::from( + self.cache + .freshness(&cache_entry, Some(&filename.name), None) + .map_err(ErrorKind::Io)?, + ) + } + } Connectivity::Offline => CacheControl::AllowStale, }; diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs index 8ac7c3cd4..04614a18e 100644 --- a/crates/uv-distribution-types/src/index.rs +++ b/crates/uv-distribution-types/src/index.rs @@ -6,11 +6,23 @@ use thiserror::Error; use uv_auth::{AuthPolicy, Credentials}; use uv_redacted::DisplaySafeUrl; +use uv_small_str::SmallString; use crate::index_name::{IndexName, IndexNameError}; use crate::origin::Origin; use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode}; +/// Cache control configuration for an index. +#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub struct IndexCacheControl { + /// Cache control header for Simple API requests. + pub api: Option, + /// Cache control header for file downloads. + pub files: Option, +} + #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] @@ -104,6 +116,19 @@ pub struct Index { /// ``` #[serde(default)] pub ignore_error_codes: Option>, + /// Cache control configuration for this index. + /// + /// When set, these headers will override the server's cache control headers + /// for both package metadata requests and artifact downloads. + /// + /// ```toml + /// [[tool.uv.index]] + /// name = "my-index" + /// url = "https:///simple" + /// cache-control = { api = "max-age=600", files = "max-age=3600" } + /// ``` + #[serde(default)] + pub cache_control: Option, } #[derive( @@ -142,6 +167,7 @@ impl Index { publish_url: None, authenticate: AuthPolicy::default(), ignore_error_codes: None, + cache_control: None, } } @@ -157,6 +183,7 @@ impl Index { publish_url: None, authenticate: AuthPolicy::default(), ignore_error_codes: None, + cache_control: None, } } @@ -172,6 +199,7 @@ impl Index { publish_url: None, authenticate: AuthPolicy::default(), ignore_error_codes: None, + cache_control: None, } } @@ -250,6 +278,7 @@ impl From for Index { publish_url: None, authenticate: AuthPolicy::default(), ignore_error_codes: None, + cache_control: None, } } } @@ -273,6 +302,7 @@ impl FromStr for Index { publish_url: None, authenticate: AuthPolicy::default(), ignore_error_codes: None, + cache_control: None, }); } } @@ -289,6 +319,7 @@ impl FromStr for Index { publish_url: None, authenticate: AuthPolicy::default(), ignore_error_codes: None, + cache_control: None, }) } } @@ -384,3 +415,55 @@ pub enum IndexSourceError { #[error("Index included a name, but the name was empty")] EmptyName, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_index_cache_control_headers() { + // Test that cache control headers are properly parsed from TOML + let toml_str = r#" + name = "test-index" + url = "https://test.example.com/simple" + cache-control = { api = "max-age=600", files = "max-age=3600" } + "#; + + let index: Index = toml::from_str(toml_str).unwrap(); + assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index"); + assert!(index.cache_control.is_some()); + let cache_control = index.cache_control.as_ref().unwrap(); + assert_eq!(cache_control.api.as_deref(), Some("max-age=600")); + assert_eq!(cache_control.files.as_deref(), Some("max-age=3600")); + } + + #[test] + fn test_index_without_cache_control() { + // Test that indexes work without cache control headers + let toml_str = r#" + name = "test-index" + url = "https://test.example.com/simple" + "#; + + let index: Index = toml::from_str(toml_str).unwrap(); + assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index"); + assert_eq!(index.cache_control, None); + } + + #[test] + fn test_index_partial_cache_control() { + // Test that cache control can have just one field + let toml_str = r#" + name = "test-index" + url = "https://test.example.com/simple" + cache-control = { api = "max-age=300" } + "#; + + let index: Index = toml::from_str(toml_str).unwrap(); + assert_eq!(index.name.as_ref().unwrap().as_ref(), "test-index"); + assert!(index.cache_control.is_some()); + let cache_control = index.cache_control.as_ref().unwrap(); + assert_eq!(cache_control.api.as_deref(), Some("max-age=300")); + assert_eq!(cache_control.files, None); + } +} diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 1c8cd0a76..bd3e9abc2 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -599,6 +599,26 @@ impl<'a> IndexUrls { } IndexStatusCodeStrategy::Default } + + /// Return the Simple API cache control header for an [`IndexUrl`], if configured. + pub fn simple_api_cache_control_for(&self, url: &IndexUrl) -> Option<&str> { + for index in &self.indexes { + if index.url() == url { + return index.cache_control.as_ref()?.api.as_deref(); + } + } + None + } + + /// Return the artifact cache control header for an [`IndexUrl`], if configured. + pub fn artifact_cache_control_for(&self, url: &IndexUrl) -> Option<&str> { + for index in &self.indexes { + if index.url() == url { + return index.cache_control.as_ref()?.files.as_deref(); + } + } + None + } } bitflags::bitflags! { @@ -717,4 +737,64 @@ mod tests { "git+https://github.com/example/repo.git" )); } + + #[test] + fn test_cache_control_lookup() { + use std::str::FromStr; + + use uv_small_str::SmallString; + + use crate::IndexFormat; + use crate::index_name::IndexName; + + let indexes = vec![ + Index { + name: Some(IndexName::from_str("index1").unwrap()), + url: IndexUrl::from_str("https://index1.example.com/simple").unwrap(), + cache_control: Some(crate::IndexCacheControl { + api: Some(SmallString::from("max-age=300")), + files: Some(SmallString::from("max-age=1800")), + }), + explicit: false, + default: false, + origin: None, + format: IndexFormat::Simple, + publish_url: None, + authenticate: uv_auth::AuthPolicy::default(), + ignore_error_codes: None, + }, + Index { + name: Some(IndexName::from_str("index2").unwrap()), + url: IndexUrl::from_str("https://index2.example.com/simple").unwrap(), + cache_control: None, + explicit: false, + default: false, + origin: None, + format: IndexFormat::Simple, + publish_url: None, + authenticate: uv_auth::AuthPolicy::default(), + ignore_error_codes: None, + }, + ]; + + let index_urls = IndexUrls::from_indexes(indexes); + + let url1 = IndexUrl::from_str("https://index1.example.com/simple").unwrap(); + assert_eq!( + index_urls.simple_api_cache_control_for(&url1), + Some("max-age=300") + ); + assert_eq!( + index_urls.artifact_cache_control_for(&url1), + Some("max-age=1800") + ); + + let url2 = IndexUrl::from_str("https://index2.example.com/simple").unwrap(); + assert_eq!(index_urls.simple_api_cache_control_for(&url2), None); + assert_eq!(index_urls.artifact_cache_control_for(&url2), None); + + let url3 = IndexUrl::from_str("https://index3.example.com/simple").unwrap(); + assert_eq!(index_urls.simple_api_cache_control_for(&url3), None); + assert_eq!(index_urls.artifact_cache_control_for(&url3), None); + } } diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 7635bd523..2637af8ac 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -139,6 +139,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -320,6 +321,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -502,6 +504,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -716,6 +719,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -1059,6 +1063,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -1267,6 +1272,7 @@ fn resolve_index_url() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -1299,6 +1305,7 @@ fn resolve_index_url() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -1484,6 +1491,7 @@ fn resolve_index_url() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -1516,6 +1524,7 @@ fn resolve_index_url() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -1548,6 +1557,7 @@ fn resolve_index_url() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -1755,6 +1765,7 @@ fn resolve_find_links() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], no_index: true, @@ -2124,6 +2135,7 @@ fn resolve_top_level() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -2156,6 +2168,7 @@ fn resolve_top_level() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -2337,6 +2350,7 @@ fn resolve_top_level() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -2369,6 +2383,7 @@ fn resolve_top_level() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -3564,6 +3579,7 @@ fn resolve_both() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -3870,6 +3886,7 @@ fn resolve_config_file() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -4658,6 +4675,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -4690,6 +4708,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -4873,6 +4892,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -4905,6 +4925,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -5094,6 +5115,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -5126,6 +5148,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -5310,6 +5333,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -5342,6 +5366,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -5533,6 +5558,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -5565,6 +5591,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], @@ -5749,6 +5776,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, Index { name: None, @@ -5781,6 +5809,7 @@ fn index_priority() -> anyhow::Result<()> { publish_url: None, authenticate: Auto, ignore_error_codes: None, + cache_control: None, }, ], flat_index: [], diff --git a/docs/concepts/indexes.md b/docs/concepts/indexes.md index 6c03bae66..5e6c3866c 100644 --- a/docs/concepts/indexes.md +++ b/docs/concepts/indexes.md @@ -244,6 +244,43 @@ authenticate = "never" When `authenticate` is set to `never`, uv will never search for credentials for the given index and will error if credentials are provided directly. +### Customizing cache control headers + +By default, uv will respect the cache control headers provided by the index. For example, PyPI +serves package metadata with a `max-age=600` header, thereby allowing uv to cache package metadata +for 10 minutes; and wheels and source distributions with a `max-age=365000000, immutable` header, +thereby allowing uv to cache artifacts indefinitely. + +To override the cache control headers for an index, use the `cache-control` setting: + +```toml +[[tool.uv.index]] +name = "example" +url = "https://example.com/simple" +cache-control = { api = "max-age=600", files = "max-age=365000000, immutable" } +``` + +The `cache-control` setting accepts an object with two optional keys: + +- `api`: Controls caching for Simple API requests (package metadata). +- `files`: Controls caching for artifact downloads (wheels and source distributions). + +The values for these keys are strings that follow the +[HTTP Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) +syntax. For example, to force uv to always revalidate package metadata, set `api = "no-cache"`: + +```toml +[[tool.uv.index]] +name = "example" +url = "https://example.com/simple" +cache-control = { api = "no-cache" } +``` + +This setting is most commonly used to override the default cache control headers for private indexes +that otherwise disable caching, often unintentionally. We typically recommend following PyPI's +approach to caching headers, i.e., setting `api = "max-age=600"` and +`files = "max-age=365000000, immutable"`. + ## "Flat" indexes By default, `[[tool.uv.index]]` entries are assumed to be PyPI-style registries that implement the diff --git a/uv.schema.json b/uv.schema.json index 4190672e9..e418f37f0 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -907,6 +907,18 @@ ], "default": "auto" }, + "cache-control": { + "description": "Cache control configuration for this index.\n\nWhen set, these headers will override the server's cache control headers\nfor both package metadata requests and artifact downloads.\n\n```toml\n[[tool.uv.index]]\nname = \"my-index\"\nurl = \"https:///simple\"\ncache-control = { api = \"max-age=600\", files = \"max-age=3600\" }\n```", + "anyOf": [ + { + "$ref": "#/definitions/IndexCacheControl" + }, + { + "type": "null" + } + ], + "default": null + }, "default": { "description": "Mark the index as the default index.\n\nBy default, uv uses PyPI as the default index, such that even if additional indexes are\ndefined via `[[tool.uv.index]]`, PyPI will still be used as a fallback for packages that\naren't found elsewhere. To disable the PyPI default, set `default = true` on at least one\nother index.\n\nMarking an index as default will move it to the front of the list of indexes, such that it\nis given the highest priority when resolving packages.", "type": "boolean", @@ -972,6 +984,26 @@ "url" ] }, + "IndexCacheControl": { + "description": "Cache control configuration for an index.", + "type": "object", + "properties": { + "api": { + "description": "Cache control header for Simple API requests.", + "type": [ + "string", + "null" + ] + }, + "files": { + "description": "Cache control header for file downloads.", + "type": [ + "string", + "null" + ] + } + } + }, "IndexFormat": { "oneOf": [ {