Skip to main content

edgefirst_client/
retry.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright © 2025 Au-Zone Technologies. All Rights Reserved.
3
4//! Retry policies with URL-based classification for EdgeFirst Studio Client.
5//!
6//! # Overview
7//!
8//! This module implements intelligent retry logic that classifies requests into
9//! two categories:
10//!
11//! - **StudioApi**: EdgeFirst Studio JSON-RPC API calls
12//!   (`*.edgefirst.studio/api`)
13//! - **FileIO**: File upload/download operations (AWS S3 pre-signed URLs,
14//!   CloudFront, etc.)
15//!
16//! # Motivation
17//!
18//! Different types of operations have different failure characteristics and
19//! retry requirements:
20//!
21//! ## Studio API Requests
22//!
23//! - **Low concurrency**: Sequential JSON-RPC method calls
24//! - **Fast-fail desired**: Authentication failures should not retry
25//! - **Predictable errors**: HTTP 401/403 indicate auth issues, not transient
26//!   failures
27//! - **User experience**: Users expect quick feedback on invalid credentials
28//!
29//! ## File I/O Operations (S3, CloudFront)
30//!
31//! - **High concurrency**: Parallel uploads/downloads of dataset files (100+
32//!   files)
33//! - **Transient failures common**: S3 rate limiting, network congestion,
34//!   timeouts
35//! - **Retry-safe**: Idempotent operations (pre-signed URLs, multipart uploads)
36//! - **Robustness critical**: Dataset operations must complete reliably despite
37//!   temporary issues
38//!
39//! # Classification Strategy
40//!
41//! URLs are classified by inspecting the host and path:
42//!
43//! - **StudioApi**: `https://*.edgefirst.studio/api*` (exact host match + path
44//!   prefix)
45//! - **FileIO**: Everything else (S3, CloudFront, or any non-API Studio path)
46//!
47//! # Retry Behavior
48//!
49//! Both scopes use the same configurable retry count (`EDGEFIRST_MAX_RETRIES`,
50//! default: 3), but differ in error classification:
51//!
52//! # Environment Variables
53//!
54//! - `EDGEFIRST_MAX_RETRIES`: Maximum number of retries for failed requests
55//!   (default: 5)
56//! - `MAX_TASKS`: Maximum concurrent upload/download tasks (default: half of
57//!   CPU cores, min 2, max 8). Lower values (2-8) work better for large files
58//!   to avoid timeouts. Higher values (16-32) are better for many small files.
59//!
60//! ## StudioApi Error Classification
61//!
62//! - **Never retry**: 401 Unauthorized, 403 Forbidden (auth failures)
63//! - **Always retry**: 408 Timeout, 429 Too Many Requests, 5xx Server Errors
64//! - **Retry transports errors**: Connection failures, DNS errors, timeouts
65//!
66//! ## FileIO Error Classification
67//!
68//! - **Always retry**: 408 Timeout, 409 Conflict, 423 Locked, 429 Too Many
69//!   Requests, 5xx Server Errors
70//! - **Retry transport errors**: Connection failures, DNS errors, timeouts
71//! - **No auth bypass**: All HTTP errors (including 401/403) are retried for S3
72//!   URLs
73//!
74//! # Configuration
75//!
76//! - `EDGEFIRST_MAX_RETRIES`: Maximum retry attempts per request (default: 5)
77//! - `EDGEFIRST_TIMEOUT`: Total-request deadline for **fast** Studio API calls
78//!   in seconds (default: 30). Applies to the `http` client / [`crate::Client::rpc`]
79//!   only. **Do not** increase this for large file transfers or paginated
80//!   sample fetches — use `EDGEFIRST_READ_TIMEOUT` / [`crate::Client::rpc_bulk`] instead.
81//! - `EDGEFIRST_READ_TIMEOUT`: Per-chunk idle timeout for bulk operations in
82//!   seconds (default: 120). Applies to the `bulk_http` client used by file
83//!   downloads/uploads, [`crate::Client::rpc_bulk`] (paginated `samples.list`, bulk
84//!   annotation APIs, etc.), and related helpers. Resets after every received
85//!   chunk, so healthy large transfers are never interrupted. Only fires when
86//!   no bytes arrive for the configured duration.
87//! - `EDGEFIRST_SAMPLES_PAGE_SIZE`: Optional page size for `samples.list` when
88//!   fetching mask/seg annotations (default: 100, clamped to 1..=1000). Smaller
89//!   pages keep server pre-response work under the bulk idle timeout.
90//! - `EDGEFIRST_UPLOAD_TIMEOUT`: Per-operation total timeout for bulk uploads
91//!   in seconds (default: 600). Applied per-request via `RequestBuilder::timeout`
92//!   on each upload attempt (per part for multipart, per file for single uploads).
93//!   Covers the send phase where `EDGEFIRST_READ_TIMEOUT` does not apply.
94//!   Sized for PART_SIZE (100 MB) at ~170 KB/s minimum; increase for very slow
95//!   uplinks or larger single-file uploads.
96//!
97//! **For bulk file operations and sample fetches**, increase retry count for better resilience:
98//! ```bash
99//! export EDGEFIRST_MAX_RETRIES=10     # More retries for S3 operations
100//! export EDGEFIRST_READ_TIMEOUT=300   # 5-minute idle timeout for very slow downlinks
101//! export EDGEFIRST_UPLOAD_TIMEOUT=900 # 15-minute per-part timeout for very slow uplinks
102//! ```
103//!
104//! # Examples
105//!
106//! ```rust
107//! use edgefirst_client::{RetryScope, classify_url};
108//!
109//! // Studio API calls
110//! assert_eq!(
111//!     classify_url("https://edgefirst.studio/api"),
112//!     RetryScope::StudioApi
113//! );
114//! assert_eq!(
115//!     classify_url("https://test.edgefirst.studio/api/datasets.list"),
116//!     RetryScope::StudioApi
117//! );
118//!
119//! // File I/O operations
120//! assert_eq!(
121//!     classify_url("https://s3.amazonaws.com/bucket/file.bin"),
122//!     RetryScope::FileIO
123//! );
124//! assert_eq!(
125//!     classify_url("https://d123abc.cloudfront.net/dataset.zip"),
126//!     RetryScope::FileIO
127//! );
128//! ```
129
130use url::Url;
131
132/// Retry scope classification for URL-based retry policies.
133///
134/// Determines whether a request is a Studio API call or a File I/O operation,
135/// enabling different error handling strategies for each category.
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub enum RetryScope {
138    /// EdgeFirst Studio JSON-RPC API calls to `*.edgefirst.studio/api`.
139    ///
140    /// These calls should fail fast on authentication errors but retry
141    /// server errors and transient failures.
142    StudioApi,
143
144    /// File upload/download operations to S3, CloudFront, or other endpoints.
145    ///
146    /// These operations experience high concurrency and should retry
147    /// aggressively on all transient failures.
148    FileIO,
149}
150
151/// Classifies a URL to determine which retry policy to apply.
152///
153/// This function performs URL-based classification to differentiate between
154/// EdgeFirst Studio API calls and File I/O operations (S3, CloudFront, etc.).
155///
156/// # Classification Algorithm
157///
158/// 1. Parse URL using proper URL parser (handles ports, query params,
159///    fragments)
160/// 2. Check protocol: Only HTTP/HTTPS are classified as StudioApi (all others →
161///    FileIO)
162/// 3. Check host: Must be `edgefirst.studio` or `*.edgefirst.studio`
163/// 4. Check path: Must start with `/api` (exact match or `/api/...`)
164/// 5. If all conditions met → `StudioApi`, otherwise → `FileIO`
165///
166/// # Edge Cases Handled
167///
168/// - **Port numbers**: `https://test.edgefirst.studio:8080/api` → StudioApi
169/// - **Trailing slashes**: `https://edgefirst.studio/api/` → StudioApi
170/// - **Query parameters**: `https://edgefirst.studio/api?foo=bar` → StudioApi
171/// - **Subdomains**: `https://ocean.edgefirst.studio/api` → StudioApi
172/// - **Similar domains**: `https://edgefirst.studio.com/api` → FileIO (not
173///   exact match)
174/// - **Path injection**: `https://evil.com/edgefirst.studio/api` → FileIO (host
175///   mismatch)
176/// - **Non-API paths**: `https://edgefirst.studio/download` → FileIO
177///
178/// # Security
179///
180/// The function uses proper URL parsing to prevent domain spoofing attacks.
181/// Only the URL host is checked, not the path, preventing injection via
182/// `https://attacker.com/edgefirst.studio/api`.
183///
184/// # Examples
185///
186/// ```rust
187/// use edgefirst_client::{RetryScope, classify_url};
188///
189/// // Studio API URLs
190/// assert_eq!(
191///     classify_url("https://edgefirst.studio/api"),
192///     RetryScope::StudioApi
193/// );
194/// assert_eq!(
195///     classify_url("https://test.edgefirst.studio/api/datasets"),
196///     RetryScope::StudioApi
197/// );
198/// assert_eq!(
199///     classify_url("https://test.edgefirst.studio:443/api?token=abc"),
200///     RetryScope::StudioApi
201/// );
202///
203/// // File I/O URLs (S3, CloudFront, etc.)
204/// assert_eq!(
205///     classify_url("https://s3.amazonaws.com/bucket/file.bin"),
206///     RetryScope::FileIO
207/// );
208/// assert_eq!(
209///     classify_url("https://d123abc.cloudfront.net/dataset.zip"),
210///     RetryScope::FileIO
211/// );
212/// assert_eq!(
213///     classify_url("https://edgefirst.studio/download_model"),
214///     RetryScope::FileIO // Non-API path
215/// );
216/// ```
217pub fn classify_url(url: &str) -> RetryScope {
218    // Try to parse as proper URL
219    if let Ok(parsed) = Url::parse(url) {
220        // Only match HTTP/HTTPS protocols
221        if parsed.scheme() != "http" && parsed.scheme() != "https" {
222            return RetryScope::FileIO;
223        }
224
225        if let Some(host) = parsed.host_str() {
226            let host_matches = host == "edgefirst.studio" || host.ends_with(".edgefirst.studio");
227
228            // Path must be exactly "/api" or start with "/api/" (not "/apis" etc.)
229            let path = parsed.path();
230            let path_is_api = path == "/api" || path.starts_with("/api/");
231
232            if host_matches && path_is_api {
233                return RetryScope::StudioApi;
234            }
235        }
236    }
237
238    RetryScope::FileIO
239}
240
241/// Creates a retry policy with URL-based classification.
242///
243/// This function builds a reqwest retry policy that inspects each request URL
244/// and applies different error classification rules based on whether it's a
245/// Studio API call or a File I/O operation.
246///
247/// # Retry Configuration
248///
249/// - **Max retries**: Configurable via `EDGEFIRST_MAX_RETRIES` (default: 5)
250/// - **Timeout**: Configurable via `EDGEFIRST_TIMEOUT` (default: 30 seconds)
251///   for fast API calls; bulk transfers and paginated sample fetches use
252///   `EDGEFIRST_READ_TIMEOUT` (default: 120 seconds idle) via `bulk_http` /
253///   [`crate::Client::rpc_bulk`].
254///
255/// # Error Classification by Scope
256///
257/// ## StudioApi (*.edgefirst.studio/api)
258///
259/// Optimized for fast-fail on authentication errors:
260///
261/// | HTTP Status | Action | Rationale |
262/// |-------------|--------|-----------|
263/// | 401, 403 | Never retry | Authentication failure - user action required |
264/// | 408, 429 | Retry | Timeout, rate limiting - transient |
265/// | 5xx | Retry | Server error - may recover |
266/// | Connection errors | Retry | Network issues - transient |
267///
268/// ## FileIO (S3, CloudFront, etc.)
269///
270/// Optimized for robustness under high concurrency:
271///
272/// | HTTP Status | Action | Rationale |
273/// |-------------|--------|-----------|
274/// | 408, 429 | Retry | Timeout, rate limiting - common with S3 |
275/// | 409, 423 | Retry | Conflict, locked - S3 eventual consistency |
276/// | 5xx | Retry | Server error - S3 transient issues |
277/// | Connection errors | Retry | Network issues - common in parallel uploads |
278///
279/// # Usage Recommendations
280///
281/// **For dataset downloads/uploads** (many concurrent S3 operations):
282/// ```bash
283/// export EDGEFIRST_MAX_RETRIES=10      # More retries for robustness
284/// export EDGEFIRST_READ_TIMEOUT=300    # Longer idle timeout for slow links
285/// export EDGEFIRST_UPLOAD_TIMEOUT=900  # Longer per-part timeout for slow uplinks
286/// ```
287///
288/// **For testing** (fast failure detection):
289/// ```bash
290/// export EDGEFIRST_MAX_RETRIES=1   # Minimal retries
291/// export EDGEFIRST_TIMEOUT=10      # Quick API call timeout
292/// ```
293///
294/// # Implementation Notes
295///
296/// Due to reqwest retry API limitations, both StudioApi and FileIO use the
297/// same `max_retries_per_request` value. The differentiation is in error
298/// classification only (which errors trigger retries), not retry count.
299///
300/// For operations requiring different retry counts, use separate Client
301/// instances with different `EDGEFIRST_MAX_RETRIES` configuration.
302pub fn create_retry_policy() -> reqwest::retry::Builder {
303    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES")
304        .ok()
305        .and_then(|s| s.parse().ok())
306        .unwrap_or(5);
307
308    // Use wildcard host scope since we do URL inspection in classify_fn
309    reqwest::retry::for_host("*")
310        .max_retries_per_request(max_retries)
311        .classify_fn(|req_rep| {
312            let url = req_rep.uri().to_string();
313
314            match classify_url(&url) {
315                RetryScope::StudioApi => {
316                    // Studio API: Never retry auth failures, retry server errors
317                    match req_rep.status() {
318                        Some(status) => match status.as_u16() {
319                            401 | 403 => req_rep.success(), // Auth failures - don't retry
320                            429 | 408 | 500..=599 => req_rep.retryable(),
321                            _ => req_rep.success(),
322                        },
323                        // No status code means connection error, timeout, or other transport
324                        // failure These are safe to retry for API calls
325                        None if req_rep.error().is_some() => req_rep.retryable(),
326                        None => req_rep.success(),
327                    }
328                }
329                RetryScope::FileIO => {
330                    // File I/O: Retry all transient errors
331                    match req_rep.status() {
332                        Some(status) => match status.as_u16() {
333                            429 | 408 | 500..=599 | 409 | 423 => req_rep.retryable(),
334                            _ => req_rep.success(),
335                        },
336                        None if req_rep.error().is_some() => req_rep.retryable(),
337                        None => req_rep.success(),
338                    }
339                }
340            }
341        })
342}
343
344pub fn log_retry_configuration() {
345    let max_retries = std::env::var("EDGEFIRST_MAX_RETRIES").unwrap_or_else(|_| "5".to_string());
346    let timeout = std::env::var("EDGEFIRST_TIMEOUT").unwrap_or_else(|_| "30".to_string());
347    let read_timeout =
348        std::env::var("EDGEFIRST_READ_TIMEOUT").unwrap_or_else(|_| "120".to_string());
349    let upload_timeout =
350        std::env::var("EDGEFIRST_UPLOAD_TIMEOUT").unwrap_or_else(|_| "600".to_string());
351    log::debug!(
352        "Retry configuration - max_retries={}, api_timeout={}s, bulk_read_timeout={}s, upload_timeout={}s",
353        max_retries,
354        timeout,
355        read_timeout,
356        upload_timeout
357    );
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_classify_url_studio_api() {
366        // Base production URL
367        assert_eq!(
368            classify_url("https://edgefirst.studio/api"),
369            RetryScope::StudioApi
370        );
371
372        // Server-specific instances
373        assert_eq!(
374            classify_url("https://test.edgefirst.studio/api"),
375            RetryScope::StudioApi
376        );
377        assert_eq!(
378            classify_url("https://stage.edgefirst.studio/api"),
379            RetryScope::StudioApi
380        );
381        assert_eq!(
382            classify_url("https://saas.edgefirst.studio/api"),
383            RetryScope::StudioApi
384        );
385        assert_eq!(
386            classify_url("https://ocean.edgefirst.studio/api"),
387            RetryScope::StudioApi
388        );
389
390        // API endpoints with paths
391        assert_eq!(
392            classify_url("https://test.edgefirst.studio/api/datasets"),
393            RetryScope::StudioApi
394        );
395        assert_eq!(
396            classify_url("https://stage.edgefirst.studio/api/auth.login"),
397            RetryScope::StudioApi
398        );
399    }
400
401    #[test]
402    fn test_classify_url_file_io() {
403        // S3 URLs for file operations
404        assert_eq!(
405            classify_url("https://s3.amazonaws.com/bucket/file.bin"),
406            RetryScope::FileIO
407        );
408
409        // CloudFront URLs for file distribution
410        assert_eq!(
411            classify_url("https://d123abc.cloudfront.net/file.bin"),
412            RetryScope::FileIO
413        );
414
415        // Non-API paths on edgefirst.studio domain
416        assert_eq!(
417            classify_url("https://edgefirst.studio/docs"),
418            RetryScope::FileIO
419        );
420        assert_eq!(
421            classify_url("https://test.edgefirst.studio/download_model"),
422            RetryScope::FileIO
423        );
424        assert_eq!(
425            classify_url("https://stage.edgefirst.studio/download_checkpoint"),
426            RetryScope::FileIO
427        );
428
429        // Generic download URLs
430        assert_eq!(
431            classify_url("https://example.com/download"),
432            RetryScope::FileIO
433        );
434    }
435}