Repository: astral-sh/uv
Pull request #12805: Make uv’s first-index strategy more secure by default by failing early on authentication failure
Author: login: jtfmumm
Labels: name: enhancement
description: New feature or improvement to existing functionality
name: network
description: Network connectivity e.g. proxies, DNS, and SSL
name: breaking
description: A breaking change
Created: 2025-04-10T12:38:11Z | Merged: 2025-04-29T17:48:56Z

uv’s default index strategy was designed with dependency confusion attacks in mind. [According to the docs](https://docs.astral.sh/uv/configuration/indexes/#searching-across-multiple-indexes), “if a package exists on an internal index, it should always be installed from the internal index, and never from PyPI”. Unfortunately, this is not true in the case where authentication fails on that internal index. In that case, uv will simply try the next index (even on the `first-index` strategy). This means that uv is not secure by default in this common scenario.

This PR causes uv to stop searching for a package if it encounters an authentication failure at an index. It is possible to opt out of this behavior for an index with a new `pyproject.toml` option `ignore-error-codes`. For example:

```
[[tool.uv.index]]
name = "my-index"
url = "<index-url>"
ignore-error-codes = [401, 403]
```

This will also enable users to handle idiosyncratic registries in a more fine-grained way. For example, PyTorch registries return a 403 when a package is not found. In this PR, we special-case PyTorch registries to ignore 403s, but users can use `ignore-error-codes` to handle similar behaviors if they encounter them on internal registries. 

Depends on #12651

Closes #9429
Closes #12362

Linked issue #9429: uv suffers dependency confusion if user forgets to put username:password on internal index url
I accidentally triggered a dependency confusion vulnerability in uv just by forgetting to add credentials to our company's internal package index URL.

Steps to reproduce:
- Run an internal Python package index that requires basic HTTP auth to connect
- Put a package on this internal package index named `example-component` (seriously, this is what I named it, I was testing!)
- Run these commands:

```bash
$ uv venv scratchenv
Using CPython 3.11.10
Creating virtual environment at: scratchenv
Activate with: source scratchenv/bin/activate
$ uv pip install --python=scratchenv --index=https://internal.example.com/repository/production/simple example-component
Using Python 3.11.10 environment at scratchenv
Resolved 1 package in 870ms
Installed 1 package in 1ms
 + example-component==0.0.1
$ uv pip list --python=scratchenv
Using Python 3.11.10 environment at scratchenv
Package           Version
----------------- -------
example-component 0.0.1
```

Expected result:

Either `example-component` is installed from my internal Python package index, or else I get some kind of error message if something was wrong with the package index URL.

Actual result:

As you can see from the command output above, `uv` silently and happily ignored my internal package index URL, gave no warning, and installs [example-component from pypi](https://pypi.org/project/example-component/)! The only reason I knew something was wrong is that `uv pip list` reported an unexpected version number.

Something was very wrong with my internal package index URL: It was missing the required credentials. So `uv` just silently ignored the error and went on to the default index URL, causing dependency confusion.

(Fortunately the example-component from pypi.org does not seem to be any kind of malware.)

It seems from the uv docs about [Packages that exist on multiple indexes](https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes) that uv prides itself on being better than pip at avoiding dependency confusion. So I thought you would want to hear about this case where uv is easily vulnerable to it.

Operating system: Ubuntu 24.04
```bash
$ uv version
uv 0.5.4
```
Linked issue comments: user: login: charliermarsh
created_at: 2024-11-25T21:46:58Z
body: Thanks. I'm not certain what we should change here if anything. Are you for `--default-index=` rather than `--index=`? `--index=` allows fallback to PyPI, _if_ a package isn't found on a preceding index (in other words, it prevents dependency confusion attacks by disallowing packages to shadow packages on other indexes).

I don't know if there's actually anything we can do about erroring if we fail to connect to your index, because indexes in general are so bad and so inconsistent in how they handle these cases. Some indexes return 403 when you provide valid credentials but request a non-existent package; some indexes return 404 when you provide invalid credentials. No matter what we do, we'll break something. We do show a dedicated hint if we fail to resolve and see (e.g.) a 403. I guess we could consider showing that warning even if the installation completes.

author_association: MEMBER
user: login: charliermarsh
created_at: 2024-11-25T21:51:44Z
body: We should probably _at least_ warn when we fail to connect to an index. We may want to special-case the indexes that we _know_ return 403 consistently.
author_association: MEMBER
user: login: dhandy2013
created_at: 2024-11-25T21:56:29Z
body: A warning would be helpful. Something in the docs to warn users would also be helpful.

Speaking of the docs, I really hate putting credentials in the package index URL. I would much rather put them in environment variables. I saw the docs for [UV_INDEX_{name}_PASSWORD](https://docs.astral.sh/uv/configuration/environment/#uv_index_name_password) and was wondering how on earth to set the name of an index given on the command line, given that configuring the index in a pyproject.toml file wasn't going to work for me.

So I searched the source code (Thanks for writing `uv` in Rust!) and found in crates/uv-distribution-types/src/index.rs the implementation for `Index::from_str` that apparently lets you put `name=` in front of an index URL on the command line! I'm going to try this out right now. If this works, then I humbly request that you please document this feature!
author_association: NONE
user: login: charliermarsh
created_at: 2024-11-25T21:57:35Z
body: I documented it here recently: https://docs.astral.sh/uv/configuration/indexes/#defining-an-index. Take a look at "When providing an index on the command line..."
author_association: MEMBER
user: login: dhandy2013
created_at: 2024-11-25T22:00:36Z
body: Thank you. The docs are good, I don't know why I missed that. :)
author_association: NONE
user: login: chrisrodrigue
created_at: 2024-11-26T19:58:08Z
body: What happens when a user configures 
`UV_DEFAULT_INDEX` to a PyPI mirror URL that does not contain credentials? 

`UV_INDEX_INTERNAL_PROXY_USERNAME` and `UV_INDEX_INTERNAL_PROXY_PASSWORD` do not appear to work with the default index.

Should the variables `UV_INDEX_DEFAULT_USERNAME` and `UV_INDEX_DEFAULT_PASSWORD` apply to the default index? Or should they be `UV_DEFAULT_INDEX_USERNAME` and `UV_DEFAULT_INDEX_PASSWORD`?
author_association: NONE
user: login: dhandy2013
created_at: 2024-11-26T23:00:26Z
body: Answering this previous question from @charliermarsh :
> Are you for --default-index= rather than --index=? --index= allows fallback to PyPI, if a package isn't found on a preceding index

In my case I wanted `example-component` to come from my internal package index, but its dependencies to come from PyPI. That's why I used `--index` rather than `--default-index` to point to the internal package index.
author_association: NONE

Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 9f37717f49660..860611f515536 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5133,6 +5133,7 @@ dependencies = [
  "arcstr",
  "bitflags 2.9.0",
  "fs-err 3.1.0",
+ "http",
  "itertools 0.14.0",
  "jiff",
  "owo-colors",
diff --git a/crates/uv-auth/src/cache.rs b/crates/uv-auth/src/cache.rs
index e8894727b7b6e..636ec4ec4c80d 100644
--- a/crates/uv-auth/src/cache.rs
+++ b/crates/uv-auth/src/cache.rs
@@ -1,3 +1,4 @@
+use std::fmt::Display;
 use std::fmt::Formatter;
 use std::hash::BuildHasherDefault;
 use std::sync::Arc;
@@ -14,11 +15,28 @@ use crate::Realm;
 
 type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum FetchUrl {
+    /// A full index URL
+    Index(Url),
+    /// A realm URL
+    Realm(Realm),
+}
+
+impl Display for FetchUrl {
+    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
+        match self {
+            Self::Index(index) => Display::fmt(index, f),
+            Self::Realm(realm) => Display::fmt(realm, f),
+        }
+    }
+}
+
 pub struct CredentialsCache {
     /// A cache per realm and username
     realms: RwLock<FxHashMap<(Realm, Username), Arc<Credentials>>>,
     /// A cache tracking the result of realm or index URL fetches from external services
-    pub(crate) fetches: FxOnceMap<(String, Username), Option<Arc<Credentials>>>,
+    pub(crate) fetches: FxOnceMap<(FetchUrl, Username), Option<Arc<Credentials>>>,
     /// A cache per URL, uses a trie for efficient prefix queries.
     urls: RwLock<UrlTrie>,
 }
diff --git a/crates/uv-auth/src/index.rs b/crates/uv-auth/src/index.rs
index b420c5ec33762..9419a9a227e42 100644
--- a/crates/uv-auth/src/index.rs
+++ b/crates/uv-auth/src/index.rs
@@ -60,6 +60,23 @@ pub struct Index {
     pub auth_policy: AuthPolicy,
 }
 
+impl Index {
+    pub fn is_prefix_for(&self, url: &Url) -> bool {
+        if self.root_url.scheme() != url.scheme()
+            || self.root_url.host_str() != url.host_str()
+            || self.root_url.port_or_known_default() != url.port_or_known_default()
+        {
+            return false;
+        }
+
+        url.path().starts_with(self.root_url.path())
+    }
+}
+
+// TODO(john): Multiple methods in this struct need to iterate over
+// all the indexes in the set. There are probably not many URLs to
+// iterate through, but we could use a trie instead of a HashSet here
+// for more efficient search.
 #[derive(Debug, Default, Clone, Eq, PartialEq)]
 pub struct Indexes(FxHashSet<Index>);
 
@@ -79,36 +96,17 @@ impl Indexes {
 
     /// Get the index URL prefix for a URL if one exists.
     pub fn index_url_for(&self, url: &Url) -> Option<&Url> {
-        // TODO(john): There are probably not many URLs to iterate through,
-        // but we could use a trie instead of a HashSet here for more
-        // efficient search.
-        self.0
-            .iter()
-            .find(|index| is_url_prefix(&index.root_url, url))
-            .map(|index| &index.url)
+        self.find_prefix_index(url).map(|index| &index.url)
     }
 
     /// Get the [`AuthPolicy`] for a URL.
-    pub fn policy_for(&self, url: &Url) -> AuthPolicy {
-        // TODO(john): There are probably not many URLs to iterate through,
-        // but we could use a trie instead of a HashMap here for more
-        // efficient search.
-        for index in &self.0 {
-            if is_url_prefix(&index.root_url, url) {
-                return index.auth_policy;
-            }
-        }
-        AuthPolicy::Auto
+    pub fn auth_policy_for(&self, url: &Url) -> AuthPolicy {
+        self.find_prefix_index(url)
+            .map(|index| index.auth_policy)
+            .unwrap_or(AuthPolicy::Auto)
     }
-}
 
-fn is_url_prefix(base: &Url, url: &Url) -> bool {
-    if base.scheme() != url.scheme()
-        || base.host_str() != url.host_str()
-        || base.port_or_known_default() != url.port_or_known_default()
-    {
-        return false;
+    fn find_prefix_index(&self, url: &Url) -> Option<&Index> {
+        self.0.iter().find(|&index| index.is_prefix_for(url))
     }
-
-    url.path().starts_with(base.path())
 }
diff --git a/crates/uv-auth/src/middleware.rs b/crates/uv-auth/src/middleware.rs
index 544351b58f98a..cb9b2be2004ea 100644
--- a/crates/uv-auth/src/middleware.rs
+++ b/crates/uv-auth/src/middleware.rs
@@ -4,6 +4,7 @@ use http::{Extensions, StatusCode};
 use url::Url;
 
 use crate::{
+    cache::FetchUrl,
     credentials::{Credentials, Username},
     index::{AuthPolicy, Indexes},
     realm::Realm,
@@ -182,7 +183,7 @@ impl Middleware for AuthMiddleware {
         // to the headers so for display purposes we restore some information
         let url = tracing_url(&request, request_credentials.as_ref());
         let maybe_index_url = self.indexes.index_url_for(request.url());
-        let auth_policy = self.indexes.policy_for(request.url());
+        let auth_policy = self.indexes.auth_policy_for(request.url());
         trace!("Handling request for {url} with authentication policy {auth_policy}");
 
         let credentials: Option<Arc<Credentials>> = if matches!(auth_policy, AuthPolicy::Never) {
@@ -384,7 +385,7 @@ impl AuthMiddleware {
         extensions: &mut Extensions,
         next: Next<'_>,
         url: &str,
-        maybe_index_url: Option<&Url>,
+        index_url: Option<&Url>,
         auth_policy: AuthPolicy,
     ) -> reqwest_middleware::Result<Response> {
         let credentials = Arc::new(credentials);
@@ -402,7 +403,7 @@ impl AuthMiddleware {
         // There's just a username, try to find a password.
         // If we have an index URL, check the cache for that URL. Otherwise,
         // check for the realm.
-        let maybe_cached_credentials = if let Some(index_url) = maybe_index_url {
+        let maybe_cached_credentials = if let Some(index_url) = index_url {
             self.cache()
                 .get_url(index_url, credentials.as_username().as_ref())
         } else {
@@ -426,17 +427,12 @@ impl AuthMiddleware {
             // Do not insert already-cached credentials
             None
         } else if let Some(credentials) = self
-            .fetch_credentials(
-                Some(&credentials),
-                request.url(),
-                maybe_index_url,
-                auth_policy,
-            )
+            .fetch_credentials(Some(&credentials), request.url(), index_url, auth_policy)
             .await
         {
             request = credentials.authenticate(request);
             Some(credentials)
-        } else if maybe_index_url.is_some() {
+        } else if index_url.is_some() {
             // If this is a known index, we fall back to checking for the realm.
             self.cache()
                 .get_realm(Realm::from(request.url()), credentials.to_username())
@@ -468,9 +464,9 @@ impl AuthMiddleware {
         // Fetches can be expensive, so we will only run them _once_ per realm or index URL and username combination
         // All other requests for the same realm or index URL will wait until the first one completes
         let key = if let Some(index_url) = maybe_index_url {
-            (index_url.to_string(), username)
+            (FetchUrl::Index(index_url.clone()), username)
         } else {
-            (Realm::from(url).to_string(), username)
+            (FetchUrl::Realm(Realm::from(url)), username)
         };
         if !self.cache().fetches.register(key.clone()) {
             let credentials = self
@@ -520,7 +516,7 @@ impl AuthMiddleware {
                         debug!("Checking keyring for credentials for index URL {}@{}", username, index_url);
                         keyring.fetch(index_url, Some(username)).await
                     } else {
-                        debug!("Checking keyring for credentials for full URL {}@{}", username, *url);
+                        debug!("Checking keyring for credentials for full URL {}@{}", username, url);
                         keyring.fetch(url, Some(username)).await
                     }
                 } else if matches!(auth_policy, AuthPolicy::Always) {
@@ -530,10 +526,7 @@ impl AuthMiddleware {
                         );
                         keyring.fetch(index_url, None).await
                     } else {
-                        debug!(
-                            "Checking keyring for credentials for full URL {url} without username due to `authenticate = always`"
-                        );
-                        keyring.fetch(url, None).await
+                        None
                     }
                 } else {
                     debug!("Skipping keyring fetch for {url} without username; use `authenticate = always` to force");
diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs
index d08bdb7e10e09..8bdf2c065261b 100644
--- a/crates/uv-client/src/registry_client.rs
+++ b/crates/uv-client/src/registry_client.rs
@@ -7,13 +7,13 @@ use std::time::Duration;
 
 use async_http_range_reader::AsyncHttpRangeReader;
 use futures::{FutureExt, StreamExt, TryStreamExt};
-use http::HeaderMap;
+use http::{HeaderMap, StatusCode};
 use itertools::Either;
-use reqwest::{Proxy, Response, StatusCode};
+use reqwest::{Proxy, Response};
 use reqwest_middleware::ClientWithMiddleware;
 use rustc_hash::FxHashMap;
 use tokio::sync::{Mutex, Semaphore};
-use tracing::{info_span, instrument, trace, warn, Instrument};
+use tracing::{debug, info_span, instrument, trace, warn, Instrument};
 use url::Url;
 
 use uv_auth::Indexes;
@@ -23,7 +23,7 @@ use uv_configuration::{IndexStrategy, TrustedHost};
 use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename};
 use uv_distribution_types::{
     BuiltDist, File, FileLocation, IndexCapabilities, IndexFormat, IndexLocations,
-    IndexMetadataRef, IndexUrl, IndexUrls, Name,
+    IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name,
 };
 use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream};
 use uv_normalize::PackageName;
@@ -331,12 +331,29 @@ impl RegistryClient {
                     let _permit = download_concurrency.acquire().await;
                     match index.format {
                         IndexFormat::Simple => {
-                            if let Some(metadata) = self
-                                .simple_single_index(package_name, index.url, capabilities)
+                            let status_code_strategy =
+                                self.index_urls.status_code_strategy_for(index.url);
+                            match self
+                                .simple_single_index(
+                                    package_name,
+                                    index.url,
+                                    capabilities,
+                                    &status_code_strategy,
+                                )
                                 .await?
                             {
-                                results.push((index.url, MetadataFormat::Simple(metadata)));
-                                break;
+                                SimpleMetadataSearchOutcome::Found(metadata) => {
+                                    results.push((index.url, MetadataFormat::Simple(metadata)));
+                                    break;
+                                }
+                                // Package not found, so we will continue on to the next index (if there is one)
+                                SimpleMetadataSearchOutcome::NotFound => {}
+                                // The search failed because of an HTTP status code that we don't ignore for
+                                // this index. We end our search here.
+                                SimpleMetadataSearchOutcome::StatusCodeFailure(status_code) => {
+                                    debug!("Indexes search failed because of status code failure: {status_code}");
+                                    break;
+                                }
                             }
                         }
                         IndexFormat::Flat => {
@@ -357,9 +374,21 @@ impl RegistryClient {
                         let _permit = download_concurrency.acquire().await;
                         match index.format {
                             IndexFormat::Simple => {
-                                let metadata = self
-                                    .simple_single_index(package_name, index.url, capabilities)
-                                    .await?;
+                                // For unsafe matches, ignore authentication failures.
+                                let status_code_strategy =
+                                    IndexStatusCodeStrategy::ignore_authentication_error_codes();
+                                let metadata = match self
+                                    .simple_single_index(
+                                        package_name,
+                                        index.url,
+                                        capabilities,
+                                        &status_code_strategy,
+                                    )
+                                    .await?
+                                {
+                                    SimpleMetadataSearchOutcome::Found(metadata) => Some(metadata),
+                                    _ => None,
+                                };
                                 Ok((index.url, metadata.map(MetadataFormat::Simple)))
                             }
                             IndexFormat::Flat => {
@@ -438,14 +467,13 @@ impl RegistryClient {
     ///
     /// The index can either be a PEP 503-compatible remote repository, or a local directory laid
     /// out in the same format.
-    ///
-    /// Returns `Ok(None)` if the package is not found in the index.
     async fn simple_single_index(
         &self,
         package_name: &PackageName,
         index: &IndexUrl,
         capabilities: &IndexCapabilities,
-    ) -> Result<Option<OwnedArchive<SimpleMetadata>>, Error> {
+        status_code_strategy: &IndexStatusCodeStrategy,
+    ) -> Result<SimpleMetadataSearchOutcome, Error> {
         // Format the URL for PyPI.
         let mut url = index.url().clone();
         url.path_segments_mut()
@@ -487,27 +515,31 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
+                ErrorKind::WrappedReqwestError(url, err) => {
+                    let Some(status_code) = err.status() else {
+                        return Err(ErrorKind::WrappedReqwestError(url, err).into());
+                    };
+                    let decision =
+                        status_code_strategy.handle_status_code(status_code, index, capabilities);
+                    if let IndexStatusCodeDecision::Fail(status_code) = decision {
+                        if !matches!(
+                            status_code,
+                            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
+                        ) {
+                            return Err(ErrorKind::WrappedReqwestError(url, err).into());
+                        }
                     }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                    Ok(SimpleMetadataSearchOutcome::from(decision))
+                }
 
                 // The package is unavailable due to a lack of connectivity.
-                ErrorKind::Offline(_) => Ok(None),
+                ErrorKind::Offline(_) => Ok(SimpleMetadataSearchOutcome::NotFound),
 
                 // The package could not be found in the local index.
-                ErrorKind::FileNotFound(_) => Ok(None),
+                ErrorKind::FileNotFound(_) => Ok(SimpleMetadataSearchOutcome::NotFound),
 
                 err => Err(err.into()),
             },
@@ -960,6 +992,26 @@ impl RegistryClient {
     }
 }
 
+#[derive(Debug)]
+pub(crate) enum SimpleMetadataSearchOutcome {
+    /// Simple metadata was found
+    Found(OwnedArchive<SimpleMetadata>),
+    /// Simple metadata was not found
+    NotFound,
+    /// A status code failure was encountered when searching for
+    /// simple metadata and our strategy did not ignore it
+    StatusCodeFailure(StatusCode),
+}
+
+impl From<IndexStatusCodeDecision> for SimpleMetadataSearchOutcome {
+    fn from(item: IndexStatusCodeDecision) -> Self {
+        match item {
+            IndexStatusCodeDecision::Ignore => Self::NotFound,
+            IndexStatusCodeDecision::Fail(status_code) => Self::StatusCodeFailure(status_code),
+        }
+    }
+}
+
 /// A map from [`IndexUrl`] to [`FlatIndexEntry`] entries found at the given URL, indexed by
 /// [`PackageName`].
 #[derive(Default, Debug, Clone)]
diff --git a/crates/uv-distribution-types/Cargo.toml b/crates/uv-distribution-types/Cargo.toml
index b77f5dd050850..91943937e1b2e 100644
--- a/crates/uv-distribution-types/Cargo.toml
+++ b/crates/uv-distribution-types/Cargo.toml
@@ -32,6 +32,7 @@ uv-small-str = { workspace = true }
 arcstr = { workspace = true }
 bitflags = { workspace = true }
 fs-err = { workspace = true }
+http = { workspace = true }
 itertools = { workspace = true }
 jiff = { workspace = true }
 owo-colors = { workspace = true }
@@ -47,7 +48,5 @@ tracing = { workspace = true }
 url = { workspace = true }
 version-ranges = { workspace = true }
 
-
 [dev-dependencies]
 toml = { workspace = true }
-
diff --git a/crates/uv-distribution-types/src/index.rs b/crates/uv-distribution-types/src/index.rs
index 77fc9f008e241..d3f974ad3c69d 100644
--- a/crates/uv-distribution-types/src/index.rs
+++ b/crates/uv-distribution-types/src/index.rs
@@ -1,6 +1,7 @@
 use std::path::Path;
 use std::str::FromStr;
 
+use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use url::Url;
 
@@ -8,11 +9,9 @@ use uv_auth::{AuthPolicy, Credentials};
 
 use crate::index_name::{IndexName, IndexNameError};
 use crate::origin::Origin;
-use crate::{IndexUrl, IndexUrlError};
+use crate::{IndexStatusCodeStrategy, IndexUrl, IndexUrlError, SerializableStatusCode};
 
-#[derive(
-    Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, serde::Deserialize,
-)]
+#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
 #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
 #[serde(rename_all = "kebab-case")]
 pub struct Index {
@@ -94,6 +93,17 @@ pub struct Index {
     /// ```
     #[serde(default)]
     pub authenticate: AuthPolicy,
+    /// Status codes that uv should ignore when deciding whether
+    /// to continue searching in the next index after a failure.
+    ///
+    /// ```toml
+    /// [[tool.uv.index]]
+    /// name = "my-index"
+    /// url = "https://<omitted>/simple"
+    /// ignore-error-codes = [401, 403]
+    /// ```
+    #[serde(default)]
+    pub ignore_error_codes: Option<Vec<SerializableStatusCode>>,
 }
 
 #[derive(
@@ -131,6 +141,7 @@ impl Index {
             format: IndexFormat::Simple,
             publish_url: None,
             authenticate: AuthPolicy::default(),
+            ignore_error_codes: None,
         }
     }
 
@@ -145,6 +156,7 @@ impl Index {
             format: IndexFormat::Simple,
             publish_url: None,
             authenticate: AuthPolicy::default(),
+            ignore_error_codes: None,
         }
     }
 
@@ -159,6 +171,7 @@ impl Index {
             format: IndexFormat::Flat,
             publish_url: None,
             authenticate: AuthPolicy::default(),
+            ignore_error_codes: None,
         }
     }
 
@@ -214,6 +227,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if let Some(ignore_error_codes) = &self.ignore_error_codes {
+            IndexStatusCodeStrategy::from_ignored_error_codes(ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
+    }
 }
 
 impl From<IndexUrl> for Index {
@@ -227,6 +249,7 @@ impl From<IndexUrl> for Index {
             format: IndexFormat::Simple,
             publish_url: None,
             authenticate: AuthPolicy::default(),
+            ignore_error_codes: None,
         }
     }
 }
@@ -249,6 +272,7 @@ impl FromStr for Index {
                     format: IndexFormat::Simple,
                     publish_url: None,
                     authenticate: AuthPolicy::default(),
+                    ignore_error_codes: None,
                 });
             }
         }
@@ -264,6 +288,7 @@ impl FromStr for Index {
             format: IndexFormat::Simple,
             publish_url: None,
             authenticate: AuthPolicy::default(),
+            ignore_error_codes: None,
         })
     }
 }
diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs
index fdb4ea1e5a6b4..e7f50cd189fef 100644
--- a/crates/uv-distribution-types/src/index_url.rs
+++ b/crates/uv-distribution-types/src/index_url.rs
@@ -12,7 +12,7 @@ use url::{ParseError, Url};
 
 use uv_pep508::{split_scheme, Scheme, VerbatimUrl, VerbatimUrlError};
 
-use crate::{Index, Verbatim};
+use crate::{Index, IndexStatusCodeStrategy, Verbatim};
 
 static PYPI_URL: LazyLock<Url> = LazyLock::new(|| Url::parse("https://pypi.org/simple").unwrap());
 
@@ -536,6 +536,16 @@ impl<'a> IndexUrls {
     pub fn no_index(&self) -> bool {
         self.no_index
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for an [`IndexUrl`].
+    pub fn status_code_strategy_for(&self, url: &IndexUrl) -> IndexStatusCodeStrategy {
+        for index in &self.indexes {
+            if index.url() == url {
+                return index.status_code_strategy();
+            }
+        }
+        IndexStatusCodeStrategy::Default
+    }
 }
 
 bitflags::bitflags! {
diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs
index 2732d1fdb0123..c062e8b01625d 100644
--- a/crates/uv-distribution-types/src/lib.rs
+++ b/crates/uv-distribution-types/src/lib.rs
@@ -75,6 +75,7 @@ pub use crate::requirement::*;
 pub use crate::resolution::*;
 pub use crate::resolved::*;
 pub use crate::specified_requirement::*;
+pub use crate::status_code_strategy::*;
 pub use crate::traits::*;
 
 mod annotation;
@@ -101,6 +102,7 @@ mod requirement;
 mod resolution;
 mod resolved;
 mod specified_requirement;
+mod status_code_strategy;
 mod traits;
 
 #[derive(Debug, Clone)]
diff --git a/crates/uv-distribution-types/src/status_code_strategy.rs b/crates/uv-distribution-types/src/status_code_strategy.rs
new file mode 100644
index 0000000000000..758fb001fcaf4
--- /dev/null
+++ b/crates/uv-distribution-types/src/status_code_strategy.rs
@@ -0,0 +1,291 @@
+use std::ops::Deref;
+
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.ends_with("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: status_codes
+                .iter()
+                .map(SerializableStatusCode::deref)
+                .copied()
+                .collect::<FxHashSet<_>>(),
+        }
+    }
+
+    /// Derive a strategy for ignoring authentication error codes.
+    pub fn ignore_authentication_error_codes() -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: FxHashSet::from_iter([
+                StatusCode::UNAUTHORIZED,
+                StatusCode::FORBIDDEN,
+                StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
+                StatusCode::PROXY_AUTHENTICATION_REQUIRED,
+            ]),
+        }
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Ignore,
+                StatusCode::UNAUTHORIZED => {
+                    capabilities.set_unauthorized(index_url.clone());
+                    IndexStatusCodeDecision::Fail(status_code)
+                }
+                StatusCode::FORBIDDEN => {
+                    capabilities.set_forbidden(index_url.clone());
+                    IndexStatusCodeDecision::Fail(status_code)
+                }
+                _ => IndexStatusCodeDecision::Fail(status_code),
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Ignore
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
+pub enum IndexStatusCodeDecision {
+    Ignore,
+    Fail(StatusCode),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SerializableStatusCode(StatusCode);
+
+impl Deref for SerializableStatusCode {
+    type Target = StatusCode;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl Serialize for SerializableStatusCode {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_u16(self.0.as_u16())
+    }
+}
+
+impl<'de> Deserialize<'de> for SerializableStatusCode {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let code = u16::deserialize(deserializer)?;
+        StatusCode::from_u16(code)
+            .map(SerializableStatusCode)
+            .map_err(|_| {
+                serde::de::Error::custom(format!("{code} is not a valid HTTP status code"))
+            })
+    }
+}
+
+#[cfg(feature = "schemars")]
+impl schemars::JsonSchema for SerializableStatusCode {
+    fn schema_name() -> String {
+        "StatusCode".to_string()
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        let mut schema = gen.subschema_for::<u16>().into_object();
+        schema.metadata().description = Some("HTTP status code (100-599)".to_string());
+        schema.number().minimum = Some(100.0);
+        schema.number().maximum = Some(599.0);
+
+        schema.into()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::str::FromStr;
+
+    use url::Url;
+
+    use super::*;
+
+    #[test]
+    fn test_strategy_normal_registry() {
+        let url = Url::from_str("https://internal-registry.com/simple").unwrap();
+        assert_eq!(
+            IndexStatusCodeStrategy::from_index_url(&url),
+            IndexStatusCodeStrategy::Default
+        );
+    }
+
+    #[test]
+    fn test_strategy_pytorch_registry() {
+        let status_codes = std::iter::once(StatusCode::FORBIDDEN).collect::<FxHashSet<_>>();
+        let url = Url::from_str("https://download.pytorch.org/whl/cu118").unwrap();
+        assert_eq!(
+            IndexStatusCodeStrategy::from_index_url(&url),
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes }
+        );
+    }
+
+    #[test]
+    fn test_strategy_custom_error_codes() {
+        let status_codes = FxHashSet::from_iter([StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN]);
+        let serializable_status_codes = status_codes
+            .iter()
+            .map(|code| SerializableStatusCode(*code))
+            .collect::<Vec<_>>();
+        assert_eq!(
+            IndexStatusCodeStrategy::from_ignored_error_codes(&serializable_status_codes),
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes }
+        );
+    }
+
+    #[test]
+    fn test_decision_default_400() {
+        let strategy = IndexStatusCodeStrategy::Default;
+        let status_code = StatusCode::BAD_REQUEST;
+        let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
+        let capabilities = IndexCapabilities::default();
+        let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+        assert_eq!(
+            decision,
+            IndexStatusCodeDecision::Fail(StatusCode::BAD_REQUEST)
+        );
+    }
+
+    #[test]
+    fn test_decision_default_401() {
+        let strategy = IndexStatusCodeStrategy::Default;
+        let status_code = StatusCode::UNAUTHORIZED;
+        let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
+        let capabilities = IndexCapabilities::default();
+        let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+        assert_eq!(
+            decision,
+            IndexStatusCodeDecision::Fail(StatusCode::UNAUTHORIZED)
+        );
+        assert!(capabilities.unauthorized(&index_url));
+        assert!(!capabilities.forbidden(&index_url));
+    }
+
+    #[test]
+    fn test_decision_default_403() {
+        let strategy = IndexStatusCodeStrategy::Default;
+        let status_code = StatusCode::FORBIDDEN;
+        let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
+        let capabilities = IndexCapabilities::default();
+        let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+        assert_eq!(
+            decision,
+            IndexStatusCodeDecision::Fail(StatusCode::FORBIDDEN)
+        );
+        assert!(capabilities.forbidden(&index_url));
+        assert!(!capabilities.unauthorized(&index_url));
+    }
+
+    #[test]
+    fn test_decision_default_404() {
+        let strategy = IndexStatusCodeStrategy::Default;
+        let status_code = StatusCode::NOT_FOUND;
+        let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
+        let capabilities = IndexCapabilities::default();
+        let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+        assert_eq!(decision, IndexStatusCodeDecision::Ignore);
+        assert!(!capabilities.forbidden(&index_url));
+        assert!(!capabilities.unauthorized(&index_url));
+    }
+
+    #[test]
+    fn test_decision_pytorch() {
+        let index_url = IndexUrl::parse("https://download.pytorch.org/whl/cu118", None).unwrap();
+        let strategy = IndexStatusCodeStrategy::from_index_url(&index_url);
+        let capabilities = IndexCapabilities::default();
+        // Test we continue on 403 for PyTorch registry.
+        let status_code = StatusCode::FORBIDDEN;
+        let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+        assert_eq!(decision, IndexStatusCodeDecision::Ignore);
+        // Test we stop on 401 for PyTorch registry.
+        let status_code = StatusCode::UNAUTHORIZED;
+        let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+        assert_eq!(
+            decision,
+            IndexStatusCodeDecision::Fail(StatusCode::UNAUTHORIZED)
+        );
+    }
+
+    #[test]
+    fn test_decision_multiple_ignored_status_codes() {
+        let status_codes = vec![
+            StatusCode::UNAUTHORIZED,
+            StatusCode::BAD_GATEWAY,
+            StatusCode::SERVICE_UNAVAILABLE,
+        ];
+        let strategy = IndexStatusCodeStrategy::IgnoreErrorCodes {
+            status_codes: status_codes.iter().copied().collect::<FxHashSet<_>>(),
+        };
+        let index_url = IndexUrl::parse("https://internal-registry.com/simple", None).unwrap();
+        let capabilities = IndexCapabilities::default();
+        // Test each ignored status code
+        for status_code in status_codes {
+            let decision = strategy.handle_status_code(status_code, &index_url, &capabilities);
+            assert_eq!(decision, IndexStatusCodeDecision::Ignore);
+        }
+        // Test a status code that's not ignored
+        let other_status_code = StatusCode::FORBIDDEN;
+        let decision = strategy.handle_status_code(other_status_code, &index_url, &capabilities);
+        assert_eq!(
+            decision,
+            IndexStatusCodeDecision::Fail(StatusCode::FORBIDDEN)
+        );
+    }
+}
diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs
index 06f3e8d4a439f..5247f5ca374e8 100644
--- a/crates/uv-resolver/src/pubgrub/report.rs
+++ b/crates/uv-resolver/src/pubgrub/report.rs
@@ -930,12 +930,6 @@ impl PubGrubReportFormatter<'_> {
                 });
             }
             if index_capabilities.forbidden(&index.url) {
-                // If the index is a PyTorch index (e.g., `https://download.pytorch.org/whl/cu118`),
-                // avoid noting the lack of credentials. PyTorch returns a 403 (Forbidden) status
-                // code for any package that does not exist.
-                if index.url.url().host_str() == Some("download.pytorch.org") {
-                    continue;
-                }
                 hints.insert(PubGrubHint::ForbiddenIndex {
                     index: index.url.clone(),
                 });
diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs
index a101e18120b66..4e9923cb680ba 100644
--- a/crates/uv/tests/it/edit.rs
+++ b/crates/uv/tests/it/edit.rs
@@ -15,6 +15,7 @@ use indoc::{formatdoc, indoc};
 use insta::assert_snapshot;
 use std::path::Path;
 use uv_fs::Simplified;
+use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
 
 use uv_static::EnvVars;
 
@@ -10533,10 +10534,8 @@ fn add_index_url_in_keyring() -> Result<()> {
         version = "1.0.0"
         requires-python = ">=3.11, <4"
         dependencies = []
-
         [tool.uv]
         keyring-provider = "subprocess"
-
         [[tool.uv.index]]
         name = "proxy"
         url = "https://pypi-proxy.fly.dev/basic-auth/simple"
@@ -10593,10 +10592,8 @@ fn add_full_url_in_keyring() -> Result<()> {
         version = "1.0.0"
         requires-python = ">=3.11, <4"
         dependencies = []
-
         [tool.uv]
         keyring-provider = "subprocess"
-
         [[tool.uv.index]]
         name = "proxy"
         url = "https://pypi-proxy.fly.dev/basic-auth/simple"
@@ -10625,6 +10622,244 @@ fn add_full_url_in_keyring() -> Result<()> {
     Ok(())
 }
 
+/// If uv receives an authentication failure from a configured index, it
+/// should not fall back to the default index.
+#[test]
+fn add_stop_index_search_early_on_auth_failure() -> Result<()> {
+    let context = TestContext::new("3.12");
+
+    let pyproject_toml = context.temp_dir.child("pyproject.toml");
+    pyproject_toml.write_str(indoc! { r#"
+        [project]
+        name = "foo"
+        version = "1.0.0"
+        requires-python = ">=3.11, <4"
+        dependencies = []
+        [[tool.uv.index]]
+        name = "my-index"
+        url = "https://pypi-proxy.fly.dev/basic-auth/simple"
+        "#
+    })?;
+
+    uv_snapshot!(context.add().arg("anyio"), @r"
+    success: false
+    exit_code: 1
+    ----- stdout -----
+
+    ----- stderr -----
+      × No solution found when resolving dependencies:
+      ╰─▶ Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable.
+
+          hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
+      help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
+    "
+    );
+    Ok(())
+}
+
+/// uv should continue searching the default index if it receives an
+/// authentication failure that is specified in `ignore-error-codes`.
+#[test]
+fn add_ignore_error_codes() -> Result<()> {
+    let context = TestContext::new("3.12");
+
+    let pyproject_toml = context.temp_dir.child("pyproject.toml");
+    pyproject_toml.write_str(indoc! { r#"
+        [project]
+        name = "foo"
+        version = "1.0.0"
+        requires-python = ">=3.11, <4"
+        dependencies = []
+        [[tool.uv.index]]
+        name = "my-index"
+        url = "https://pypi-proxy.fly.dev/basic-auth/simple"
+        ignore-error-codes = [401, 403]
+        "#
+    })?;
+
+    uv_snapshot!(context.add().arg("anyio"), @r"
+    success: true
+    exit_code: 0
+    ----- stdout -----
+
+    ----- stderr -----
+    Resolved 4 packages in [TIME]
+    Prepared 3 packages in [TIME]
+    Installed 3 packages in [TIME]
+     + anyio==4.3.0
+     + idna==3.6
+     + sniffio==1.3.1
+    "
+    );
+
+    context.assert_command("import anyio").success();
+    Ok(())
+}
+
+/// uv should only fall through on 404s if an empty list is specified
+/// in `ignore-error-codes`, even for pytorch.
+#[test]
+fn add_empty_ignore_error_codes() -> Result<()> {
+    let context = TestContext::new("3.12");
+
+    let pyproject_toml = context.temp_dir.child("pyproject.toml");
+    pyproject_toml.write_str(indoc! { r#"
+        [project]
+        name = "foo"
+        version = "1.0.0"
+        requires-python = ">=3.11, <4"
+        dependencies = []
+
+        [tool.uv.sources]
+        jinja2 = { index = "pytorch" }
+
+        [[tool.uv.index]]
+        name = "pytorch"
+        url = "https://download.pytorch.org/whl/cpu"
+        ignore-error-codes = []
+        "#
+    })?;
+
+    // The default behavior of ignoring pytorch 403s has been overridden
+    // by the empty ignore-error-codes list.
+    uv_snapshot!(context.add().arg("flask"), @r"
+    success: false
+    exit_code: 1
+    ----- stdout -----
+
+    ----- stderr -----
+      × No solution found when resolving dependencies:
+      ╰─▶ Because flask was not found in the package registry and your project depends on flask, we can conclude that your project's requirements are unsatisfiable.
+
+          hint: An index URL (https://download.pytorch.org/whl/cpu) could not be queried due to a lack of valid authentication credentials (403 Forbidden).
+      help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
+    "
+    );
+    Ok(())
+}
+
+/// uv should not report a credential error on a missing package for pytorch since
+/// pytorch returns 403s to indicate not found.
+#[test]
+fn add_missing_package_on_pytorch() -> Result<()> {
+    let context = TestContext::new("3.12");
+
+    let pyproject_toml = context.temp_dir.child("pyproject.toml");
+    pyproject_toml.write_str(indoc! { r#"
+        [project]
+        name = "foo"
+        version = "1.0.0"
+        requires-python = ">=3.11, <4"
+        dependencies = []
+
+        [tool.uv.sources]
+        fakepkg = { index = "pytorch" }
+
+        [[tool.uv.index]]
+        name = "pytorch"
+        url = "https://download.pytorch.org/whl/cpu"
+        "#
+    })?;
+
+    uv_snapshot!(context.add().arg("fakepkg"), @r"
+    success: false
+    exit_code: 1
+    ----- stdout -----
+
+    ----- stderr -----
+      × No solution found when resolving dependencies:
+      ╰─▶ Because fakepkg was not found in the package registry and your project depends on fakepkg, we can conclude that your project's requirements are unsatisfiable.
+      help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
+    "
+    );
+    Ok(())
+}
+
+/// Test HTTP errors other than 401s and 403s.
+#[tokio::test]
+async fn add_unexpected_error_code() -> Result<()> {
+    let context = TestContext::new("3.12");
+    let filters = context
+        .filters()
+        .into_iter()
+        .chain([(r"127\.0\.0\.1(?::\d+)?", "[LOCALHOST]")])
+        .collect::<Vec<_>>();
+
+    let server = MockServer::start().await;
+
+    Mock::given(method("GET"))
+        .respond_with(ResponseTemplate::new(503))
+        .mount(&server)
+        .await;
+
+    let pyproject_toml = context.temp_dir.child("pyproject.toml");
+    pyproject_toml.write_str(indoc! { r#"
+        [project]
+        name = "foo"
+        version = "1.0.0"
+        requires-python = ">=3.11, <4"
+        dependencies = []
+        "#
+    })?;
+
+    uv_snapshot!(filters, context.add().arg("anyio").arg("--index").arg(server.uri()), @r"
+    success: false
+    exit_code: 2
+    ----- stdout -----
+
+    ----- stderr -----
+    error: Failed to fetch: `http://[LOCALHOST]/anyio/`
+      Caused by: HTTP status server error (503 Service Unavailable) for url (http://[LOCALHOST]/anyio/)
+    "
+    );
+    Ok(())
+}
+
+/// uv should fail to parse `pyproject.toml` if `ignore-error-codes`
+/// contains an invalid status code number.
+#[test]
+fn add_invalid_ignore_error_code() -> Result<()> {
+    let context = TestContext::new("3.12");
+
+    let pyproject_toml = context.temp_dir.child("pyproject.toml");
+    pyproject_toml.write_str(indoc! { r#"
+        [project]
+        name = "foo"
+        version = "1.0.0"
+        requires-python = ">=3.11, <4"
+        dependencies = []
+        [[tool.uv.index]]
+        name = "my-index"
+        url = "https://pypi-proxy.fly.dev/basic-auth/simple"
+        ignore-error-codes = [401, 403, 1234]
+        "#
+    })?;
+
+    uv_snapshot!(context.add().arg("anyio"), @r"
+    success: false
+    exit_code: 2
+    ----- stdout -----
+
+    ----- stderr -----
+    warning: Failed to parse `pyproject.toml` during settings discovery:
+      TOML parse error at line 9, column 22
+        |
+      9 | ignore-error-codes = [401, 403, 1234]
+        |                      ^^^^^^^^^^^^^^^^
+      1234 is not a valid HTTP status code
+
+    error: Failed to parse: `pyproject.toml`
+      Caused by: TOML parse error at line 9, column 22
+      |
+    9 | ignore-error-codes = [401, 403, 1234]
+      |                      ^^^^^^^^^^^^^^^^
+    1234 is not a valid HTTP status code
+    "
+    );
+
+    Ok(())
+}
+
 /// In authentication "always", the normal authentication flow should still work.
 #[test]
 fn add_auth_policy_always_with_credentials() -> Result<()> {
diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs
index fb88ebc6d521e..faab528a55efc 100644
--- a/crates/uv/tests/it/lock.rs
+++ b/crates/uv/tests/it/lock.rs
@@ -7517,7 +7517,7 @@ fn lock_index_workspace_member() -> Result<()> {
     )?;
 
     // Locking without the necessary credentials should fail.
-    uv_snapshot!(context.filters(), context.lock(), @r###"
+    uv_snapshot!(context.filters(), context.lock(), @r"
     success: false
     exit_code: 1
     ----- stdout -----
@@ -7526,7 +7526,7 @@ fn lock_index_workspace_member() -> Result<()> {
       × No solution found when resolving dependencies:
       ╰─▶ Because iniconfig was not found in the package registry and child depends on iniconfig>=2, we can conclude that child's requirements are unsatisfiable.
           And because your workspace requires child, we can conclude that your workspace's requirements are unsatisfiable.
-    "###);
+    ");
 
     uv_snapshot!(context.filters(), context.lock()
         .env("UV_INDEX_MY_INDEX_USERNAME", "public")
@@ -8459,7 +8459,7 @@ fn lock_env_credentials() -> Result<()> {
     )?;
 
     // Without credentials, the resolution should fail.
-    uv_snapshot!(context.filters(), context.lock(), @r###"
+    uv_snapshot!(context.filters(), context.lock(), @r"
     success: false
     exit_code: 1
     ----- stdout -----
@@ -8469,7 +8469,7 @@ fn lock_env_credentials() -> Result<()> {
       ╰─▶ Because iniconfig was not found in the package registry and your project depends on iniconfig, we can conclude that your project's requirements are unsatisfiable.
 
           hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
-    "###);
+    ");
 
     // Provide credentials via environment variables.
     uv_snapshot!(context.filters(), context.lock()
diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs
index 55da48df55c90..cf7c1c7a2854e 100644
--- a/crates/uv/tests/it/show_settings.rs
+++ b/crates/uv/tests/it/show_settings.rs
@@ -138,6 +138,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -299,6 +300,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -461,6 +463,7 @@ fn resolve_uv_toml() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -655,6 +658,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -959,6 +963,7 @@ fn resolve_pyproject_toml() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -1147,6 +1152,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -1178,6 +1184,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -1343,6 +1350,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -1374,6 +1382,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -1405,6 +1414,7 @@ fn resolve_index_url() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -1592,6 +1602,7 @@ fn resolve_find_links() -> anyhow::Result<()> {
                         format: Flat,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 no_index: true,
@@ -1922,6 +1933,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -1953,6 +1965,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -2114,6 +2127,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -2145,6 +2159,7 @@ fn resolve_top_level() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -3224,6 +3239,7 @@ fn resolve_both() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -3510,6 +3526,7 @@ fn resolve_config_file() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -4221,6 +4238,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -4252,6 +4270,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -4415,6 +4434,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -4446,6 +4466,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -4615,6 +4636,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -4646,6 +4668,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -4810,6 +4833,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -4841,6 +4865,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -5012,6 +5037,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -5043,6 +5069,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
@@ -5207,6 +5234,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                     Index {
                         name: None,
@@ -5238,6 +5266,7 @@ fn index_priority() -> anyhow::Result<()> {
                         format: Simple,
                         publish_url: None,
                         authenticate: Auto,
+                        ignore_error_codes: None,
                     },
                 ],
                 flat_index: [],
diff --git a/docs/configuration/indexes.md b/docs/configuration/indexes.md
index a4e66ea6e26d0..b740082bea8ac 100644
--- a/docs/configuration/indexes.md
+++ b/docs/configuration/indexes.md
@@ -122,7 +122,7 @@ malicious package to be installed instead of the internal package. See, for exam
 [the `torchtriton` attack](https://pytorch.org/blog/compromised-nightly-dependency/) from
 December 2022.
 
-Users can opt in to alternate index behaviors via the`--index-strategy` command-line option, or the
+To opt in to alternate index behaviors, use the`--index-strategy` command-line option, or the
 `UV_INDEX_STRATEGY` environment variable, which supports the following values:
 
 - `first-index` (default): Search for each package across all indexes, limiting the candidate
@@ -211,6 +211,27 @@ credentials cannot be found. If the discovered credentials are not valid (i.e.,
 HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as
 described in the [index strategy](#searching-across-multiple-indexes) section.
 
+### Ignoring error codes when searching across indexes
+
+When using the [first-index strategy](#searching-across-multiple-indexes), uv will stop searching
+across indexes if an HTTP 401 Unauthorized or HTTP 403 Forbidden status code is encountered. The one
+exception is that uv will ignore 403s when searching the `pytorch` index (since this index returns a
+403 when a package is not present).
+
+To configure which error codes are ignored for an index, use the `ignored-error-codes` setting. For
+example, to ignore 403s (but not 401s) for a private index:
+
+```toml
+[[tool.uv.index]]
+name = "private-index"
+url = "https://private-index.com/simple"
+authenticate = "always"
+ignore-error-codes = [403]
+```
+
+uv will always continue searching across indexes when it encounters a `404 Not Found`. This cannot
+be overridden.
+
 ### Disabling authentication
 
 To prevent leaking credentials, authentication can be disabled for an index:
diff --git a/uv.schema.json b/uv.schema.json
index aab9c9e455022..ee2b2c1e186ee 100644
--- a/uv.schema.json
+++ b/uv.schema.json
@@ -870,6 +870,17 @@
             }
           ]
         },
+        "ignore-error-codes": {
+          "description": "Status codes that uv should ignore when deciding whether to continue searching in the next index after a failure.\n\n```toml [[tool.uv.index]] name = \"my-index\" url = \"https://<omitted>/simple\" ignore-error-codes = [401, 403] ```",
+          "default": null,
+          "type": [
+            "array",
+            "null"
+          ],
+          "items": {
+            "$ref": "#/definitions/StatusCode"
+          }
+        },
         "name": {
           "description": "The name of the index.\n\nIndex names can be used to reference indexes elsewhere in the configuration. For example, you can pin a package to a specific index by name:\n\n```toml [[tool.uv.index]] name = \"pytorch\" url = \"https://download.pytorch.org/whl/cu121\"\n\n[tool.uv.sources] torch = { index = \"pytorch\" } ```",
           "anyOf": [
@@ -2005,6 +2016,13 @@
         }
       }
     },
+    "StatusCode": {
+      "description": "HTTP status code (100-599)",
+      "type": "integer",
+      "format": "uint16",
+      "maximum": 599.0,
+      "minimum": 100.0
+    },
     "String": {
       "type": "string"
     },


Reviews:
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T19:45:35Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T18:58:42Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:00:05Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:01:02Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:02:13Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:03:38Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T19:06:24Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T09:59:22Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T10:02:19Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T10:10:39Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T10:29:40Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T13:38:08Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T13:43:22Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T13:51:07Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T14:01:42Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T14:03:18Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:21:39Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:22:34Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:29:08Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T16:57:51Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T17:01:11Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T18:44:15Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T19:13:14Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T20:18:59Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T20:30:55Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T20:53:07Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T20:54:10Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T20:54:33Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T20:56:00Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T20:56:42Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T07:46:58Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T07:47:07Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:09:24Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:14:49Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:15:02Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:16:48Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:18:05Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:19:45Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:23:27Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:24:35Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T21:37:00Z
user: login: zanieb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-18T21:43:36Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-19T07:45:27Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T09:10:28Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T14:53:58Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:46:40Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:48:46Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:50:04Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:50:50Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:51:21Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:52:30Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:54:22Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:54:40Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T02:56:15Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:25:11Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:43:07Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:47:03Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:03:43Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:05:25Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T08:48:12Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T09:39:19Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T13:37:53Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:50:24Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:51:24Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:54:46Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:57:52Z
user: login: jtfmumm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T13:58:03Z
user: login: charliermarsh
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T15:39:54Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:40:52Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:41:21Z
user: login: charliermarsh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T15:42:23Z

Review comments:
diff_hunk: @@ -60,6 +60,10 @@ pub struct Index {
     pub auth_policy: AuthPolicy,
 }
 
+// TODO(john): Multiple methods in this struct need to iterate over
path: crates/uv-auth/src/index.rs
user: login: zanieb
body: I love watching this comment jump around :D
created_at: 2025-04-10T19:45:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,205 @@
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.contains("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter(vec![StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[u16]) -> Self {
+        let status_codes = status_codes
+            .iter()
+            .map(|code| StatusCode::from_u16(*code))
+            .collect::<Result<FxHashSet<_>, _>>()
+            .expect("Status codes should be valid.");
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: zanieb
body: Why `expect` here? How do we know they're valid? Should this just return an `Err`?
created_at: 2025-04-16T18:58:42Z
author_association: MEMBER
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: Does this mean we don't allow overriding our default behavior for given index URLs by defining an empty set `ignore-error-codes = []`?
created_at: 2025-04-16T19:00:05Z
author_association: MEMBER
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: Why not just store `StatusCode` directly instead of the u16?
created_at: 2025-04-16T19:01:01Z
author_association: MEMBER
diff_hunk: @@ -490,18 +505,19 @@ impl RegistryClient {
             Ok(metadata) => Ok(Some(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
+                ErrorKind::WrappedReqwestError(url, err) => {
+                    let decision = if let Some(status) = err.status() {
+                        status_code_strategy.handle_status_code(status, index, capabilities)
+                    } else {
+                        IndexStatusCodeDecision::Stop
+                    };
path: crates/uv-client/src/registry_client.rs
user: login: zanieb
body: Can this use `.map_or`?
created_at: 2025-04-16T19:02:12Z
author_association: MEMBER
diff_hunk: @@ -272,17 +274,20 @@ impl Middleware for AuthMiddleware {
             (request, None)
         };
 
-        // Check if there are credentials in the realm-level cache
-        let credentials = self
-            .cache()
-            .get_realm(
-                Realm::from(retry_request.url()),
-                credentials
-                    .as_ref()
-                    .map(|credentials| credentials.to_username())
-                    .unwrap_or(Username::none()),
-            )
-            .or(credentials);
+        let username = credentials
+            .as_ref()
+            .map(|credentials| credentials.to_username())
+            .unwrap_or(Username::none());
+        let credentials = if let Some(index_url) = maybe_index_url {
+            self.cache().get_url(index_url, &username)
+        } else {
+            // Since there is no known index for this URL, check if there are credentials in
+            // the realm-level cache.
+            self.cache()
+                .get_realm(Realm::from(retry_request.url()), username)
+        }
+        .or(credentials);
path: crates/uv-auth/src/middleware.rs
user: login: zanieb
body: Is this part of this pull request? Does this need rebase?
created_at: 2025-04-16T19:03:37Z
author_association: MEMBER
diff_hunk: @@ -578,6 +590,18 @@ impl IndexCapabilities {
             .insert(Flags::NO_RANGE_REQUESTS);
     }
 
+    pub fn set_by_status_code(&self, status_code: StatusCode, index_url: &IndexUrl) {
path: crates/uv-distribution-types/src/index_url.rs
user: login: zanieb
body: Can you add a doc?
created_at: 2025-04-16T19:06:24Z
author_association: MEMBER
diff_hunk: @@ -272,17 +274,20 @@ impl Middleware for AuthMiddleware {
             (request, None)
         };
 
-        // Check if there are credentials in the realm-level cache
-        let credentials = self
-            .cache()
-            .get_realm(
-                Realm::from(retry_request.url()),
-                credentials
-                    .as_ref()
-                    .map(|credentials| credentials.to_username())
-                    .unwrap_or(Username::none()),
-            )
-            .or(credentials);
+        let username = credentials
+            .as_ref()
+            .map(|credentials| credentials.to_username())
+            .unwrap_or(Username::none());
+        let credentials = if let Some(index_url) = maybe_index_url {
+            self.cache().get_url(index_url, &username)
+        } else {
+            // Since there is no known index for this URL, check if there are credentials in
+            // the realm-level cache.
+            self.cache()
+                .get_realm(Realm::from(retry_request.url()), username)
+        }
+        .or(credentials);
path: crates/uv-auth/src/middleware.rs
user: login: jtfmumm
body: Yeah, need a rebase
created_at: 2025-04-17T09:59:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: I tried that first, but `StatusCode` doesn't implement `Serialize`. 
created_at: 2025-04-17T10:02:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: The new default behavior is to only ignore 404s, which we do no matter what `IndexStatusCodeStrategy` is specified. I'm not sure we want to allow disabling that. 
created_at: 2025-04-17T10:10:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -578,6 +590,18 @@ impl IndexCapabilities {
             .insert(Flags::NO_RANGE_REQUESTS);
     }
 
+    pub fn set_by_status_code(&self, status_code: StatusCode, index_url: &IndexUrl) {
path: crates/uv-distribution-types/src/index_url.rs
user: login: jtfmumm
body: Added
created_at: 2025-04-17T10:29:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: Why does it need to implement `Serialize`?
created_at: 2025-04-17T13:38:08Z
author_association: MEMBER
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: I'm referring to the `from_index_url` behavior here, not the default behavior. Let's say I'm using pytorch but don't want to ignore 403s (e.g., because something has changed and our default is incorrect), that's not possible with `ignore-error-codes = []`, right? You'd need `ignore-error-codes = [XXX]` where `XXX` is some arbitrary code.

Separately, regarding the fallback to the default behavior... what do we do if a user specifies `ignore-error-codes = [404]`?  We'd ignore the 404, right?
created_at: 2025-04-17T13:43:21Z
author_association: MEMBER
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: Ah, ok that wasn't clear. That's true for the pytorch case. I can update it to treat an explicit empty list as overriding the default.

If you specified just `[404]`, then uv would behave exactly as it does with the default.
created_at: 2025-04-17T13:51:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: > I can update it to treat an explicit empty list as overriding the default.

I like the idea of allowing the user to opt out of our inference.

> If you specified just [404], then uv would behave exactly as it does with the default.

Ah yeah sorry that was a silly question, the 404 is _ignored_ by default. Confusing myself :)

Is the motivation for this that you don't want people to need to specify `404` once they start adding `ignore-error-codes`? And / or that you think people should _never_ be allowed to fail on 404s?
created_at: 2025-04-17T14:01:41Z
author_association: MEMBER
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: (fwiw we had a similar discussion around extension of default values in https://github.com/astral-sh/uv/pull/7136#discussion_r1750399976)
created_at: 2025-04-17T14:03:18Z
author_association: MEMBER
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: Yeah, I agree that allowing them to opt-out is usefully flexible. But I don't think failing on 404s makes sense. The whole point of the index strategies is to determine what to do when a package is missing. If someone requested the ability to fail on 404s, I'd recommend that they set a single index they want to search as the default. Does that make sense?
created_at: 2025-04-17T14:21:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: (updated by the way)
created_at: 2025-04-17T14:22:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: Both `Serialize` and `Deserialize` are derived on `Index` and `StatusCode` doesn't implement either. We could do `#[serde(skip)]` but that seemed incorrect to me.
created_at: 2025-04-17T15:29:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: Stepping up a level, why is `Index` serializable? For writing it to the TOML file? If that's it, why _would_ we need this field to be serializable? A user can't construct it via the CLI. If truly necessary, why not use a wrapper type to implement serializability?
created_at: 2025-04-17T16:57:51Z
author_association: MEMBER
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: Alright, that seems sensible. Let's just make sure it's clearly documented that 404s are always "ignored". Can you also add a section to https://docs.astral.sh/uv/configuration/indexes/
created_at: 2025-04-17T17:01:11Z
author_association: MEMBER
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: If we don’t need it, I can use #[serde(skip)]
created_at: 2025-04-17T18:44:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: I guess it's going to be more complicated than that. We can't use skip when we need a `deserialize_with` to parse the numbers in the TOML file. I'll need to implement a wrapper type around `StatusCode` for deriving `schemars::JsonSchema` as well as `De/Serialize` if we want to do it this way. 
created_at: 2025-04-17T19:13:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: zanieb
body: It seems nice for type safety throughout and straightforward to implement? I don't feel super strongly, but the current approach of validating at the CLI level, but retaining the u16, then revalidating later seems quite weird.
created_at: 2025-04-17T20:18:59Z
author_association: MEMBER
diff_hunk: @@ -349,6 +375,25 @@ impl<'a> From<&'a IndexUrl> for IndexMetadataRef<'a> {
     }
 }
 
+/// Validate the provided error codes.
+fn validate_error_codes<'de, D>(deserializer: D) -> Result<Vec<u16>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    let status_codes = Option::<Vec<u16>>::deserialize(deserializer)?;
+    if let Some(codes) = status_codes {
+        for code in &codes {
+            if StatusCode::from_u16(*code).is_err() {
+                return Err(serde::de::Error::custom(format!(
+                    "{code} is not a valid HTTP status code"
+                )));
+            }
+        }
+        return Ok(codes);
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: Yeah that’s why originally it was validated once on ingest with an expect down the line when converted to a StatusCode. But I can also create the wrapper
created_at: 2025-04-17T20:30:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,205 @@
+use http::{status::InvalidStatusCode, StatusCode};
+use rustc_hash::FxHashSet;
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.contains("pytorch.org"))
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: charliermarsh
body: Should we compare the `Realm` here instead? Or use `endswith`?
created_at: 2025-04-17T20:53:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,205 @@
+use http::{status::InvalidStatusCode, StatusCode};
+use rustc_hash::FxHashSet;
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.contains("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter(vec![StatusCode::FORBIDDEN]),
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: charliermarsh
body: I don't think `vec!` is necessary here.
created_at: 2025-04-17T20:54:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,205 @@
+use http::{status::InvalidStatusCode, StatusCode};
+use rustc_hash::FxHashSet;
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.contains("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter(vec![StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[u16]) -> Result<Self, InvalidStatusCode> {
+        let status_codes = status_codes
+            .iter()
+            .map(|code| StatusCode::from_u16(*code))
+            .collect::<Result<FxHashSet<_>, _>>()?;
+        Ok(Self::IgnoreErrorCodes { status_codes })
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        capabilities.set_by_status_code(status_code, index_url);
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Continue,
+                StatusCode::UNAUTHORIZED => IndexStatusCodeDecision::Stop,
+                StatusCode::FORBIDDEN => IndexStatusCodeDecision::Stop,
+                _ => IndexStatusCodeDecision::Stop,
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Continue
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Hash, Eq, PartialEq)]
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: charliermarsh
body: This can be `Copy`.
created_at: 2025-04-17T20:54:32Z
author_association: MEMBER
diff_hunk: @@ -236,6 +236,9 @@ pub enum ErrorKind {
     #[error("Invalid `Content-Type` header for {0}")]
     InvalidContentTypeHeader(Url, #[source] http::header::ToStrError),
 
+    #[error("Invalid status code: {0}")]
+    InvalidStatusCode(String),
path: crates/uv-client/src/error.rs
user: login: charliermarsh
body: Can we retain the type? In other words, should this be `#[from] IndexSourceError`? Why `String`?
created_at: 2025-04-17T20:56:00Z
author_association: MEMBER
diff_hunk: @@ -30,8 +30,10 @@ uv-pypi-types = { workspace = true }
 uv-small-str = { workspace = true }
 
 arcstr = { workspace = true }
+anyhow = { workspace = true }
path: crates/uv-distribution-types/Cargo.toml
user: login: charliermarsh
body: Can we make this a dev dependency? (We typically want to avoid using `anyhow` in library crates.)
created_at: 2025-04-17T20:56:42Z
author_association: MEMBER
diff_hunk: @@ -30,8 +30,10 @@ uv-pypi-types = { workspace = true }
 uv-small-str = { workspace = true }
 
 arcstr = { workspace = true }
+anyhow = { workspace = true }
path: crates/uv-distribution-types/Cargo.toml
user: login: jtfmumm
body: Removed with refactor
created_at: 2025-04-18T07:46:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -236,6 +236,9 @@ pub enum ErrorKind {
     #[error("Invalid `Content-Type` header for {0}")]
     InvalidContentTypeHeader(Url, #[source] http::header::ToStrError),
 
+    #[error("Invalid status code: {0}")]
+    InvalidStatusCode(String),
path: crates/uv-client/src/error.rs
user: login: jtfmumm
body: Removed with refactor
created_at: 2025-04-18T07:47:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -214,6 +228,15 @@ impl Index {
         }
         Ok(self)
     }
+
+    /// Return the [`IndexStatusCodeStrategy`] for this index.
+    pub fn status_code_strategy(&self) -> IndexStatusCodeStrategy {
+        if !self.ignore_error_codes.is_empty() {
+            IndexStatusCodeStrategy::from_ignored_error_codes(&self.ignore_error_codes)
+        } else {
+            IndexStatusCodeStrategy::from_index_url(self.url.url())
+        }
path: crates/uv-distribution-types/src/index.rs
user: login: jtfmumm
body: Added
created_at: 2025-04-18T09:09:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.ends_with("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: status_codes
+                .iter()
+                .map(SerializableStatusCode::status_code)
+                .copied()
+                .collect::<FxHashSet<_>>(),
+        }
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        capabilities.set_by_status_code(status_code, index_url);
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Continue,
+                StatusCode::UNAUTHORIZED => IndexStatusCodeDecision::Stop,
+                StatusCode::FORBIDDEN => IndexStatusCodeDecision::Stop,
+                _ => IndexStatusCodeDecision::Stop,
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Continue
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
+pub enum IndexStatusCodeDecision {
+    Continue,
+    Stop,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SerializableStatusCode(StatusCode);
+
+impl SerializableStatusCode {
+    pub fn status_code(&self) -> &StatusCode {
+        &self.0
+    }
+}
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: zanieb
body: Should this just be a deref implementation? (we do that pretty often)
created_at: 2025-04-18T21:14:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,255 @@
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.ends_with("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: status_codes
+                .iter()
+                .map(SerializableStatusCode::status_code)
+                .copied()
+                .collect::<FxHashSet<_>>(),
+        }
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        capabilities.set_by_status_code(status_code, index_url);
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Continue,
+                StatusCode::UNAUTHORIZED => IndexStatusCodeDecision::Stop,
+                StatusCode::FORBIDDEN => IndexStatusCodeDecision::Stop,
+                _ => IndexStatusCodeDecision::Stop,
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Continue
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
+pub enum IndexStatusCodeDecision {
+    Continue,
+    Stop,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SerializableStatusCode(StatusCode);
+
+impl SerializableStatusCode {
+    pub fn status_code(&self) -> &StatusCode {
+        &self.0
+    }
+}
+
+impl Serialize for SerializableStatusCode {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_u16(self.0.as_u16())
+    }
+}
+
+impl<'de> Deserialize<'de> for SerializableStatusCode {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let code = u16::deserialize(deserializer)?;
+        StatusCode::from_u16(code)
+            .map(SerializableStatusCode)
+            .map_err(|_| {
+                serde::de::Error::custom(format!("{code} is not a valid HTTP status code"))
+            })
+    }
+}
+
+#[cfg(feature = "schemars")]
+impl schemars::JsonSchema for SerializableStatusCode {
+    fn schema_name() -> String {
+        "StatusCode".to_string()
+    }
+
+    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
+        let mut schema = gen.subschema_for::<u16>().into_object();
+        schema.metadata().description = Some("HTTP status code (100-599)".to_string());
+        schema.number().minimum = Some(100.0);
+        schema.number().maximum = Some(599.0);
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: zanieb
body: Ah it seems nice to have this in the schema
created_at: 2025-04-18T21:15:02Z
author_association: MEMBER
diff_hunk: @@ -10509,15 +10657,12 @@ fn add_auth_policy_never_with_env_var_credentials() -> Result<()> {
         .env("UV_INDEX_MY_INDEX_USERNAME", "public")
         .env("UV_INDEX_MY_INDEX_PASSWORD", "heron"), @r"
     success: false
-    exit_code: 1
+    exit_code: 2
     ----- stdout -----
 
     ----- stderr -----
-      × No solution found when resolving dependencies:
-      ╰─▶ Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable.
-
-          hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
-      help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
+    error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/simple/anyio/`
+      Caused by: HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/simple/anyio/)
path: crates/uv/tests/it/edit.rs
user: login: zanieb
body: This looks like a regression, e.g., that `help: ` message is gone.

This is an example of what I was concerned about (e.g., over in https://github.com/astral-sh/uv/pull/12667#discussion_r2029115491) by exiting eagerly instead of relying existing error handling.
created_at: 2025-04-18T21:16:48Z
author_association: MEMBER
diff_hunk: @@ -7517,16 +7517,15 @@ fn lock_index_workspace_member() -> Result<()> {
     )?;
 
     // Locking without the necessary credentials should fail.
-    uv_snapshot!(context.filters(), context.lock(), @r###"
+    uv_snapshot!(context.filters(), context.lock(), @r"
     success: false
-    exit_code: 1
+    exit_code: 2
     ----- stdout -----
 
     ----- stderr -----
-      × No solution found when resolving dependencies:
-      ╰─▶ Because iniconfig was not found in the package registry and child depends on iniconfig>=2, we can conclude that child's requirements are unsatisfiable.
-          And because your workspace requires child, we can conclude that your workspace's requirements are unsatisfiable.
-    "###);
+    error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/`
+      Caused by: HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/)
path: crates/uv/tests/it/lock.rs
user: login: zanieb
body: This also looks like a regression, we no longer are explaining _why_ `iniconfig` was required (though it _is_ an improvement that we say which index we failed to fetch it from — I think that's just the missing hint I flagged in https://github.com/astral-sh/uv/pull/12667#discussion_r2029183230 though).
created_at: 2025-04-18T21:18:05Z
author_association: MEMBER
diff_hunk: @@ -211,6 +211,25 @@ credentials cannot be found. If the discovered credentials are not valid (i.e.,
 HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as
 described in the [index strategy](#searching-across-multiple-indexes) section.
 
+### Ignoring error codes when searching across indexes
+
+When using the `first-index` strategy, uv will stop searching if it encounters a `401 Unauthorized`
path: docs/configuration/indexes.md
user: login: zanieb
body: I'd link to the strategy here, if we can
created_at: 2025-04-18T21:19:45Z
author_association: MEMBER
diff_hunk: @@ -211,6 +211,25 @@ credentials cannot be found. If the discovered credentials are not valid (i.e.,
 HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as
 described in the [index strategy](#searching-across-multiple-indexes) section.
 
+### Ignoring error codes when searching across indexes
+
+When using the `first-index` strategy, uv will stop searching if it encounters a `401 Unauthorized`
+or `403 Forbidden` response status code. The one exception is that uv will ignore 403s when
path: docs/configuration/indexes.md
user: login: zanieb
body: Elsewhere, we stylize error codes as "HTTP XXX" instead of "`XXX NAME`". I do think the name is nice, I might do "HTTP 403 Forbidden" though? I'd probably omit the backticks, since you refer to the error codes in the subsequent text without them.

You can probably just say "status code" instead of "response status code".

Generally, I'd avoid using "it" to refer to uv if it's easy. So... "uv will stop searching if a ... is encountered" rather than "uv will stop searching if it encounters ...".


created_at: 2025-04-18T21:23:27Z
author_association: MEMBER
diff_hunk: @@ -211,6 +211,25 @@ credentials cannot be found. If the discovered credentials are not valid (i.e.,
 HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as
 described in the [index strategy](#searching-across-multiple-indexes) section.
 
+### Ignoring error codes when searching across indexes
+
+When using the `first-index` strategy, uv will stop searching if it encounters a `401 Unauthorized`
+or `403 Forbidden` response status code. The one exception is that uv will ignore 403s when
+searching the `pytorch` index (since this index returns a 403 when a package is not present).
+
+Users can configure which error codes are ignored for an index, using the `ignored-error-codes`
path: docs/configuration/indexes.md
user: login: zanieb
body: We avoid referring to "Users" like this in the documentation. Instead, I'd say "You can configure which error codes..." or, more typically, "To ignore additional error codes for an index, use the `...` setting."
created_at: 2025-04-18T21:24:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,255 @@
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.ends_with("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: status_codes
+                .iter()
+                .map(SerializableStatusCode::status_code)
+                .copied()
+                .collect::<FxHashSet<_>>(),
+        }
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        capabilities.set_by_status_code(status_code, index_url);
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Continue,
+                StatusCode::UNAUTHORIZED => IndexStatusCodeDecision::Stop,
+                StatusCode::FORBIDDEN => IndexStatusCodeDecision::Stop,
+                _ => IndexStatusCodeDecision::Stop,
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Continue
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
+pub enum IndexStatusCodeDecision {
+    Continue,
+    Stop,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SerializableStatusCode(StatusCode);
+
+impl SerializableStatusCode {
+    pub fn status_code(&self) -> &StatusCode {
+        &self.0
+    }
+}
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: jtfmumm
body: I’m pretty sure you can’t match against e.g. StatusCode::FORBIDDEN that way (which is what we need this for)
created_at: 2025-04-18T21:37:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.ends_with("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: status_codes
+                .iter()
+                .map(SerializableStatusCode::status_code)
+                .copied()
+                .collect::<FxHashSet<_>>(),
+        }
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        capabilities.set_by_status_code(status_code, index_url);
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Continue,
+                StatusCode::UNAUTHORIZED => IndexStatusCodeDecision::Stop,
+                StatusCode::FORBIDDEN => IndexStatusCodeDecision::Stop,
+                _ => IndexStatusCodeDecision::Stop,
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Continue
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
+pub enum IndexStatusCodeDecision {
+    Continue,
+    Stop,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SerializableStatusCode(StatusCode);
+
+impl SerializableStatusCode {
+    pub fn status_code(&self) -> &StatusCode {
+        &self.0
+    }
+}
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: zanieb
body: Wouldn't you just deref it where you're currently calling this method?
created_at: 2025-04-18T21:43:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,255 @@
+use http::StatusCode;
+use rustc_hash::FxHashSet;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use url::Url;
+
+use crate::{IndexCapabilities, IndexUrl};
+
+#[derive(Debug, Clone, Default, Eq, PartialEq)]
+pub enum IndexStatusCodeStrategy {
+    #[default]
+    Default,
+    IgnoreErrorCodes {
+        status_codes: FxHashSet<StatusCode>,
+    },
+}
+
+impl IndexStatusCodeStrategy {
+    /// Derive a strategy from an index URL. We special-case PyTorch. Otherwise,
+    /// we follow the default strategy.
+    pub fn from_index_url(url: &Url) -> Self {
+        if url
+            .host_str()
+            .is_some_and(|host| host.ends_with("pytorch.org"))
+        {
+            // The PyTorch registry returns a 403 when a package is not found, so
+            // we ignore them when deciding whether to search other indexes.
+            Self::IgnoreErrorCodes {
+                status_codes: FxHashSet::from_iter([StatusCode::FORBIDDEN]),
+            }
+        } else {
+            Self::Default
+        }
+    }
+
+    /// Derive a strategy from a list of status codes to ignore.
+    pub fn from_ignored_error_codes(status_codes: &[SerializableStatusCode]) -> Self {
+        Self::IgnoreErrorCodes {
+            status_codes: status_codes
+                .iter()
+                .map(SerializableStatusCode::status_code)
+                .copied()
+                .collect::<FxHashSet<_>>(),
+        }
+    }
+
+    /// Based on the strategy, decide whether to continue searching the next index
+    /// based on the status code returned by this one.
+    pub fn handle_status_code(
+        &self,
+        status_code: StatusCode,
+        index_url: &IndexUrl,
+        capabilities: &IndexCapabilities,
+    ) -> IndexStatusCodeDecision {
+        capabilities.set_by_status_code(status_code, index_url);
+        match self {
+            IndexStatusCodeStrategy::Default => match status_code {
+                StatusCode::NOT_FOUND => IndexStatusCodeDecision::Continue,
+                StatusCode::UNAUTHORIZED => IndexStatusCodeDecision::Stop,
+                StatusCode::FORBIDDEN => IndexStatusCodeDecision::Stop,
+                _ => IndexStatusCodeDecision::Stop,
+            },
+            IndexStatusCodeStrategy::IgnoreErrorCodes { status_codes } => {
+                if status_codes.contains(&status_code) {
+                    IndexStatusCodeDecision::Continue
+                } else {
+                    IndexStatusCodeStrategy::Default.handle_status_code(
+                        status_code,
+                        index_url,
+                        capabilities,
+                    )
+                }
+            }
+        }
+    }
+}
+
+/// Decision on whether to continue searching the next index.
+#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
+pub enum IndexStatusCodeDecision {
+    Continue,
+    Stop,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SerializableStatusCode(StatusCode);
+
+impl SerializableStatusCode {
+    pub fn status_code(&self) -> &StatusCode {
+        &self.0
+    }
+}
path: crates/uv-distribution-types/src/status_code_strategy.rs
user: login: jtfmumm
body: Oh yeah, I was thinking of the transparent deref, but you're right you can just call `deref()`. Updated
created_at: 2025-04-19T07:45:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -10509,15 +10657,12 @@ fn add_auth_policy_never_with_env_var_credentials() -> Result<()> {
         .env("UV_INDEX_MY_INDEX_USERNAME", "public")
         .env("UV_INDEX_MY_INDEX_PASSWORD", "heron"), @r"
     success: false
-    exit_code: 1
+    exit_code: 2
     ----- stdout -----
 
     ----- stderr -----
-      × No solution found when resolving dependencies:
-      ╰─▶ Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable.
-
-          hint: An index URL (https://pypi-proxy.fly.dev/basic-auth/simple) could not be queried due to a lack of valid authentication credentials (401 Unauthorized).
-      help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing.
+    error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/simple/anyio/`
+      Caused by: HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/simple/anyio/)
path: crates/uv/tests/it/edit.rs
user: login: jtfmumm
body: I've created a PR off this branch to address this problem (see #13016). 
created_at: 2025-04-21T09:10:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7517,16 +7517,15 @@ fn lock_index_workspace_member() -> Result<()> {
     )?;
 
     // Locking without the necessary credentials should fail.
-    uv_snapshot!(context.filters(), context.lock(), @r###"
+    uv_snapshot!(context.filters(), context.lock(), @r"
     success: false
-    exit_code: 1
+    exit_code: 2
     ----- stdout -----
 
     ----- stderr -----
-      × No solution found when resolving dependencies:
-      ╰─▶ Because iniconfig was not found in the package registry and child depends on iniconfig>=2, we can conclude that child's requirements are unsatisfiable.
-          And because your workspace requires child, we can conclude that your workspace's requirements are unsatisfiable.
-    "###);
+    error: Failed to fetch: `https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/`
+      Caused by: HTTP status client error (401 Unauthorized) for url (https://pypi-proxy.fly.dev/basic-auth/simple/iniconfig/)
path: crates/uv/tests/it/lock.rs
user: login: jtfmumm
body: Addressed in #13016
created_at: 2025-04-22T14:53:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -517,52 +472,149 @@ async fn show_help(
     Ok(())
 }
 
-/// Display a warning if an executable is not provided by package.
-///
-/// If found in a dependency of the requested package instead of the requested package itself, we will hint to use that instead.
-fn warn_executable_not_provided_by_package(
-    executable: &str,
-    from_package: &PackageName,
-    site_packages: &SitePackages,
+/// A set of hints about the packages that provide an executable.
+#[derive(Debug)]
+struct ExecutableProviderHints<'a> {
path: crates/uv/src/commands/tool/run.rs
user: login: charliermarsh
body: Are these changes intended as part of this PR?
created_at: 2025-04-25T02:46:40Z
author_association: MEMBER
diff_hunk: @@ -17,8 +17,8 @@ type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 pub struct CredentialsCache {
     /// A cache per realm and username
     realms: RwLock<FxHashMap<(Realm, Username), Arc<Credentials>>>,
-    /// A cache tracking the result of fetches from external services
-    pub(crate) fetches: FxOnceMap<(Realm, Username), Option<Arc<Credentials>>>,
+    /// A cache tracking the result of realm or index URL fetches from external services
+    pub(crate) fetches: FxOnceMap<(String, Username), Option<Arc<Credentials>>>,
path: crates/uv-auth/src/cache.rs
user: login: charliermarsh
body: As a point of data modeling, I think this should be an enum, not a string -- is that possible? I recommend always approaching `String` with skepticism by default, since casting down to `String` means you've lost ~all type safety and type information. If we know this is either a realm or an index URL, then it makes sense to model the data that way, right?
created_at: 2025-04-25T02:48:46Z
author_association: MEMBER
diff_hunk: @@ -374,6 +384,7 @@ impl AuthMiddleware {
         extensions: &mut Extensions,
         next: Next<'_>,
         url: &str,
+        maybe_index_url: Option<&Url>,
path: crates/uv-auth/src/middleware.rs
user: login: charliermarsh
body: Personally, I'd just call this `index_url`, since `maybe` is implied by the type.
created_at: 2025-04-25T02:50:03Z
author_association: MEMBER
diff_hunk: @@ -363,9 +372,20 @@ impl RegistryClient {
                         let _permit = download_concurrency.acquire().await;
                         match index.format {
                             IndexFormat::Simple => {
-                                let metadata = self
-                                    .simple_single_index(package_name, index.url, capabilities)
-                                    .await?;
+                                let status_code_strategy =
+                                    IndexStatusCodeStrategy::ignore_authentication_error_codes();
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: Why is this not `status_code_strategy_for`?
created_at: 2025-04-25T02:50:50Z
author_association: MEMBER
diff_hunk: @@ -966,6 +981,22 @@ impl RegistryClient {
     }
 }
 
+#[derive(Debug)]
+pub(crate) enum SimpleMetadataSearchOutcome {
+    Found(OwnedArchive<SimpleMetadata>),
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: I think we should document these variants, do you mind?
created_at: 2025-04-25T02:51:21Z
author_association: MEMBER
diff_hunk: @@ -337,12 +332,26 @@ impl RegistryClient {
                     let _permit = download_concurrency.acquire().await;
                     match index.format {
                         IndexFormat::Simple => {
-                            if let Some(metadata) = self
-                                .simple_single_index(package_name, index.url, capabilities)
+                            let status_code_strategy =
+                                self.index_urls.status_code_strategy_for(index.url);
+                            match self
+                                .simple_single_index(
+                                    package_name,
+                                    index.url,
+                                    capabilities,
+                                    &status_code_strategy,
+                                )
                                 .await?
                             {
-                                results.push((index.url, MetadataFormat::Simple(metadata)));
-                                break;
+                                SimpleMetadataSearchOutcome::Found(metadata) => {
+                                    results.push((index.url, MetadataFormat::Simple(metadata)));
+                                    break;
+                                }
+                                // Package not found, so we will continue on to the next index (if there is one)
+                                SimpleMetadataSearchOutcome::NotFound => {}
+                                // The search failed because of an HTTP status code that we don't ignore for
+                                // this index. We end our search here.
+                                SimpleMetadataSearchOutcome::StatusCodeFailure => break,
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: Should this variant include the status code? Should we log this interaction here?
created_at: 2025-04-25T02:52:30Z
author_association: MEMBER
diff_hunk: @@ -493,27 +512,23 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
-                    }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                ErrorKind::WrappedReqwestError(url, err) => {
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: Do we now lose this error? Does it ever get surfaced to the user?
created_at: 2025-04-25T02:54:22Z
author_association: MEMBER
diff_hunk: @@ -337,12 +332,26 @@ impl RegistryClient {
                     let _permit = download_concurrency.acquire().await;
                     match index.format {
                         IndexFormat::Simple => {
-                            if let Some(metadata) = self
-                                .simple_single_index(package_name, index.url, capabilities)
+                            let status_code_strategy =
+                                self.index_urls.status_code_strategy_for(index.url);
+                            match self
+                                .simple_single_index(
+                                    package_name,
+                                    index.url,
+                                    capabilities,
+                                    &status_code_strategy,
+                                )
                                 .await?
                             {
-                                results.push((index.url, MetadataFormat::Simple(metadata)));
-                                break;
+                                SimpleMetadataSearchOutcome::Found(metadata) => {
+                                    results.push((index.url, MetadataFormat::Simple(metadata)));
+                                    break;
+                                }
+                                // Package not found, so we will continue on to the next index (if there is one)
+                                SimpleMetadataSearchOutcome::NotFound => {}
+                                // The search failed because of an HTTP status code that we don't ignore for
+                                // this index. We end our search here.
+                                SimpleMetadataSearchOutcome::StatusCodeFailure => break,
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: Having trouble following a bit, how does this error propagate upwards?
created_at: 2025-04-25T02:54:40Z
author_association: MEMBER
diff_hunk: @@ -211,6 +211,26 @@ credentials cannot be found. If the discovered credentials are not valid (i.e.,
 HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as
 described in the [index strategy](#searching-across-multiple-indexes) section.
 
+### Ignoring error codes when searching across indexes
+
+When using the [first-index strategy](#searching-across-multiple-indexes), uv will stop searching if
+an HTTP 401 Unauthorized or HTTP 403 Forbidden status code is encountered. The one exception is that
path: docs/configuration/indexes.md
user: login: charliermarsh
body: What does "stop searching" mean, for a user? Similarly, what does it mean to "keep" searching?
created_at: 2025-04-25T02:56:15Z
author_association: MEMBER
diff_hunk: @@ -517,52 +472,149 @@ async fn show_help(
     Ok(())
 }
 
-/// Display a warning if an executable is not provided by package.
-///
-/// If found in a dependency of the requested package instead of the requested package itself, we will hint to use that instead.
-fn warn_executable_not_provided_by_package(
-    executable: &str,
-    from_package: &PackageName,
-    site_packages: &SitePackages,
+/// A set of hints about the packages that provide an executable.
+#[derive(Debug)]
+struct ExecutableProviderHints<'a> {
path: crates/uv/src/commands/tool/run.rs
user: login: jtfmumm
body: No, just needed to rebase against the newly rebased `release/070`
created_at: 2025-04-25T07:25:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -363,9 +372,20 @@ impl RegistryClient {
                         let _permit = download_concurrency.acquire().await;
                         match index.format {
                             IndexFormat::Simple => {
-                                let metadata = self
-                                    .simple_single_index(package_name, index.url, capabilities)
-                                    .await?;
+                                let status_code_strategy =
+                                    IndexStatusCodeStrategy::ignore_authentication_error_codes();
path: crates/uv-client/src/registry_client.rs
user: login: jtfmumm
body: I've added a comment. For unsafe index strategies, we ignore authentication failures and just find whatever matches we can across the indexes.
created_at: 2025-04-25T07:43:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -337,12 +332,26 @@ impl RegistryClient {
                     let _permit = download_concurrency.acquire().await;
                     match index.format {
                         IndexFormat::Simple => {
-                            if let Some(metadata) = self
-                                .simple_single_index(package_name, index.url, capabilities)
+                            let status_code_strategy =
+                                self.index_urls.status_code_strategy_for(index.url);
+                            match self
+                                .simple_single_index(
+                                    package_name,
+                                    index.url,
+                                    capabilities,
+                                    &status_code_strategy,
+                                )
                                 .await?
                             {
-                                results.push((index.url, MetadataFormat::Simple(metadata)));
-                                break;
+                                SimpleMetadataSearchOutcome::Found(metadata) => {
+                                    results.push((index.url, MetadataFormat::Simple(metadata)));
+                                    break;
+                                }
+                                // Package not found, so we will continue on to the next index (if there is one)
+                                SimpleMetadataSearchOutcome::NotFound => {}
+                                // The search failed because of an HTTP status code that we don't ignore for
+                                // this index. We end our search here.
+                                SimpleMetadataSearchOutcome::StatusCodeFailure => break,
path: crates/uv-client/src/registry_client.rs
user: login: jtfmumm
body: > Having trouble following a bit, how does this error propagate upwards?

It's the same as before. On a 401 or 403 that our strategy does not ignore, we set that in the `IndexCapabilities`. When you break here, that effectively ends the search and a `PackageNotFound` error will be returned at the end of this function.
created_at: 2025-04-25T07:47:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -493,27 +512,23 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
-                    }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                ErrorKind::WrappedReqwestError(url, err) => {
path: crates/uv-client/src/registry_client.rs
user: login: jtfmumm
body: It no longer gets surfaced to the user if it's an error with a response status code. That's because the new interface gives the user the ability to ignore any status code they want. 

But now I've updated it so that if the strategy check fails on anything other than a 401 or 403, we just return the original error. This still provides the flexibility but preserves the old error information.
created_at: 2025-04-25T08:03:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -211,6 +211,26 @@ credentials cannot be found. If the discovered credentials are not valid (i.e.,
 HTTP 401 or 403), then uv will treat packages as unavailable and query the next configured index as
 described in the [index strategy](#searching-across-multiple-indexes) section.
 
+### Ignoring error codes when searching across indexes
+
+When using the [first-index strategy](#searching-across-multiple-indexes), uv will stop searching if
+an HTTP 401 Unauthorized or HTTP 403 Forbidden status code is encountered. The one exception is that
path: docs/configuration/indexes.md
user: login: jtfmumm
body: I thought it was clear from the section title, but I'll make it more explicit.

It now reads "stop searching across indexes" and "continue searching across indexes"
created_at: 2025-04-25T08:05:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,8 +17,8 @@ type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 pub struct CredentialsCache {
     /// A cache per realm and username
     realms: RwLock<FxHashMap<(Realm, Username), Arc<Credentials>>>,
-    /// A cache tracking the result of fetches from external services
-    pub(crate) fetches: FxOnceMap<(Realm, Username), Option<Arc<Credentials>>>,
+    /// A cache tracking the result of realm or index URL fetches from external services
+    pub(crate) fetches: FxOnceMap<(String, Username), Option<Arc<Credentials>>>,
path: crates/uv-auth/src/cache.rs
user: login: jtfmumm
body: Updated
created_at: 2025-04-25T08:48:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -493,27 +512,23 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
-                    }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                ErrorKind::WrappedReqwestError(url, err) => {
path: crates/uv-client/src/registry_client.rs
user: login: jtfmumm
body: I've also added a regression test for this case.
created_at: 2025-04-25T09:39:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,11 +15,26 @@ use crate::Realm;
 
 type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum FetchUrl {
path: crates/uv-auth/src/cache.rs
user: login: charliermarsh
body: Rustdoc please for the variants.
created_at: 2025-04-29T13:24:40Z
author_association: MEMBER
diff_hunk: @@ -14,11 +15,26 @@ use crate::Realm;
 
 type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum FetchUrl {
+    Index(Url),
+    Realm(Realm),
+}
+
+impl Display for FetchUrl {
+    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
+        match self {
+            FetchUrl::Index(index) => Display::fmt(index, f),
+            FetchUrl::Realm(realm) => Display::fmt(realm, f),
path: crates/uv-auth/src/cache.rs
user: login: charliermarsh
body: Nit: `Self` instead of `FetchUrl`
created_at: 2025-04-29T13:24:48Z
author_association: MEMBER
diff_hunk: @@ -60,6 +60,23 @@ pub struct Index {
     pub auth_policy: AuthPolicy,
 }
 
+impl Index {
+    pub fn is_prefix_for(&self, url: &Url) -> bool {
path: crates/uv-auth/src/index.rs
user: login: charliermarsh
body: Nit: Rustdoc please with an example of when this returns `true` vs. `false`.
created_at: 2025-04-29T13:25:28Z
author_association: MEMBER
diff_hunk: @@ -493,27 +512,23 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
-                    }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                ErrorKind::WrappedReqwestError(url, err) => {
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: I'm still struggling a bit. Why do we break when (e.g.) we hit a 401, but 401 isn't included in the list of ignored errors? Why is it not something like this?

```diff
diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs
index 8bdf2c065..3cb6b4978 100644
--- a/crates/uv-client/src/registry_client.rs
+++ b/crates/uv-client/src/registry_client.rs
@@ -348,12 +348,6 @@ impl RegistryClient {
                                 }
                                 // Package not found, so we will continue on to the next index (if there is one)
                                 SimpleMetadataSearchOutcome::NotFound => {}
-                                // The search failed because of an HTTP status code that we don't ignore for
-                                // this index. We end our search here.
-                                SimpleMetadataSearchOutcome::StatusCodeFailure(status_code) => {
-                                    debug!("Indexes search failed because of status code failure: {status_code}");
-                                    break;
-                                }
                             }
                         }
                         IndexFormat::Flat => {
@@ -522,17 +516,15 @@ impl RegistryClient {
                     let Some(status_code) = err.status() else {
                         return Err(ErrorKind::WrappedReqwestError(url, err).into());
                     };
-                    let decision =
-                        status_code_strategy.handle_status_code(status_code, index, capabilities);
-                    if let IndexStatusCodeDecision::Fail(status_code) = decision {
-                        if !matches!(
-                            status_code,
-                            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
-                        ) {
-                            return Err(ErrorKind::WrappedReqwestError(url, err).into());
+                    match status_code_strategy.handle_status_code(status_code, index, capabilities)
+                    {
+                        IndexStatusCodeDecision::Ignore => {
+                            Ok(SimpleMetadataSearchOutcome::NotFound)
+                        }
+                        IndexStatusCodeDecision::Fail(..) => {
+                            Err(ErrorKind::WrappedReqwestError(url, err).into())
                         }
                     }
-                    Ok(SimpleMetadataSearchOutcome::from(decision))
                 }
 
                 // The package is unavailable due to a lack of connectivity.
@@ -998,18 +990,6 @@ pub(crate) enum SimpleMetadataSearchOutcome {
     Found(OwnedArchive<SimpleMetadata>),
     /// Simple metadata was not found
     NotFound,
-    /// A status code failure was encountered when searching for
-    /// simple metadata and our strategy did not ignore it
-    StatusCodeFailure(StatusCode),
-}
-
-impl From<IndexStatusCodeDecision> for SimpleMetadataSearchOutcome {
-    fn from(item: IndexStatusCodeDecision) -> Self {
-        match item {
-            IndexStatusCodeDecision::Ignore => Self::NotFound,
-            IndexStatusCodeDecision::Fail(status_code) => Self::StatusCodeFailure(status_code),
-        }
-    }
 }
 
 /// A map from [`IndexUrl`] to [`FlatIndexEntry`] entries found at the given URL, indexed by
```
created_at: 2025-04-29T13:36:49Z
author_association: MEMBER
diff_hunk: @@ -493,27 +512,23 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
-                    }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                ErrorKind::WrappedReqwestError(url, err) => {
path: crates/uv-client/src/registry_client.rs
user: login: jtfmumm
body: Because if we do it that way, we lose the richer error messages from the resolver. I actually originally did it the way you suggest, but @zanieb pointed out that there were regressions in the amount of information the user was getting. 
created_at: 2025-04-29T13:50:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -493,27 +512,23 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
-                    }
-                    _ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
-                },
+                ErrorKind::WrappedReqwestError(url, err) => {
path: crates/uv-client/src/registry_client.rs
user: login: jtfmumm
body: This is sort of like the original behavior of returning `Ok(None)` except that it also stops the search from going to the next index.
created_at: 2025-04-29T13:51:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,6 +60,23 @@ pub struct Index {
     pub auth_policy: AuthPolicy,
 }
 
+impl Index {
+    pub fn is_prefix_for(&self, url: &Url) -> bool {
path: crates/uv-auth/src/index.rs
user: login: jtfmumm
body: Because of the timing of the release, can we defer this to a PR afterwards? I just moved this logic from a function to a method in this PR.
created_at: 2025-04-29T13:54:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,11 +15,26 @@ use crate::Realm;
 
 type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum FetchUrl {
path: crates/uv-auth/src/cache.rs
user: login: jtfmumm
body: Added
created_at: 2025-04-29T13:57:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,11 +15,26 @@ use crate::Realm;
 
 type FxOnceMap<K, V> = OnceMap<K, V, BuildHasherDefault<FxHasher>>;
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum FetchUrl {
+    Index(Url),
+    Realm(Realm),
+}
+
+impl Display for FetchUrl {
+    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
+        match self {
+            FetchUrl::Index(index) => Display::fmt(index, f),
+            FetchUrl::Realm(realm) => Display::fmt(realm, f),
path: crates/uv-auth/src/cache.rs
user: login: jtfmumm
body: Updated
created_at: 2025-04-29T13:58:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -331,12 +331,29 @@ impl RegistryClient {
                     let _permit = download_concurrency.acquire().await;
                     match index.format {
                         IndexFormat::Simple => {
-                            if let Some(metadata) = self
-                                .simple_single_index(package_name, index.url, capabilities)
+                            let status_code_strategy =
+                                self.index_urls.status_code_strategy_for(index.url);
+                            match self
+                                .simple_single_index(
+                                    package_name,
+                                    index.url,
+                                    capabilities,
+                                    &status_code_strategy,
+                                )
                                 .await?
                             {
-                                results.push((index.url, MetadataFormat::Simple(metadata)));
-                                break;
+                                SimpleMetadataSearchOutcome::Found(metadata) => {
+                                    results.push((index.url, MetadataFormat::Simple(metadata)));
+                                    break;
+                                }
+                                // Package not found, so we will continue on to the next index (if there is one)
+                                SimpleMetadataSearchOutcome::NotFound => {}
+                                // The search failed because of an HTTP status code that we don't ignore for
+                                // this index. We end our search here.
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: I think this should mention the assumption that the status code failure will be tracked by `IndexCapabilities` and surfaced via hints.
created_at: 2025-04-29T15:40:52Z
author_association: MEMBER
diff_hunk: @@ -487,27 +515,31 @@ impl RegistryClient {
         };
 
         match result {
-            Ok(metadata) => Ok(Some(metadata)),
+            Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
             Err(err) => match err.into_kind() {
                 // The package could not be found in the remote index.
-                ErrorKind::WrappedReqwestError(url, err) => match err.status() {
-                    Some(StatusCode::NOT_FOUND) => Ok(None),
-                    Some(StatusCode::UNAUTHORIZED) => {
-                        capabilities.set_unauthorized(index.clone());
-                        Ok(None)
-                    }
-                    Some(StatusCode::FORBIDDEN) => {
-                        capabilities.set_forbidden(index.clone());
-                        Ok(None)
+                ErrorKind::WrappedReqwestError(url, err) => {
+                    let Some(status_code) = err.status() else {
+                        return Err(ErrorKind::WrappedReqwestError(url, err).into());
+                    };
+                    let decision =
+                        status_code_strategy.handle_status_code(status_code, index, capabilities);
+                    if let IndexStatusCodeDecision::Fail(status_code) = decision {
+                        if !matches!(
+                            status_code,
+                            StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: I think this should mention that these specific codes are tracked via `IndexCapabilities` and surfaced via hints; otherwise, it's not really clear why these _specific_ codes are ignored.
created_at: 2025-04-29T15:41:21Z
author_association: MEMBER
diff_hunk: @@ -960,6 +992,26 @@ impl RegistryClient {
     }
 }
 
+#[derive(Debug)]
+pub(crate) enum SimpleMetadataSearchOutcome {
+    /// Simple metadata was found
+    Found(OwnedArchive<SimpleMetadata>),
+    /// Simple metadata was not found
+    NotFound,
+    /// A status code failure was encountered when searching for
+    /// simple metadata and our strategy did not ignore it
+    StatusCodeFailure(StatusCode),
path: crates/uv-client/src/registry_client.rs
user: login: charliermarsh
body: I think I would find it clearer if we actually just made individual enum variants for Unauthorized and Forbidden, rather than allowing an arbitrary status code here. That way the expectations are encoded in the type system. We should make the invalid state (e.g., `StatusCodeFailure(400)`) unrepresentable.
created_at: 2025-04-29T15:42:23Z
author_association: MEMBER

PR comments:
user: login: jtfmumm
created_at: 2025-04-18T08:28:00Z
body: @zanieb I've created a wrapper type for `StatusCode` that implements `JsonSchema` and `De/Serialize` on [this commit](https://github.com/astral-sh/uv/pull/12805/commits/4da1534f68bf482714232db449877aa855db774c).
author_association: CONTRIBUTOR
user: login: jtfmumm
created_at: 2025-04-22T14:59:55Z
body: @zanieb Now that #13016 is merged into this PR and I've rebased against `release/070`, I don't think there is unaddressed feedback.
author_association: CONTRIBUTOR
user: login: zanieb
created_at: 2025-04-29T17:48:51Z
body: I'm going to merge and kick the tires on the release.
author_association: MEMBER
user: login: juhaszp95
created_at: 2025-04-30T08:22:20Z
body: Hi There,

Just a quick question on the above - I have an internal package index which also returns 403 for missing packages. Is it possible to make the `ignore-error-codes` a user-wide setting by somehow incorporating it in `uv.toml`? I.e. to declare custom exceptions user-wide.

Furthermore, it seems to me that the workaround in `pyproject.toml` only works if I include the authentication token as well in the url, which is obviously undesirable.
author_association: NONE
user: login: jtfmumm
created_at: 2025-04-30T08:46:24Z
body: > Hi There,
> 
> Just a quick question on the above - I have an internal package index which also returns 403 for missing packages. Is it possible to make the `ignore-error-codes` a user-wide setting by somehow incorporating it in `uv.toml`? I.e. to declare custom exceptions user-wide.
> 
> Furthermore, it seems to me that the workaround in `pyproject.toml` only works if I include the authentication token as well in the url, which is obviously undesirable.

You shouldn't need to include a token in the URL (and I agree you shouldn't include one in `pyproject.toml`). Did you run into an issue when excluding it?
author_association: CONTRIBUTOR
user: login: juhaszp95
created_at: 2025-04-30T09:12:44Z
body: Yes - interestingly, I get the error `An index URL (https://myorg.jfrog.io/artifactory/api/pypi/pypi/simple) could not be queried due to a lack of valid authentication credentials (403 Forbidden).`, even though I set `ignore-error-codes = [401, 403]`. This index is set as an `extra-index-url` in `uv.toml`. This doesn't seem to be an issue for the other internal index I use, which is set as the `index-url` in `uv.toml`.
author_association: NONE
user: login: jtfmumm
created_at: 2025-04-30T09:23:08Z
body: Ok, would you mind opening an issue and including a reproduction and trace (`-vv`) logs? I can investigate this.
author_association: CONTRIBUTOR
user: login: juhaszp95
created_at: 2025-04-30T09:25:51Z
body: Should I put it [here](https://github.com/astral-sh/uv/issues/13216) as it seems closely related, or would you rather a separate issue?
author_association: NONE
user: login: jtfmumm
created_at: 2025-04-30T09:28:35Z
body: I think a separate issue for now would be helpful. It's not clear to me these are the same problem.
author_association: CONTRIBUTOR
user: login: juhaszp95
created_at: 2025-04-30T10:14:23Z
body: I've realised what the problem is just now (and it's sort of OK): as I was debugging, I left the `url` in the format `USER@ORG@repository-url` which works for `extra-index-url` but strangely does not for `index-url`. When I removed the `USER@ORG` part it worked again, so that's all solved I believe! I have no idea why it behaves differently for `index-url` and `extra-index-url`, but at least it works for both when there aren't really any credentials there (i.e. not just the token deleted, but everything). Also, interestingly, I need to put the `index-url` in `pyproject.toml` (without any `ignore-error-codes`) for it to work - strange. It's like as if the `extra-index-url` is treated differently than the `index-url`, or perhaps if indexes are declared in `pyproject.toml`, the contents of `uv.toml` are ignored, hence the need to declare the index in `pyproject.toml` as well.

This still leaves the outstanding question whether all this config could be saved to a `uv.toml` for user-level configuration so I don't need to do this at every single project.
author_association: NONE
user: login: jtfmumm
created_at: 2025-04-30T11:16:43Z
body: Is this problem unique to 0.7.0 or do you also see it on 0.6.17? The `ignore-error-codes` feature doesn't exist on 0.6.17, but I'm trying to understand if something broke with 0.7.0 or if this is just an enhancement you'd like to see to the new error code behavior. Or if the main concern is the change that we are no longer ignoring 403s by default (and you can't find a good way to configure uv to ignore them for your situation).
author_association: CONTRIBUTOR
user: login: juhaszp95
created_at: 2025-04-30T11:56:18Z
body: The problem and enhancement request is both unique to 0.7.0. Before, I had all index and authentication info saved in `uv.toml` with no issues. Now, due to how 0.7.0 handles error codes, I have to copy ignores to all projects as well. It'd be good if the ignores could be configured centrally rather than at a project level (for my use-case). I've checked 0.7.1 doesn't solve this (as expected).
author_association: NONE