Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0b9e8d1
Error and skip on 429
christeefy Apr 21, 2025
3d0ea05
🎨 Apply cargo fmt
christeefy Apr 21, 2025
e11c0ea
📝 Improve comment
christeefy Apr 21, 2025
7dd16fd
Re-trigger CI
christeefy Apr 22, 2025
6eeb463
Merge branch 'main' into gh-fast-path/429
christeefy May 17, 2025
1ff93df
Update implementation based on feedback
christeefy May 17, 2025
8ff2112
🔥 Remove previous implementation
christeefy May 17, 2025
eab2d32
Format docs
christeefy May 17, 2025
066169d
Merge branch 'main' into gh-fast-path/429
christeefy May 20, 2025
c14c21b
🎨 Reformat code
christeefy May 20, 2025
a2a9dac
🔧 Add `UV_GITHUB_FAST_PATH_URL` env var
christeefy May 20, 2025
5e04f88
Apply env var change
christeefy May 20, 2025
1fb7bca
✅ Add test for GitResolver
christeefy May 20, 2025
29ce14c
✅ Add unit test for `github_fast_path` fn
christeefy May 20, 2025
7a7fd11
Do not retry on HTTP 429
christeefy May 21, 2025
f3f8355
Revert "Do not retry on HTTP 429"
christeefy May 21, 2025
0431722
Merge branch 'main' into gh-fast-path/429
christeefy Jun 21, 2025
67f9a65
Remove use of LazyLock
christeefy Jun 21, 2025
dc85d68
Change memory ordering
christeefy Jun 21, 2025
a093666
Check rate-limited status from response header
christeefy Jun 21, 2025
912dbaa
Determine rate limit via status codes
christeefy Jun 21, 2025
872d6ee
✅ Update tests
christeefy Jun 21, 2025
8875411
Limit visibility for GitHubRateLimitStatus::new
christeefy Jun 24, 2025
e9b0a90
Update tests
christeefy Jun 24, 2025
3a55cf6
Merge branch 'main' into gh-fast-path/429
christeefy Jun 24, 2025
ab45228
Fix lint
christeefy Jun 24, 2025
9d94d9b
Retrigger CI
christeefy Jun 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion crates/uv-git/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars;
use uv_version::version;

use crate::rate_limit::{GITHUB_RATE_LIMIT_STATUS, is_github_rate_limited};

/// A file indicates that if present, `git reset` has been done and a repo
/// checkout is ready to go. See [`GitCheckout::reset`] for why we need this.
const CHECKOUT_READY_LOCK: &str = ".ok";
Expand Down Expand Up @@ -787,7 +789,15 @@ fn github_fast_path(
}
};

let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{github_branch_name}");
// Check if we're rate-limited by GitHub before determining the FastPathRev
if GITHUB_RATE_LIMIT_STATUS.is_active() {
debug!("Skipping GitHub fast path attempt for: {url} (rate-limited)");
return Ok(FastPathRev::Indeterminate);
}

let base_url = std::env::var(EnvVars::UV_GITHUB_FAST_PATH_URL)
.unwrap_or("https://api.github.com/repos".to_owned());
let url = format!("{base_url}/{owner}/{repo}/commits/{github_branch_name}");

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
Expand All @@ -807,6 +817,11 @@ fn github_fast_path(

let response = request.send().await?;

if is_github_rate_limited(&response) {
// Mark that we are being rate-limited by GitHub
GITHUB_RATE_LIMIT_STATUS.activate();
}

// GitHub returns a 404 if the repository does not exist, and a 422 if it exists but GitHub
// is unable to resolve the requested revision.
response.error_for_status_ref()?;
Expand Down
1 change: 1 addition & 0 deletions crates/uv-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ pub use crate::source::{Fetch, GitSource, Reporter};

mod credentials;
mod git;
mod rate_limit;
mod resolver;
mod source;
37 changes: 37 additions & 0 deletions crates/uv-git/src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use reqwest::{Response, StatusCode};
use std::sync::atomic::{AtomicBool, Ordering};

/// A global state on whether we are being rate-limited by GitHub's REST API.
/// If we are, avoid "fast-path" attempts.
pub(crate) static GITHUB_RATE_LIMIT_STATUS: GitHubRateLimitStatus = GitHubRateLimitStatus::new();

/// GitHub REST API rate limit status tracker.
///
/// ## Assumptions
///
/// The rate limit timeout duration is much longer than the runtime of a `uv` command.
/// And so we do not need to invalidate this state based on `x-ratelimit-reset`.
#[derive(Debug)]
pub(crate) struct GitHubRateLimitStatus(AtomicBool);

impl GitHubRateLimitStatus {
const fn new() -> Self {
Self(AtomicBool::new(false))
}

pub(crate) fn activate(&self) {
self.0.store(true, Ordering::Relaxed);
}

pub(crate) fn is_active(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}

/// Determine if GitHub is applying rate-limiting based on the response
pub(crate) fn is_github_rate_limited(response: &Response) -> bool {
// HTTP 403 and 429 are possible status codes in the event of a primary or secondary rate limit.
// Source: https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api?apiVersion=2022-11-28#rate-limit-errors
let status_code = response.status();
status_code == StatusCode::FORBIDDEN || status_code == StatusCode::TOO_MANY_REQUESTS
}
24 changes: 21 additions & 3 deletions crates/uv-git/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl};
use uv_static::EnvVars;
use uv_version::version;

use crate::{Fetch, GitSource, Reporter};
use crate::{
Fetch, GitSource, Reporter,
rate_limit::{GITHUB_RATE_LIMIT_STATUS, is_github_rate_limited},
};

#[derive(Debug, thiserror::Error)]
pub enum GitResolverError {
Expand Down Expand Up @@ -85,10 +88,18 @@ impl GitResolver {
return Ok(None);
};

// Check if we're rate-limited by GitHub, before determining the Git reference
if GITHUB_RATE_LIMIT_STATUS.is_active() {
debug!("Rate-limited by GitHub. Skipping GitHub fast path attempt for: {url}");
return Ok(None);
}

// Determine the Git reference.
let rev = url.reference().as_rev();

let github_api_url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{rev}");
let github_api_base_url = std::env::var(EnvVars::UV_GITHUB_FAST_PATH_URL)
.unwrap_or("https://api.github.com/repos".to_owned());
let github_api_url = format!("{github_api_base_url}/{owner}/{repo}/commits/{rev}");

debug!("Querying GitHub for commit at: {github_api_url}");
let mut request = client.get(&github_api_url);
Expand All @@ -99,13 +110,20 @@ impl GitResolver {
);

let response = request.send().await?;
if !response.status().is_success() {
let status = response.status();
if !status.is_success() {
// Returns a 404 if the repository does not exist, and a 422 if GitHub is unable to
// resolve the requested rev.
debug!(
"GitHub API request failed for: {github_api_url} ({})",
response.status()
);

if is_github_rate_limited(&response) {
// Mark that we are being rate-limited by GitHub
GITHUB_RATE_LIMIT_STATUS.activate();
}

return Ok(None);
}

Expand Down
4 changes: 4 additions & 0 deletions crates/uv-static/src/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,10 @@ impl EnvVars {
#[attr_hidden]
pub const UV_TEST_INDEX_URL: &'static str = "UV_TEST_INDEX_URL";

/// Used to set the GitHub fast-path url for tests.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should document what shape of URL is expected, is there something that GitHub considers as API root that users could exchange?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This env var's intended use is to mock the GitHub API root during tests. It's not meant for users to override, and is hidden from user-facing docs with #[attr_hidden].

I can rename this to UV_TEST_GITHUB_FAST_PATH_URL to be even more explicit, assuming we are okay with UV_TEST_* appearing in non-test files.

Please let me know what you think.

#[attr_hidden]
pub const UV_GITHUB_FAST_PATH_URL: &'static str = "UV_GITHUB_FAST_PATH_URL";

/// Hide progress messages with non-deterministic order in tests.
#[attr_hidden]
pub const UV_TEST_NO_CLI_PROGRESS: &'static str = "UV_TEST_NO_CLI_PROGRESS";
Expand Down
82 changes: 82 additions & 0 deletions crates/uv/tests/it/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,88 @@ fn add_git_private_raw() -> Result<()> {
Ok(())
}

#[tokio::test]
#[cfg(feature = "git")]
async fn add_git_private_rate_limited_by_github_rest_api_403_response() -> Result<()> {
let context = TestContext::new("3.12");
let token = decode_token(READ_ONLY_GITHUB_TOKEN);

let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403))
.expect(1)
.mount(&server)
.await;

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;

uv_snapshot!(context.filters(), context
.add()
.arg(format!("uv-private-pypackage @ git+https://{token}@github.com/astral-test/uv-private-pypackage"))
.env("UV_GITHUB_FAST_PATH_URL", server.uri()), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
");

Ok(())
}

#[tokio::test]
#[cfg(feature = "git")]
async fn add_git_private_rate_limited_by_github_rest_api_429_response() -> Result<()> {
use uv_client::DEFAULT_RETRIES;

let context = TestContext::new("3.12");
let token = decode_token(READ_ONLY_GITHUB_TOKEN);

let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429))
.expect(1 + u64::from(DEFAULT_RETRIES)) // Middleware retries on 429 by default
.mount(&server)
.await;

let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#})?;

uv_snapshot!(context.filters(), context
.add()
.arg(format!("uv-private-pypackage @ git+https://{token}@github.com/astral-test/uv-private-pypackage"))
.env("UV_GITHUB_FAST_PATH_URL", server.uri()), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-private-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
");

Ok(())
}

#[test]
#[cfg(feature = "git")]
fn add_git_error() -> Result<()> {
Expand Down
58 changes: 58 additions & 0 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2066,6 +2066,64 @@ fn install_git_public_https_missing_branch_or_tag() {
"###);
}

#[tokio::test]
#[cfg(feature = "git")]
async fn install_git_public_rate_limited_by_github_rest_api_403_response() {
let context = TestContext::new("3.12");

let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403))
.expect(1)
.mount(&server)
.await;

uv_snapshot!(context.filters(), context
.pip_install()
.arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage")
.env("UV_GITHUB_FAST_PATH_URL", server.uri()), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389)
");
}

#[tokio::test]
#[cfg(feature = "git")]
async fn install_git_public_rate_limited_by_github_rest_api_429_response() {
use uv_client::DEFAULT_RETRIES;

let context = TestContext::new("3.12");

let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(429))
.expect(1 + u64::from(DEFAULT_RETRIES)) // Middleware retries on 429 by default
.mount(&server)
.await;
Comment on lines +2104 to +2109
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on the testing strategy here!


uv_snapshot!(context.filters(), context
.pip_install()
.arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage")
.env("UV_GITHUB_FAST_PATH_URL", server.uri()), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389)
");
}

/// Install a package from a public GitHub repository at a ref that does not exist
#[test]
#[cfg(feature = "git")]
Expand Down
Loading