Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e5e7a01
feat(remote-cache): honour the Registry's request limits and its 100 …
tolgaergin Oct 11, 2026
7f15ef8
fix(remote-cache): wait out a request limit instead of skipping for t…
tolgaergin Oct 11, 2026
b42744d
fix(remote-cache): retry an upload refused for a moment, and name cap…
tolgaergin Oct 11, 2026
234b85f
fix(remote-cache): spread upload retries, recheck pauses, and keep wa…
tolgaergin Oct 11, 2026
427cc8a
fix(remote-cache): note uploads skipped while the cache is busy, and …
tolgaergin Oct 11, 2026
7f65e40
fix(remote-cache): look a refused artifact up once more after a short…
tolgaergin Oct 11, 2026
35ab785
fix(remote-cache): record spent limits when they're taken, and spread…
tolgaergin Oct 11, 2026
3c45aeb
fix(cache): note uploads skipped while lookups wait on a busy server
tolgaergin Oct 11, 2026
d14cfda
fix(cache): stop requests the cache server refuses for the rest of th…
tolgaergin Oct 11, 2026
fb18963
fix(cache): stop the run for a lapsed bill, keep uploading past a ful…
tolgaergin Oct 11, 2026
5965bc9
test(cache): say once that the run stops requests refused at once
tolgaergin Oct 11, 2026
643621f
feat(cache): take cache hits from storage when the Registry redirects…
tolgaergin Oct 12, 2026
a5647d8
fix(cache): don't fetch storage for a redirect without the artifact's…
tolgaergin Oct 12, 2026
de26b58
test(cache): don't fetch storage for a redirect without the tag a sig…
tolgaergin Oct 12, 2026
9666d95
docs(cache): name the download host in the redirect header's comment
tolgaergin Oct 12, 2026
e5ccb80
fix(cache): keep the signed download URL out of failed downloads' errors
tolgaergin Oct 13, 2026
f005f0d
Merge remote-tracking branch 'origin/main' into remote-cache/request-…
tolgaergin Oct 13, 2026
5cb8a39
Merge branch 'main' into remote-cache/request-limits
tolgaergin Oct 13, 2026
c6c9ebb
fix(login): ask again while the Registry settles the authorization code
tolgaergin Oct 13, 2026
a98d64a
test(login): pin the Retry-After cap and the retry-exhausted message
tolgaergin Oct 13, 2026
446996a
docs(remote-cache): describe the quota refusal as the server now make…
tolgaergin Oct 14, 2026
6da5fcf
Merge remote-tracking branch 'origin/main' into remote-cache/request-…
tolgaergin Oct 14, 2026
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ tokio-util = { version = "0.7", features = ["io", "io-util"] }

# HTTP
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream", "blocking", "http2", "gzip", "brotli", "zstd"], default-features = false }
httpdate = "1"
axum = { version = "0.8", features = ["json"] }

# Serialization
Expand Down
1 change: 1 addition & 0 deletions crates/lpm-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ cvss = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true, features = ["rt"] }
reqwest = { workspace = true }
httpdate = { workspace = true }
axum = { workspace = true }
serde = { workspace = true, features = ["rc"] }
serde_json = { workspace = true }
Expand Down
249 changes: 222 additions & 27 deletions crates/lpm-cli/src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,33 +189,14 @@ pub async fn run(
}
let exchange_url = format!("{registry_url}/api/cli/exchange");
let http_client = exchange_client(std::time::Duration::from_secs(30))?;
let resp = http_client
.post(&exchange_url)
.json(&serde_json::json!({ "code": code, "code_verifier": code_verifier }))
.send()
.await
.map_err(|error| {
LpmError::Registry(format!(
"exchange request failed: {}",
lpm_http::display_error(&error)
))
})?;

let status = resp.status();
let body = read_capped_exchange_body(resp).await?;
if !status.is_success() {
let detail = if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&body) {
json["error"]
.as_str()
.unwrap_or("unknown error")
.to_string()
} else {
format!("HTTP {status}")
};
return Err(LpmError::Registry(format!(
"Failed to exchange authorization code: {detail}. It may have expired — please try again."
)));
}
let body = exchange_authorization_code(
&http_client,
&exchange_url,
&code,
&code_verifier,
EXCHANGE_RETRY_BUDGET,
)
.await?;

let session = parse_cli_exchange_session(&body)?;
let token = session.token;
Expand Down Expand Up @@ -251,6 +232,84 @@ pub async fn run(
Ok(())
}

/// How long `lpm login` keeps asking the Registry to redeem an authorization
/// code it answered "try again" for, as while it settles a code's state.
const EXCHANGE_RETRY_BUDGET: std::time::Duration = std::time::Duration::from_secs(30);
/// The longest wait between attempts, whatever `Retry-After` asks.
const EXCHANGE_RETRY_MAX_WAIT: std::time::Duration = std::time::Duration::from_secs(5);

/// Redeems the authorization code for a session, and returns the response
/// body. The Registry answers 503 while it can't yet tell whether the code
/// was redeemed, and 429 when the address is rate-limited; both keep the
/// code, so they're asked again after their `Retry-After` while `budget`
/// allows. Any other refusal is final, as the code is spent or invalid.
async fn exchange_authorization_code(
client: &reqwest::Client,
exchange_url: &str,
code: &str,
code_verifier: &str,
budget: std::time::Duration,
) -> Result<Vec<u8>, LpmError> {
let started = std::time::Instant::now();
loop {
let response = client
.post(exchange_url)
.json(&serde_json::json!({ "code": code, "code_verifier": code_verifier }))
.send()
.await
.map_err(|error| {
LpmError::Registry(format!(
"exchange request failed: {}",
lpm_http::display_error(&error)
))
})?;
let status = response.status();
let retryable = status == reqwest::StatusCode::SERVICE_UNAVAILABLE
|| status == reqwest::StatusCode::TOO_MANY_REQUESTS;
let wait = exchange_retry_wait(&response);
let body = read_capped_exchange_body(response).await?;
if status.is_success() {
return Ok(body);
}
if retryable && started.elapsed() + wait <= budget {
tokio::time::sleep(wait).await;
continue;
}
let detail = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|json| json["error"].as_str().map(str::to_string))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
return Err(LpmError::Registry(if retryable {
format!("Failed to exchange authorization code: {detail}. Run `lpm login` again.")
} else {
format!(
"Failed to exchange authorization code: {detail}. It may have expired — please try again."
)
}));
}
}

/// The wait a response's `Retry-After` asks, in whole seconds, or a second
/// when it names none, at most `EXCHANGE_RETRY_MAX_WAIT`.
fn exchange_retry_wait(response: &reqwest::Response) -> std::time::Duration {
retry_after_wait(
response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok()),
)
}

fn retry_after_wait(retry_after: Option<&str>) -> std::time::Duration {
retry_after
.and_then(|value| value.trim().parse::<u64>().ok())
.map_or(
std::time::Duration::from_secs(1),
std::time::Duration::from_secs,
)
.min(EXCHANGE_RETRY_MAX_WAIT)
}

fn exchange_client(timeout: std::time::Duration) -> Result<reqwest::Client, LpmError> {
lpm_http::client_builder()
.redirect(reqwest::redirect::Policy::none())
Expand Down Expand Up @@ -757,6 +816,142 @@ mod tests {
}
}

async fn exchange_server(responses: Vec<wiremock::ResponseTemplate>) -> wiremock::MockServer {
use wiremock::{Mock, MockServer};
let server = MockServer::start().await;
for (index, response) in responses.into_iter().enumerate() {
Mock::given(wiremock::matchers::method("POST"))
.respond_with(response)
.up_to_n_times(1)
.with_priority(u8::try_from(index + 1).unwrap())
.mount(&server)
.await;
}
server
}

#[tokio::test]
async fn exchange_retries_while_the_registry_settles_the_code_then_succeeds() {
use wiremock::ResponseTemplate;
let unsettled = || {
ResponseTemplate::new(503)
.insert_header("retry-after", "0")
.set_body_json(serde_json::json!({"error": "The sign-in can't be completed right now; try again"}))
};
let server = exchange_server(vec![
unsettled(),
ResponseTemplate::new(429).insert_header("retry-after", "0"),
unsettled(),
ResponseTemplate::new(200).set_body_json(valid_exchange_session()),
])
.await;
let client = exchange_client(std::time::Duration::from_secs(5)).unwrap();

let body = exchange_authorization_code(
&client,
&server.uri(),
"the-code",
"the-verifier",
std::time::Duration::from_secs(10),
)
.await
.unwrap();

assert!(parse_cli_exchange_session(&body).is_ok());
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 4);
for request in requests {
let sent: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(
sent,
serde_json::json!({"code": "the-code", "code_verifier": "the-verifier"})
);
}
}

#[tokio::test]
async fn exchange_stops_retrying_at_its_budget_with_the_registry_message() {
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(wiremock::matchers::method("POST"))
.respond_with(
ResponseTemplate::new(503)
.insert_header("retry-after", "1")
.set_body_json(serde_json::json!({"error": "The sign-in can't be completed right now; try again"})),
)
.mount(&server)
.await;
let client = exchange_client(std::time::Duration::from_secs(5)).unwrap();
let started = std::time::Instant::now();

let error = exchange_authorization_code(
&client,
&server.uri(),
"the-code",
"the-verifier",
std::time::Duration::from_millis(1500),
)
.await
.unwrap_err();

assert!(started.elapsed() < std::time::Duration::from_secs(3));
assert!(
error
.to_string()
.contains("The sign-in can't be completed right now"),
"{error}"
);
assert!(
error.to_string().contains("Run `lpm login` again."),
"{error}"
);
assert!(!error.to_string().contains("expired"), "{error}");
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}

#[test]
fn exchange_waits_what_retry_after_asks_within_its_cap() {
let second = std::time::Duration::from_secs(1);
assert_eq!(retry_after_wait(Some("0")), std::time::Duration::ZERO);
assert_eq!(retry_after_wait(Some(" 2 ")), 2 * second);
assert_eq!(retry_after_wait(Some("3600")), EXCHANGE_RETRY_MAX_WAIT);
assert_eq!(
retry_after_wait(Some("Wed, 21 Oct 2026 07:28:00 GMT")),
second
);
assert_eq!(retry_after_wait(None), second);
}

#[tokio::test]
async fn exchange_does_not_retry_a_refused_code() {
use wiremock::ResponseTemplate;
let server = exchange_server(vec![
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({"error": "Invalid or expired exchange code"})),
ResponseTemplate::new(200).set_body_json(valid_exchange_session()),
])
.await;
let client = exchange_client(std::time::Duration::from_secs(5)).unwrap();

let error = exchange_authorization_code(
&client,
&server.uri(),
"the-code",
"the-verifier",
std::time::Duration::from_secs(10),
)
.await
.unwrap_err();

assert!(
error
.to_string()
.contains("Invalid or expired exchange code"),
"{error}"
);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}

#[test]
fn verification_preserves_explicit_insecure_transport_choice() {
let client = RegistryClient::new().with_insecure(true);
Expand Down
Loading
Loading