@@ -113,12 +113,11 @@ impl Binary {
113113 . unwrap( ) ,
114114 DisplaySafeUrl :: parse( & format!( "{VERSIONS_MANIFEST_URL}/{name}.ndjson" ) ) . unwrap( ) ,
115115 ] ,
116- Self :: Uv => {
117- vec ! [
118- DisplaySafeUrl :: parse( & format!( "{VERSIONS_MANIFEST_URL}/{name}.ndjson" ) )
119- . unwrap( ) ,
120- ]
121- }
116+ Self :: Uv => vec ! [
117+ DisplaySafeUrl :: parse( & format!( "{VERSIONS_MANIFEST_MIRROR}/{name}.ndjson" ) )
118+ . unwrap( ) ,
119+ DisplaySafeUrl :: parse( & format!( "{VERSIONS_MANIFEST_URL}/{name}.ndjson" ) ) . unwrap( ) ,
120+ ] ,
122121 }
123122 }
124123
@@ -393,11 +392,13 @@ impl RetriableError for Error {
393392
394393 /// Returns `true` if trying an alternative URL makes sense after this error.
395394 ///
396- /// All errors arising from downloading (including streaming during extraction)
397- /// qualify.
395+ /// Download and streaming failures qualify, as do malformed manifest responses.
398396 fn should_try_next_url ( & self ) -> bool {
399397 match self {
400- Self :: Download { .. } | Self :: ManifestFetch { .. } => true ,
398+ Self :: Download { .. }
399+ | Self :: ManifestFetch { .. }
400+ | Self :: ManifestParse ( ..)
401+ | Self :: ManifestUtf8 ( ..) => true ,
401402 Self :: Stream { .. } => true ,
402403 Self :: RetriedError { err, .. } => err. should_try_next_url ( ) ,
403404 err => {
@@ -856,13 +857,80 @@ where
856857
857858#[ cfg( test) ]
858859mod tests {
860+ use serde_json:: json;
859861 use std:: io:: Write ;
860- use std:: net:: TcpListener ;
861- use uv_client:: { BaseClientBuilder , retryable_on_request_failure} ;
862+ use uv_client:: { BaseClientBuilder , fetch_with_url_fallback, retryable_on_request_failure} ;
862863 use uv_redacted:: DisplaySafeUrl ;
864+ use wiremock:: matchers:: { method, path} ;
865+ use wiremock:: { Mock , MockServer , ResponseTemplate } ;
863866
864867 use super :: * ;
865868
869+ async fn spawn_manifest_server ( response : ResponseTemplate ) -> ( DisplaySafeUrl , MockServer ) {
870+ let server = MockServer :: start ( ) . await ;
871+ Mock :: given ( method ( "GET" ) )
872+ . and ( path ( "/uv.ndjson" ) )
873+ . respond_with ( response)
874+ . mount ( & server)
875+ . await ;
876+
877+ (
878+ DisplaySafeUrl :: parse ( & format ! ( "{}/uv.ndjson" , server. uri( ) ) ) . unwrap ( ) ,
879+ server,
880+ )
881+ }
882+
883+ fn manifest_response ( body : & str ) -> ResponseTemplate {
884+ ResponseTemplate :: new ( 200 ) . set_body_raw ( body. to_owned ( ) , "application/x-ndjson" )
885+ }
886+
887+ fn not_found_response ( ) -> ResponseTemplate {
888+ ResponseTemplate :: new ( 404 )
889+ }
890+
891+ fn uv_manifest_line ( version : & str , platform : & str ) -> String {
892+ let extension = if cfg ! ( windows) { "zip" } else { "tar.gz" } ;
893+ let url = format ! (
894+ "https://github.com/astral-sh/uv/releases/download/{version}/uv-{platform}.{extension}"
895+ ) ;
896+
897+ format ! (
898+ "{}\n " ,
899+ json!( {
900+ "version" : version,
901+ "date" : "2025-01-01T00:00:00Z" ,
902+ "artifacts" : [ {
903+ "platform" : platform,
904+ "url" : url,
905+ "archive_format" : extension,
906+ } ] ,
907+ } )
908+ )
909+ }
910+
911+ async fn resolve_version_from_manifest_urls (
912+ urls : & [ DisplaySafeUrl ] ,
913+ constraints : Option < & VersionSpecifiers > ,
914+ ) -> Result < ResolvedVersion , Error > {
915+ let platform = Platform :: from_env ( ) . unwrap ( ) ;
916+ let platform_name = platform. as_cargo_dist_triple ( ) ;
917+ let client_builder = BaseClientBuilder :: default ( ) . retries ( 0 ) ;
918+ let retry_policy = client_builder. retry_policy ( ) ;
919+ let client = client_builder. build ( ) ;
920+
921+ fetch_with_url_fallback ( urls, retry_policy, "manifest for `uv`" , |url| {
922+ fetch_and_find_matching_version (
923+ Binary :: Uv ,
924+ constraints,
925+ None ,
926+ & platform_name,
927+ url,
928+ & client,
929+ )
930+ } )
931+ . await
932+ }
933+
866934 #[ test]
867935 fn test_uv_download_urls ( ) {
868936 let urls = Binary :: Uv
@@ -886,6 +954,70 @@ mod tests {
886954 ) ;
887955 }
888956
957+ #[ tokio:: test]
958+ async fn test_manifest_falls_back_on_404 ( ) {
959+ let platform = Platform :: from_env ( ) . unwrap ( ) ;
960+ let platform_name = platform. as_cargo_dist_triple ( ) ;
961+ let ( mirror_url, mirror_server) = spawn_manifest_server ( not_found_response ( ) ) . await ;
962+ let ( canonical_url, canonical_server) = spawn_manifest_server ( manifest_response (
963+ & uv_manifest_line ( "1.2.3" , & platform_name) ,
964+ ) )
965+ . await ;
966+
967+ let resolved = resolve_version_from_manifest_urls ( & [ mirror_url, canonical_url] , None )
968+ . await
969+ . expect ( "404 from mirror should fall back to canonical manifest" ) ;
970+
971+ assert_eq ! ( resolved. version, Version :: new( [ 1 , 2 , 3 ] ) ) ;
972+ assert_eq ! ( mirror_server. received_requests( ) . await . unwrap( ) . len( ) , 1 ) ;
973+ assert_eq ! ( canonical_server. received_requests( ) . await . unwrap( ) . len( ) , 1 ) ;
974+ }
975+
976+ #[ tokio:: test]
977+ async fn test_manifest_falls_back_on_parse_error ( ) {
978+ let platform = Platform :: from_env ( ) . unwrap ( ) ;
979+ let platform_name = platform. as_cargo_dist_triple ( ) ;
980+ let ( mirror_url, mirror_server) =
981+ spawn_manifest_server ( manifest_response ( "{not json}\n " ) ) . await ;
982+ let ( canonical_url, canonical_server) = spawn_manifest_server ( manifest_response (
983+ & uv_manifest_line ( "1.2.3" , & platform_name) ,
984+ ) )
985+ . await ;
986+
987+ let resolved = resolve_version_from_manifest_urls ( & [ mirror_url, canonical_url] , None )
988+ . await
989+ . expect ( "parse failure from mirror should fall back to canonical manifest" ) ;
990+
991+ assert_eq ! ( resolved. version, Version :: new( [ 1 , 2 , 3 ] ) ) ;
992+ assert_eq ! ( mirror_server. received_requests( ) . await . unwrap( ) . len( ) , 1 ) ;
993+ assert_eq ! ( canonical_server. received_requests( ) . await . unwrap( ) . len( ) , 1 ) ;
994+ }
995+
996+ #[ tokio:: test]
997+ async fn test_manifest_no_matching_version_does_not_fallback ( ) {
998+ let platform = Platform :: from_env ( ) . unwrap ( ) ;
999+ let platform_name = platform. as_cargo_dist_triple ( ) ;
1000+ let ( mirror_url, mirror_server) = spawn_manifest_server ( manifest_response (
1001+ & uv_manifest_line ( "1.2.3" , & platform_name) ,
1002+ ) )
1003+ . await ;
1004+ let ( canonical_url, canonical_server) = spawn_manifest_server ( manifest_response (
1005+ & uv_manifest_line ( "9.9.9" , & platform_name) ,
1006+ ) )
1007+ . await ;
1008+ let constraints =
1009+ VersionSpecifiers :: from ( VersionSpecifier :: equals_version ( Version :: new ( [ 9 , 9 , 9 ] ) ) ) ;
1010+
1011+ let err =
1012+ resolve_version_from_manifest_urls ( & [ mirror_url, canonical_url] , Some ( & constraints) )
1013+ . await
1014+ . expect_err ( "no matching version should not fall back to canonical manifest" ) ;
1015+
1016+ assert ! ( matches!( err, Error :: NoMatchingVersion { .. } ) ) ;
1017+ assert_eq ! ( mirror_server. received_requests( ) . await . unwrap( ) . len( ) , 1 ) ;
1018+ assert_eq ! ( canonical_server. received_requests( ) . await . unwrap( ) . len( ) , 0 ) ;
1019+ }
1020+
8891021 /// Verify that `should_try_next_url` returns `true` even for streaming errors
8901022 /// that `retryable_on_request_failure` does not recognise as transient.
8911023 ///
@@ -895,7 +1027,7 @@ mod tests {
8951027 async fn test_non_retryable_stream_error_triggers_url_fallback ( ) {
8961028 use futures:: TryStreamExt ;
8971029
898- let listener = TcpListener :: bind ( "127.0.0.1:0" ) . unwrap ( ) ;
1030+ let listener = std :: net :: TcpListener :: bind ( "127.0.0.1:0" ) . unwrap ( ) ;
8991031 let addr = listener. local_addr ( ) . unwrap ( ) ;
9001032
9011033 std:: thread:: spawn ( move || {
0 commit comments