Skip to content

Commit 4ca1589

Browse files
Show hint in resolution failure on Forbidden (403) or Unauthorized (401) (#8264)
## Summary Closes #8167.
1 parent 5e05a62 commit 4ca1589

File tree

12 files changed

+170
-35
lines changed

12 files changed

+170
-35
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ async_zip = { git = "https://github.com/charliermarsh/rs-async-zip", rev = "011b
7777
axoupdater = { version = "0.7.2", default-features = false }
7878
backoff = { version = "0.4.0" }
7979
base64 = { version = "0.22.1" }
80+
bitflags = { version = "2.6.0" }
8081
boxcar = { version = "0.2.5" }
8182
bytecheck = { version = "0.8.0" }
8283
cachedir = { version = "0.3.1" }

crates/uv-client/src/registry_client.rs

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,12 @@ impl RegistryClient {
202202
/// and [PEP 691 – JSON-based Simple API for Python Package Indexes](https://peps.python.org/pep-0691/),
203203
/// which the pypi json api approximately implements.
204204
#[instrument("simple_api", skip_all, fields(package = % package_name))]
205-
pub async fn simple(
206-
&self,
205+
pub async fn simple<'index>(
206+
&'index self,
207207
package_name: &PackageName,
208-
index: Option<&IndexUrl>,
209-
) -> Result<Vec<(IndexUrl, OwnedArchive<SimpleMetadata>)>, Error> {
208+
index: Option<&'index IndexUrl>,
209+
capabilities: &IndexCapabilities,
210+
) -> Result<Vec<(&'index IndexUrl, OwnedArchive<SimpleMetadata>)>, Error> {
210211
let indexes = if let Some(index) = index {
211212
Either::Left(std::iter::once(index))
212213
} else {
@@ -222,30 +223,31 @@ impl RegistryClient {
222223
for index in it {
223224
match self.simple_single_index(package_name, index).await {
224225
Ok(metadata) => {
225-
results.push((index.clone(), metadata));
226+
results.push((index, metadata));
226227

227228
// If we're only using the first match, we can stop here.
228229
if self.index_strategy == IndexStrategy::FirstIndex {
229230
break;
230231
}
231232
}
232233
Err(err) => match err.into_kind() {
233-
// The package is unavailable due to a lack of connectivity.
234-
ErrorKind::Offline(_) => continue,
235-
236234
// The package could not be found in the remote index.
237-
ErrorKind::WrappedReqwestError(err) => {
238-
if err.status() == Some(StatusCode::NOT_FOUND)
239-
|| err.status() == Some(StatusCode::UNAUTHORIZED)
240-
|| err.status() == Some(StatusCode::FORBIDDEN)
241-
{
242-
continue;
235+
ErrorKind::WrappedReqwestError(err) => match err.status() {
236+
Some(StatusCode::NOT_FOUND) => {}
237+
Some(StatusCode::UNAUTHORIZED) => {
238+
capabilities.set_unauthorized(index.clone());
243239
}
244-
return Err(ErrorKind::from(err).into());
245-
}
240+
Some(StatusCode::FORBIDDEN) => {
241+
capabilities.set_forbidden(index.clone());
242+
}
243+
_ => return Err(ErrorKind::from(err).into()),
244+
},
245+
246+
// The package is unavailable due to a lack of connectivity.
247+
ErrorKind::Offline(_) => {}
246248

247249
// The package could not be found in the local index.
248-
ErrorKind::FileNotFound(_) => continue,
250+
ErrorKind::FileNotFound(_) => {}
249251

250252
other => return Err(other.into()),
251253
},
@@ -671,7 +673,7 @@ impl RegistryClient {
671673

672674
// Mark the index as not supporting range requests.
673675
if let Some(index) = index {
674-
capabilities.set_supports_range_requests(index.clone(), false);
676+
capabilities.set_no_range_requests(index.clone());
675677
}
676678
} else {
677679
return Err(err);

crates/uv-distribution-types/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ uv-platform-tags = { workspace = true }
2929
uv-pypi-types = { workspace = true }
3030

3131
anyhow = { workspace = true }
32+
bitflags = { workspace = true }
3233
fs-err = { workspace = true }
3334
itertools = { workspace = true }
3435
jiff = { workspace = true }

crates/uv-distribution-types/src/index_url.rs

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use itertools::Either;
2-
use rustc_hash::FxHashSet;
2+
use rustc_hash::{FxHashMap, FxHashSet};
33
use std::borrow::Cow;
44
use std::fmt::{Display, Formatter};
55
use std::ops::Deref;
@@ -393,26 +393,82 @@ impl<'a> IndexUrls {
393393
}
394394
}
395395

396+
bitflags::bitflags! {
397+
#[derive(Debug, Copy, Clone)]
398+
struct Flags: u8 {
399+
/// Whether the index supports range requests.
400+
const NO_RANGE_REQUESTS = 1;
401+
/// Whether the index returned a `401 Unauthorized` status code.
402+
const UNAUTHORIZED = 1 << 2;
403+
/// Whether the index returned a `403 Forbidden` status code.
404+
const FORBIDDEN = 1 << 1;
405+
}
406+
}
407+
396408
/// A map of [`IndexUrl`]s to their capabilities.
397409
///
398-
/// For now, we only support a single capability (range requests), and we only store an index if
399-
/// it _doesn't_ support range requests. The benefit is that the map is almost always empty, so
400-
/// validating capabilities is extremely cheap.
410+
/// We only store indexes that lack capabilities (i.e., don't support range requests, aren't
411+
/// authorized). The benefit is that the map is almost always empty, so validating capabilities is
412+
/// extremely cheap.
401413
#[derive(Debug, Default, Clone)]
402-
pub struct IndexCapabilities(Arc<RwLock<FxHashSet<IndexUrl>>>);
414+
pub struct IndexCapabilities(Arc<RwLock<FxHashMap<IndexUrl, Flags>>>);
403415

404416
impl IndexCapabilities {
405417
/// Returns `true` if the given [`IndexUrl`] supports range requests.
406418
pub fn supports_range_requests(&self, index_url: &IndexUrl) -> bool {
407-
!self.0.read().unwrap().contains(index_url)
419+
!self
420+
.0
421+
.read()
422+
.unwrap()
423+
.get(index_url)
424+
.is_some_and(|flags| flags.intersects(Flags::NO_RANGE_REQUESTS))
408425
}
409426

410427
/// Mark an [`IndexUrl`] as not supporting range requests.
411-
pub fn set_supports_range_requests(&self, index_url: IndexUrl, supports: bool) {
412-
if supports {
413-
self.0.write().unwrap().remove(&index_url);
414-
} else {
415-
self.0.write().unwrap().insert(index_url);
416-
}
428+
pub fn set_no_range_requests(&self, index_url: IndexUrl) {
429+
self.0
430+
.write()
431+
.unwrap()
432+
.entry(index_url)
433+
.or_insert(Flags::empty())
434+
.insert(Flags::NO_RANGE_REQUESTS);
435+
}
436+
437+
/// Returns `true` if the given [`IndexUrl`] returns a `401 Unauthorized` status code.
438+
pub fn unauthorized(&self, index_url: &IndexUrl) -> bool {
439+
self.0
440+
.read()
441+
.unwrap()
442+
.get(index_url)
443+
.is_some_and(|flags| flags.intersects(Flags::UNAUTHORIZED))
444+
}
445+
446+
/// Mark an [`IndexUrl`] as returning a `401 Unauthorized` status code.
447+
pub fn set_unauthorized(&self, index_url: IndexUrl) {
448+
self.0
449+
.write()
450+
.unwrap()
451+
.entry(index_url)
452+
.or_insert(Flags::empty())
453+
.insert(Flags::UNAUTHORIZED);
454+
}
455+
456+
/// Returns `true` if the given [`IndexUrl`] returns a `403 Forbidden` status code.
457+
pub fn forbidden(&self, index_url: &IndexUrl) -> bool {
458+
self.0
459+
.read()
460+
.unwrap()
461+
.get(index_url)
462+
.is_some_and(|flags| flags.intersects(Flags::FORBIDDEN))
463+
}
464+
465+
/// Mark an [`IndexUrl`] as returning a `403 Forbidden` status code.
466+
pub fn set_forbidden(&self, index_url: IndexUrl) {
467+
self.0
468+
.write()
469+
.unwrap()
470+
.entry(index_url)
471+
.or_insert(Flags::empty())
472+
.insert(Flags::FORBIDDEN);
417473
}
418474
}

crates/uv-resolver/src/error.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ use crate::resolution::ConflictingDistributionError;
1717
use crate::resolver::{IncompletePackage, ResolverMarkers, UnavailablePackage, UnavailableReason};
1818
use crate::Options;
1919
use tracing::trace;
20-
use uv_distribution_types::{BuiltDist, IndexLocations, IndexUrl, InstalledDist, SourceDist};
20+
use uv_distribution_types::{
21+
BuiltDist, IndexCapabilities, IndexLocations, IndexUrl, InstalledDist, SourceDist,
22+
};
2123
use uv_normalize::PackageName;
2224
use uv_pep440::Version;
2325
use uv_pep508::MarkerTree;
@@ -126,6 +128,7 @@ pub struct NoSolutionError {
126128
selector: CandidateSelector,
127129
python_requirement: PythonRequirement,
128130
index_locations: IndexLocations,
131+
index_capabilities: IndexCapabilities,
129132
unavailable_packages: FxHashMap<PackageName, UnavailablePackage>,
130133
incomplete_packages: FxHashMap<PackageName, BTreeMap<Version, IncompletePackage>>,
131134
fork_urls: ForkUrls,
@@ -143,6 +146,7 @@ impl NoSolutionError {
143146
selector: CandidateSelector,
144147
python_requirement: PythonRequirement,
145148
index_locations: IndexLocations,
149+
index_capabilities: IndexCapabilities,
146150
unavailable_packages: FxHashMap<PackageName, UnavailablePackage>,
147151
incomplete_packages: FxHashMap<PackageName, BTreeMap<Version, IncompletePackage>>,
148152
fork_urls: ForkUrls,
@@ -157,6 +161,7 @@ impl NoSolutionError {
157161
selector,
158162
python_requirement,
159163
index_locations,
164+
index_capabilities,
160165
unavailable_packages,
161166
incomplete_packages,
162167
fork_urls,
@@ -261,6 +266,7 @@ impl std::fmt::Display for NoSolutionError {
261266
&tree,
262267
&self.selector,
263268
&self.index_locations,
269+
&self.index_capabilities,
264270
&self.available_indexes,
265271
&self.unavailable_packages,
266272
&self.incomplete_packages,

crates/uv-resolver/src/pubgrub/report.rs

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use pubgrub::{DerivationTree, Derived, External, Map, Range, ReportFormatter, Te
99
use rustc_hash::FxHashMap;
1010

1111
use uv_configuration::IndexStrategy;
12-
use uv_distribution_types::{Index, IndexLocations, IndexUrl};
12+
use uv_distribution_types::{Index, IndexCapabilities, IndexLocations, IndexUrl};
1313
use uv_normalize::PackageName;
1414
use uv_pep440::Version;
1515

@@ -505,6 +505,7 @@ impl PubGrubReportFormatter<'_> {
505505
derivation_tree: &ErrorTree,
506506
selector: &CandidateSelector,
507507
index_locations: &IndexLocations,
508+
index_capabilities: &IndexCapabilities,
508509
available_indexes: &FxHashMap<PackageName, BTreeSet<IndexUrl>>,
509510
unavailable_packages: &FxHashMap<PackageName, UnavailablePackage>,
510511
incomplete_packages: &FxHashMap<PackageName, BTreeMap<Version, IncompletePackage>>,
@@ -540,6 +541,7 @@ impl PubGrubReportFormatter<'_> {
540541
set,
541542
selector,
542543
index_locations,
544+
index_capabilities,
543545
available_indexes,
544546
unavailable_packages,
545547
incomplete_packages,
@@ -589,6 +591,7 @@ impl PubGrubReportFormatter<'_> {
589591
&derived.cause1,
590592
selector,
591593
index_locations,
594+
index_capabilities,
592595
available_indexes,
593596
unavailable_packages,
594597
incomplete_packages,
@@ -602,6 +605,7 @@ impl PubGrubReportFormatter<'_> {
602605
&derived.cause2,
603606
selector,
604607
index_locations,
608+
index_capabilities,
605609
available_indexes,
606610
unavailable_packages,
607611
incomplete_packages,
@@ -621,6 +625,7 @@ impl PubGrubReportFormatter<'_> {
621625
set: &Range<Version>,
622626
selector: &CandidateSelector,
623627
index_locations: &IndexLocations,
628+
index_capabilities: &IndexCapabilities,
624629
available_indexes: &FxHashMap<PackageName, BTreeSet<IndexUrl>>,
625630
unavailable_packages: &FxHashMap<PackageName, UnavailablePackage>,
626631
incomplete_packages: &FxHashMap<PackageName, BTreeMap<Version, IncompletePackage>>,
@@ -721,6 +726,20 @@ impl PubGrubReportFormatter<'_> {
721726
}
722727
}
723728
}
729+
730+
// Add hints due to an index returning an unauthorized response.
731+
for index in index_locations.indexes() {
732+
if index_capabilities.unauthorized(&index.url) {
733+
hints.insert(PubGrubHint::UnauthorizedIndex {
734+
index: index.url.clone(),
735+
});
736+
}
737+
if index_capabilities.forbidden(&index.url) {
738+
hints.insert(PubGrubHint::ForbiddenIndex {
739+
index: index.url.clone(),
740+
});
741+
}
742+
}
724743
}
725744

726745
fn prerelease_available_hint(
@@ -873,6 +892,10 @@ pub(crate) enum PubGrubHint {
873892
// excluded from `PartialEq` and `Hash`
874893
next_index: IndexUrl,
875894
},
895+
/// An index returned an Unauthorized (401) response.
896+
UnauthorizedIndex { index: IndexUrl },
897+
/// An index returned a Forbidden (403) response.
898+
ForbiddenIndex { index: IndexUrl },
876899
}
877900

878901
/// This private enum mirrors [`PubGrubHint`] but only includes fields that should be
@@ -921,6 +944,12 @@ enum PubGrubHintCore {
921944
UncheckedIndex {
922945
package: PubGrubPackage,
923946
},
947+
UnauthorizedIndex {
948+
index: IndexUrl,
949+
},
950+
ForbiddenIndex {
951+
index: IndexUrl,
952+
},
924953
}
925954

926955
impl From<PubGrubHint> for PubGrubHintCore {
@@ -974,6 +1003,8 @@ impl From<PubGrubHint> for PubGrubHintCore {
9741003
workspace,
9751004
},
9761005
PubGrubHint::UncheckedIndex { package, .. } => Self::UncheckedIndex { package },
1006+
PubGrubHint::UnauthorizedIndex { index } => Self::UnauthorizedIndex { index },
1007+
PubGrubHint::ForbiddenIndex { index } => Self::ForbiddenIndex { index },
9771008
}
9781009
}
9791010
}
@@ -1214,6 +1245,26 @@ impl std::fmt::Display for PubGrubHint {
12141245
"--index-strategy unsafe-best-match".green(),
12151246
)
12161247
}
1248+
Self::UnauthorizedIndex { index } => {
1249+
write!(
1250+
f,
1251+
"{}{} An index URL ({}) could not be queried due to a lack of valid authentication credentials ({}).",
1252+
"hint".bold().cyan(),
1253+
":".bold(),
1254+
index.redacted().cyan(),
1255+
"401 Unauthorized".bold().red(),
1256+
)
1257+
}
1258+
Self::ForbiddenIndex { index } => {
1259+
write!(
1260+
f,
1261+
"{}{} An index URL ({}) could not be queried due to a lack of valid authentication credentials ({}).",
1262+
"hint".bold().cyan(),
1263+
":".bold(),
1264+
index.redacted().cyan(),
1265+
"403 Forbidden".bold().red(),
1266+
)
1267+
}
12171268
}
12181269
}
12191270
}

0 commit comments

Comments
 (0)