Allow users to override index cache-control headers (#14620)

## Summary

You can now override the cache control headers for the Simple API, file
downloads, or both:

```toml
[[tool.uv.index]]
name = "example"
url = "https://example.com/simple"
cache-control = { api = "max-age=600", files = "max-age=365000000, immutable" }
```

Closes https://github.com/astral-sh/uv/issues/10444.
This commit is contained in:
Charlie Marsh 2025-07-15 10:00:04 -04:00 committed by GitHub
parent 9871bbdc79
commit 405ef66cef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 323 additions and 24 deletions

View file

@ -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,
@ -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
@ -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");
@ -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.
@ -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<Payload, CachedClientError<CallBackError>> {
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<Payload::Target, CachedClientError<CallBackError>> {
let mut past_retries = 0;

View file

@ -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 {

View file

@ -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<OwnedArchive<SimpleMetadata>, 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,
};

View file

@ -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")]
@ -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(
@ -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<IndexUrl> 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);
}
}

View file

@ -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);
}
}

View file

@ -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: [],

View file

@ -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

32
uv.schema.json generated
View file

@ -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://<omitted>/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": [
{