mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
feat(drive,sdk): port upstream C# SDK up to 6cbf2f44
Port 6cbf2f44 (stop retrying permanent API errors on block transfers) and 20822176 (small file upload interop); 6ce47922 is a no-op here. - Add ProtonApiError::is_retriable / ProtonError::is_retriable mirroring C# RetryPolicy.IsRetriable: 4xx is permanent except 404, 408 and 429, with envelope codes able to override the HTTP status. - Gate upload_one_block's retry loop on it so a permanent rejection of a revision fails immediately instead of being replayed three times. - Extract BlockTargets from RevisionReader so the whole-file download path, the public link download and the reader all re-mint expired block URLs through one generation-deduplicated refresh. - Fall back from the small upload to the block path on AlreadyExists only; the small upload is not idempotent, so everything else keeps propagating. - Bound a server-supplied Retry-After by RetryPolicy::max_retry_after (default 120 s) so a long rate-limit answer cannot look like a hang.
This commit is contained in:
1 parent
4f129b778a
commit
1ca28c5848
7 files changed
+420
-113
No files matched your search
@@ -738,8 +738,9 @@ fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError {
|
||||
/// `multipart` can't be cloned), so every retry resends the full request.
|
||||
///
|
||||
/// Retryable = HTTP 408/429/502/503/504 or a transient transport error
|
||||
/// (timeout / connect). A `Retry-After` header (delta-seconds) is honoured;
|
||||
/// otherwise the delay is exponential backoff with full jitter. Non-retryable
|
||||
/// (timeout / connect). A `Retry-After` header (delta-seconds) is honoured, up
|
||||
/// to `policy.max_retry_after`; otherwise the delay is exponential backoff with
|
||||
/// full jitter. Non-retryable
|
||||
/// responses and errors — including ordinary 4xx and the 401 that drives token
|
||||
/// refresh — pass straight through to the caller untouched.
|
||||
async fn send_retrying<F>(policy: &RetryPolicy, build: F) -> Result<reqwest::Response>
|
||||
@@ -751,7 +752,9 @@ where
|
||||
match build().send().await {
|
||||
Ok(response) => {
|
||||
if attempt < policy.max_retries && is_retryable_status(response.status()) {
|
||||
let delay = retry_after(&response).unwrap_or_else(|| backoff(policy, attempt));
|
||||
let delay = retry_after(&response)
|
||||
.map(|delay| delay.min(policy.max_retry_after))
|
||||
.unwrap_or_else(|| backoff(policy, attempt));
|
||||
tokio::time::sleep(delay).await;
|
||||
attempt += 1;
|
||||
continue;
|
||||
@@ -852,12 +855,28 @@ mod tests {
|
||||
assert_eq!(parse_retry_after_secs(""), None);
|
||||
}
|
||||
|
||||
/// A `Retry-After` longer than the policy allows is clamped: the caller is
|
||||
/// blocked on this sleep, and an unbounded one looks exactly like a hang.
|
||||
#[test]
|
||||
fn retry_after_is_clamped_to_policy_ceiling() {
|
||||
let policy = RetryPolicy::default();
|
||||
let advised = parse_retry_after_secs("3600").expect("delta-seconds parses");
|
||||
assert_eq!(
|
||||
advised.min(policy.max_retry_after),
|
||||
crate::config::DEFAULT_MAX_RETRY_AFTER
|
||||
);
|
||||
// A shorter wait is honoured verbatim.
|
||||
let short = parse_retry_after_secs("5").expect("delta-seconds parses");
|
||||
assert_eq!(short.min(policy.max_retry_after), Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_grows_then_caps_within_jitter_bounds() {
|
||||
let policy = RetryPolicy {
|
||||
max_retries: 5,
|
||||
base_delay: Duration::from_millis(100),
|
||||
max_delay: Duration::from_millis(1000),
|
||||
..RetryPolicy::default()
|
||||
};
|
||||
// Full jitter: every sample stays within [0, ceiling] where the
|
||||
// ceiling is base*2^attempt capped at max_delay.
|
||||
|
||||
Reference in new issue
Block a user