Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions crates/uv-client/src/cached_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,18 @@ impl<E: Into<Self> + std::error::Error + 'static> From<CachedClientError<E>> 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<Freshness> for CacheControl {
impl From<Freshness> for CacheControl<'_> {
fn from(value: Freshness) -> Self {
match value {
Freshness::Fresh => Self::None,
Expand Down Expand Up @@ -259,7 +261,7 @@ impl CachedClient {
&self,
req: Request,
cache_entry: &CacheEntry,
cache_control: CacheControl,
cache_control: CacheControl<'_>,
response_callback: Callback,
) -> Result<Payload, CachedClientError<CallBackError>> {
let payload = self
Expand Down Expand Up @@ -292,7 +294,7 @@ impl CachedClient {
&self,
req: Request,
cache_entry: &CacheEntry,
cache_control: CacheControl,
cache_control: CacheControl<'_>,
response_callback: Callback,
) -> Result<Payload::Target, CachedClientError<CallBackError>> {
let fresh_req = req.try_clone().expect("HTTP request must be cloneable");
Expand Down Expand Up @@ -469,7 +471,7 @@ impl CachedClient {
async fn send_cached(
&self,
mut req: Request,
cache_control: CacheControl,
cache_control: CacheControl<'_>,
cached: DataWithCachePolicy,
) -> Result<CachedResponse, Error> {
// Apply the cache control header, if necessary.
Expand All @@ -481,14 +483,21 @@ 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 => {
debug!("Found fresh response for: {}", req.url());
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?
Expand Down Expand Up @@ -599,7 +608,7 @@ impl CachedClient {
&self,
req: Request,
cache_entry: &CacheEntry,
cache_control: CacheControl,
cache_control: CacheControl<'_>,
response_callback: Callback,
) -> Result<Payload, CachedClientError<CallBackError>> {
let payload = self
Expand All @@ -623,7 +632,7 @@ impl CachedClient {
&self,
req: Request,
cache_entry: &CacheEntry,
cache_control: CacheControl,
cache_control: CacheControl<'_>,
response_callback: Callback,
) -> Result<Payload::Target, CachedClientError<CallBackError>> {
let mut past_retries = 0;
Expand Down
3 changes: 3 additions & 0 deletions crates/uv-client/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 42 additions & 16 deletions crates/uv-client/src/registry_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -571,7 +577,7 @@ impl RegistryClient {
package_name: &PackageName,
url: &DisplaySafeUrl,
cache_entry: &CacheEntry,
cache_control: CacheControl,
cache_control: CacheControl<'_>,
) -> Result<OwnedArchive<SimpleMetadata>, Error> {
let simple_request = self
.uncached_client(url)
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down
83 changes: 83 additions & 0 deletions crates/uv-distribution-types/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SmallString>,
/// Cache control header for file downloads.
pub files: Option<SmallString>,
}

#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
Expand Down Expand Up @@ -104,6 +116,19 @@ pub struct Index {
/// ```
#[serde(default)]
pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
/// 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://<omitted>/simple"
/// cache-control = { api = "max-age=600", files = "max-age=3600" }
/// ```
#[serde(default)]
pub cache_control: Option<IndexCacheControl>,
}

#[derive(
Expand Down Expand Up @@ -142,6 +167,7 @@ impl Index {
publish_url: None,
authenticate: AuthPolicy::default(),
ignore_error_codes: None,
cache_control: None,
}
}

Expand All @@ -157,6 +183,7 @@ impl Index {
publish_url: None,
authenticate: AuthPolicy::default(),
ignore_error_codes: None,
cache_control: None,
}
}

Expand All @@ -172,6 +199,7 @@ impl Index {
publish_url: None,
authenticate: AuthPolicy::default(),
ignore_error_codes: None,
cache_control: None,
}
}

Expand Down Expand Up @@ -250,6 +278,7 @@ impl From<IndexUrl> for Index {
publish_url: None,
authenticate: AuthPolicy::default(),
ignore_error_codes: None,
cache_control: None,
}
}
}
Expand All @@ -273,6 +302,7 @@ impl FromStr for Index {
publish_url: None,
authenticate: AuthPolicy::default(),
ignore_error_codes: None,
cache_control: None,
});
}
}
Expand All @@ -289,6 +319,7 @@ impl FromStr for Index {
publish_url: None,
authenticate: AuthPolicy::default(),
ignore_error_codes: None,
cache_control: None,
})
}
}
Expand Down Expand Up @@ -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);
}
}
Loading