feat(intel-dcap-api): add automatic retry logic for 429 rate limiting

- Add `max_retries` field to ApiClient with default of 3 retries
- Implement `execute_with_retry()` helper method in helpers.rs
- Update all HTTP requests to use retry wrapper for automatic 429 handling
- Add `TooManyRequests` error variant with request_id and retry_after fields
- Respect Retry-After header duration before retrying requests
- Add `set_max_retries()` method to configure retry behavior (0 disables)
- Update documentation and add handle_rate_limit example
- Enhanced error handling in check_status() for 429 responses

The client now transparently handles Intel API rate limiting while remaining
configurable for users who need different retry behavior or manual handling.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Harald Hoyer <harald@matterlabs.dev>
This commit is contained in:
Harald Hoyer 2025-05-28 11:00:03 +02:00
parent 205113ecfa
commit bb9c5b195e
Signed by: harald
GPG key ID: F519A1143B3FBE32
9 changed files with 267 additions and 15 deletions

View file

@ -16,6 +16,7 @@ and enclave identity verification.
- Type-safe request/response structures - Type-safe request/response structures
- Support for SGX and TDX platforms - Support for SGX and TDX platforms
- Real data integration tests - Real data integration tests
- **Automatic rate limit handling with configurable retries**
## Development Commands ## Development Commands
@ -36,6 +37,7 @@ cargo run --example get_pck_crl # Fetch certificate revocation lists
cargo run --example common_usage # Common attestation verification patterns cargo run --example common_usage # Common attestation verification patterns
cargo run --example integration_test # Comprehensive test of most API endpoints cargo run --example integration_test # Comprehensive test of most API endpoints
cargo run --example fetch_test_data # Fetch real data from Intel API for tests cargo run --example fetch_test_data # Fetch real data from Intel API for tests
cargo run --example handle_rate_limit # Demonstrate automatic rate limiting handling
``` ```
## Architecture ## Architecture
@ -45,6 +47,8 @@ cargo run --example fetch_test_data # Fetch real data from Intel API for tests
- **ApiClient** (`src/client/mod.rs`): Main entry point supporting API v3/v4 - **ApiClient** (`src/client/mod.rs`): Main entry point supporting API v3/v4
- Base URL: https://api.trustedservices.intel.com - Base URL: https://api.trustedservices.intel.com
- Manages HTTP client and API version selection - Manages HTTP client and API version selection
- Automatic retry logic for 429 (Too Many Requests) responses
- Default: 3 retries, configurable via `set_max_retries()`
### Key Modules ### Key Modules
@ -69,6 +73,7 @@ cargo run --example fetch_test_data # Fetch real data from Intel API for tests
- **error.rs**: `IntelApiError` for comprehensive error handling - **error.rs**: `IntelApiError` for comprehensive error handling
- Extracts error details from Error-Code and Error-Message headers - Extracts error details from Error-Code and Error-Message headers
- **`TooManyRequests` variant for rate limiting (429) after retry exhaustion**
- **types.rs**: Enums (CaType, ApiVersion, UpdateType, etc.) - **types.rs**: Enums (CaType, ApiVersion, UpdateType, etc.)
- **requests.rs**: Request structures - **requests.rs**: Request structures
- **responses.rs**: Response structures with JSON and certificate data - **responses.rs**: Response structures with JSON and certificate data
@ -78,10 +83,18 @@ cargo run --example fetch_test_data # Fetch real data from Intel API for tests
All client methods follow this pattern: All client methods follow this pattern:
1. Build request with query parameters 1. Build request with query parameters
2. Send HTTP request with proper headers 2. Send HTTP request with proper headers (with automatic retry on 429)
3. Parse response (JSON + certificate chains) 3. Parse response (JSON + certificate chains)
4. Return typed response or error 4. Return typed response or error
### Rate Limiting & Retry Logic
- **Automatic Retries**: All HTTP requests automatically retry on 429 (Too Many Requests) responses
- **Retry Configuration**: Default 3 retries, configurable via `ApiClient::set_max_retries()`
- **Retry-After Handling**: Waits for duration specified in Retry-After header before retrying
- **Error Handling**: `IntelApiError::TooManyRequests` returned only after all retries exhausted
- **Implementation**: `execute_with_retry()` in `src/client/helpers.rs` handles retry logic
### Testing Strategy ### Testing Strategy
- **Mock Tests**: Two test suites using mockito for HTTP mocking - **Mock Tests**: Two test suites using mockito for HTTP mocking
@ -114,6 +127,8 @@ All client methods follow this pattern:
1. **Mockito Header Encoding**: Always URL-encode headers containing newlines/special characters 1. **Mockito Header Encoding**: Always URL-encode headers containing newlines/special characters
2. **API Version Selection**: Some endpoints are V4-only and will return errors on V3 2. **API Version Selection**: Some endpoints are V4-only and will return errors on V3
3. **Rate Limiting**: Client automatically retries 429 responses; disable with `set_max_retries(0)` if manual handling
needed
4. **Platform Filters**: Only certain values are valid (All, Client, E3, E5) 4. **Platform Filters**: Only certain values are valid (All, Client, E3, E5)
5. **Test Data**: PCK certificate endpoints require valid platform data and often need subscription keys 5. **Test Data**: PCK certificate endpoints require valid platform data and often need subscription keys
6. **Issuer Chain Validation**: Always check that `issuer_chain` is non-empty - it's critical for signature verification 6. **Issuer Chain Validation**: Always check that `issuer_chain` is non-empty - it's critical for signature verification

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2025 Matter Labs
//! Example demonstrating automatic rate limit handling
//!
//! The Intel DCAP API client now automatically handles 429 Too Many Requests responses
//! by retrying up to 3 times by default. This example shows how to configure the retry
//! behavior and handle cases where all retries are exhausted.
use intel_dcap_api::{ApiClient, IntelApiError};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create API client with default settings (3 retries)
let mut client = ApiClient::new()?;
println!("Example 1: Default behavior (automatic retries)");
println!("================================================");
// Example FMSPC value
let fmspc = "00606A000000";
// The client will automatically retry up to 3 times if rate limited
match client.get_sgx_tcb_info(fmspc, None, None).await {
Ok(tcb_info) => {
println!("✓ Successfully retrieved TCB info");
println!(
" TCB Info JSON length: {} bytes",
tcb_info.tcb_info_json.len()
);
println!(
" Issuer Chain length: {} bytes",
tcb_info.issuer_chain.len()
);
}
Err(IntelApiError::TooManyRequests {
request_id,
retry_after,
}) => {
println!("✗ Rate limited even after 3 automatic retries");
println!(" Request ID: {}", request_id);
println!(" Last retry-after was: {} seconds", retry_after);
}
Err(e) => {
eprintln!("✗ Other error: {}", e);
}
}
println!("\nExample 2: Custom retry configuration");
println!("=====================================");
// Configure client to retry up to 5 times
client.set_max_retries(5);
println!("Set max retries to 5");
match client.get_sgx_tcb_info(fmspc, None, None).await {
Ok(_) => println!("✓ Request succeeded"),
Err(IntelApiError::TooManyRequests { .. }) => {
println!("✗ Still rate limited after 5 retries")
}
Err(e) => eprintln!("✗ Error: {}", e),
}
println!("\nExample 3: Disable automatic retries");
println!("====================================");
// Disable automatic retries
client.set_max_retries(0);
println!("Disabled automatic retries");
match client.get_sgx_tcb_info(fmspc, None, None).await {
Ok(_) => println!("✓ Request succeeded on first attempt"),
Err(IntelApiError::TooManyRequests {
request_id,
retry_after,
}) => {
println!("✗ Rate limited (no automatic retry)");
println!(" Request ID: {}", request_id);
println!(" Retry after: {} seconds", retry_after);
println!(" You would need to implement manual retry logic here");
}
Err(e) => eprintln!("✗ Error: {}", e),
}
println!("\nNote: The client handles rate limiting automatically!");
println!("You only need to handle TooManyRequests errors if:");
println!("- You disable automatic retries (set_max_retries(0))");
println!("- All automatic retries are exhausted");
Ok(())
}

View file

@ -46,7 +46,7 @@ impl ApiClient {
} }
let request_builder = self.client.get(url); let request_builder = self.client.get(url);
let response = request_builder.send().await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::OK]).await?; let response = check_status(response, &[StatusCode::OK]).await?;
let fmspcs_json = response.text().await?; let fmspcs_json = response.text().await?;

View file

@ -12,6 +12,8 @@ use crate::{
use percent_encoding::percent_decode_str; use percent_encoding::percent_decode_str;
use reqwest::{RequestBuilder, Response, StatusCode}; use reqwest::{RequestBuilder, Response, StatusCode};
use std::io; use std::io;
use std::time::Duration;
use tokio::time::sleep;
impl ApiClient { impl ApiClient {
/// Helper to construct API paths dynamically based on version and technology (SGX/TDX). /// Helper to construct API paths dynamically based on version and technology (SGX/TDX).
@ -84,7 +86,7 @@ impl ApiClient {
&self, &self,
request_builder: RequestBuilder, request_builder: RequestBuilder,
) -> Result<PckCertificateResponse, IntelApiError> { ) -> Result<PckCertificateResponse, IntelApiError> {
let response = request_builder.send().await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::OK]).await?; let response = check_status(response, &[StatusCode::OK]).await?;
let issuer_chain = self.get_required_header( let issuer_chain = self.get_required_header(
@ -109,7 +111,7 @@ impl ApiClient {
&self, &self,
request_builder: RequestBuilder, request_builder: RequestBuilder,
) -> Result<PckCertificatesResponse, IntelApiError> { ) -> Result<PckCertificatesResponse, IntelApiError> {
let response = request_builder.send().await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::OK]).await?; let response = check_status(response, &[StatusCode::OK]).await?;
let issuer_chain = self.get_required_header( let issuer_chain = self.get_required_header(
@ -134,7 +136,7 @@ impl ApiClient {
v4_issuer_chain_header: &'static str, v4_issuer_chain_header: &'static str,
v3_issuer_chain_header: Option<&'static str>, v3_issuer_chain_header: Option<&'static str>,
) -> Result<(String, String), IntelApiError> { ) -> Result<(String, String), IntelApiError> {
let response = request_builder.send().await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::OK]).await?; let response = check_status(response, &[StatusCode::OK]).await?;
let issuer_chain = let issuer_chain =
@ -158,7 +160,7 @@ impl ApiClient {
)) ))
})?; })?;
let response = builder_clone.send().await?; let response = self.execute_with_retry(builder_clone).await?;
let status = response.status(); let status = response.status();
if status == StatusCode::NOT_FOUND || status == StatusCode::GONE { if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
@ -224,4 +226,69 @@ impl ApiClient {
Ok(()) Ok(())
} }
} }
/// Executes a request with automatic retry logic for rate limiting (429 responses).
///
/// This method will automatically retry the request up to `max_retries` times
/// when receiving a 429 Too Many Requests response, waiting for the duration
/// specified in the Retry-After header.
pub(super) async fn execute_with_retry(
&self,
request_builder: RequestBuilder,
) -> Result<Response, IntelApiError> {
let mut retries = 0;
loop {
// Clone the request builder for retry attempts
let builder = request_builder.try_clone().ok_or_else(|| {
IntelApiError::Io(io::Error::new(
io::ErrorKind::Other,
"Failed to clone request builder for retry",
))
})?;
let response = builder.send().await?;
let status = response.status();
if status != StatusCode::TOO_MANY_REQUESTS {
// Not a rate limit error, return the response
return Ok(response);
}
// Handle 429 Too Many Requests
if retries >= self.max_retries {
// No more retries, return the error
let request_id = response
.headers()
.get("Request-ID")
.and_then(|v| v.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60);
return Err(IntelApiError::TooManyRequests {
request_id,
retry_after,
});
}
// Parse Retry-After header
let retry_after_secs = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60); // Default to 60 seconds
// Wait before retrying
sleep(Duration::from_secs(retry_after_secs)).await;
retries += 1;
}
}
} }

View file

@ -45,6 +45,8 @@ pub struct ApiClient {
client: Client, client: Client,
base_url: Url, base_url: Url,
api_version: ApiVersion, api_version: ApiVersion,
/// Maximum number of automatic retries for rate-limited requests (429 responses)
max_retries: u32,
} }
impl ApiClient { impl ApiClient {
@ -114,6 +116,20 @@ impl ApiClient {
.build()?, .build()?,
base_url: base_url.into_url()?, base_url: base_url.into_url()?,
api_version, api_version,
max_retries: 3, // Default to 3 retries
}) })
} }
/// Sets the maximum number of automatic retries for rate-limited requests.
///
/// When the API returns a 429 (Too Many Requests) response, the client will
/// automatically wait for the duration specified in the Retry-After header
/// and retry the request up to this many times.
///
/// # Arguments
///
/// * `max_retries` - Maximum number of retries (0 disables automatic retries)
pub fn set_max_retries(&mut self, max_retries: u32) {
self.max_retries = max_retries;
}
} }

View file

@ -49,7 +49,7 @@ impl ApiClient {
} }
let request_builder = self.client.get(url); let request_builder = self.client.get(url);
let response = request_builder.send().await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::OK]).await?; let response = check_status(response, &[StatusCode::OK]).await?;
let issuer_chain = self.get_required_header( let issuer_chain = self.get_required_header(

View file

@ -36,13 +36,13 @@ impl ApiClient {
let path = self.build_api_path("sgx", "registration", "platform")?; let path = self.build_api_path("sgx", "registration", "platform")?;
let url = self.base_url.join(&path)?; let url = self.base_url.join(&path)?;
let response = self let request_builder = self
.client .client
.post(url) .post(url)
.header(header::CONTENT_TYPE, "application/octet-stream") .header(header::CONTENT_TYPE, "application/octet-stream")
.body(platform_manifest) .body(platform_manifest);
.send()
.await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::CREATED]).await?; let response = check_status(response, &[StatusCode::CREATED]).await?;
@ -81,14 +81,14 @@ impl ApiClient {
let path = self.build_api_path("sgx", "registration", "package")?; let path = self.build_api_path("sgx", "registration", "package")?;
let url = self.base_url.join(&path)?; let url = self.base_url.join(&path)?;
let response = self let request_builder = self
.client .client
.post(url) .post(url)
.header("Ocp-Apim-Subscription-Key", subscription_key) .header("Ocp-Apim-Subscription-Key", subscription_key)
.header(header::CONTENT_TYPE, "application/octet-stream") .header(header::CONTENT_TYPE, "application/octet-stream")
.body(add_package_request) .body(add_package_request);
.send()
.await?; let response = self.execute_with_retry(request_builder).await?;
let response = check_status(response, &[StatusCode::OK]).await?; let response = check_status(response, &[StatusCode::OK]).await?;

View file

@ -59,6 +59,40 @@ pub enum IntelApiError {
/// Indicates an invalid parameter was provided. /// Indicates an invalid parameter was provided.
#[error("Invalid parameter value: {0}")] #[error("Invalid parameter value: {0}")]
InvalidParameter(&'static str), InvalidParameter(&'static str),
/// Indicates that the API rate limit has been exceeded (HTTP 429).
///
/// This error is returned after the client has exhausted all automatic retry attempts
/// for a rate-limited request. The `retry_after` field contains the number of seconds
/// that was specified in the last Retry-After header. By default, the client automatically
/// retries rate-limited requests up to 3 times.
///
/// # Example
///
/// ```rust,no_run
/// use intel_dcap_api::{ApiClient, IntelApiError};
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = ApiClient::new()?;
/// client.set_max_retries(0); // Disable automatic retries
///
/// match client.get_sgx_tcb_info("00606A000000", None, None).await {
/// Ok(tcb_info) => println!("Success"),
/// Err(IntelApiError::TooManyRequests { request_id, retry_after }) => {
/// println!("Rate limited after all retries. Last retry-after was {} seconds.", retry_after);
/// }
/// Err(e) => eprintln!("Other error: {}", e),
/// }
/// # Ok(())
/// # }
/// ```
#[error("Too many requests. Retry after {retry_after} seconds")]
TooManyRequests {
/// The unique request identifier for tracing.
request_id: String,
/// Number of seconds to wait before retrying, from Retry-After header.
retry_after: u64,
},
} }
/// Extracts common API error details from response headers. /// Extracts common API error details from response headers.
@ -92,6 +126,27 @@ pub(crate) async fn check_status(
let status = response.status(); let status = response.status();
if expected_statuses.contains(&status) { if expected_statuses.contains(&status) {
Ok(response) Ok(response)
} else if status == StatusCode::TOO_MANY_REQUESTS {
// Handle 429 Too Many Requests with Retry-After header
let request_id = response
.headers()
.get("Request-ID")
.and_then(|v| v.to_str().ok())
.unwrap_or("Unknown")
.to_string();
// Parse Retry-After header (can be in seconds or HTTP date format)
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(60); // Default to 60 seconds if header is missing or invalid
Err(IntelApiError::TooManyRequests {
request_id,
retry_after,
})
} else { } else {
let (request_id, error_code, error_message) = extract_api_error_details(&response); let (request_id, error_code, error_message) = extract_api_error_details(&response);
Err(IntelApiError::ApiError { Err(IntelApiError::ApiError {

View file

@ -8,6 +8,14 @@
//! //!
//! Create an [`ApiClient`] to interface with the Intel API. //! Create an [`ApiClient`] to interface with the Intel API.
//! //!
//! # Rate Limiting
//!
//! The Intel API implements rate limiting and may return HTTP 429 (Too Many Requests) responses.
//! This client automatically handles rate limiting by retrying requests up to 3 times by default,
//! waiting for the duration specified in the `Retry-After` header. You can configure the retry
//! behavior using [`ApiClient::set_max_retries`]. If all retries are exhausted, the client
//! returns an [`IntelApiError::TooManyRequests`] error.
//!
//! Example //! Example
//! ```rust,no_run //! ```rust,no_run
//! use intel_dcap_api::{ApiClient, IntelApiError, TcbInfoResponse}; //! use intel_dcap_api::{ApiClient, IntelApiError, TcbInfoResponse};