perf: Configurable rate limit, 429 auto-retry, concurrent project ingestion
The sync pipeline was bottlenecked at 10 req/s (hardcoded) with sequential project processing and no retry on rate limiting. These changes target 3-5x throughput improvement. Rate limit configuration: - Add requestsPerSecond to SyncConfig (default 30.0, was hardcoded 10) - Pass configured rate through to GitLabClient::new from ingest - Floor rate at 0.1 rps in RateLimiter::new to prevent panic on Duration::from_secs_f64(1.0 / 0.0) — now reachable via user config 429 auto-retry: - Both request() and request_with_headers() retry up to 3 times on HTTP 429, respecting the retry-after header (default 60s) - Extract parse_retry_after helper, reused by handle_response fallback - After exhausting retries, the 429 error propagates as before - Improved JSON decode errors now include a response body preview Concurrent project ingestion: - Derive Clone on GitLabClient (cheap: shares Arc<Mutex<RateLimiter>> and reqwest::Client which is already Arc-backed) - Restructure project loop to use futures::stream::buffer_unordered with primary_concurrency (default 4) as the parallelism bound - Each project gets its own SQLite connection (WAL mode + busy_timeout handles concurrent writes) - Add show_spinner field to IngestDisplay to separate the per-project spinner from the sync-level stage spinner - Error aggregation defers failures: all successful projects get their summaries printed and results counted before returning the first error - Bump dependentConcurrency default from 2 to 8 for discussion prefetch Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,11 @@ struct RateLimiter {
|
||||
|
||||
impl RateLimiter {
|
||||
fn new(requests_per_second: f64) -> Self {
|
||||
// Floor at 0.1 rps to prevent division-by-zero panic in Duration::from_secs_f64
|
||||
let rps = requests_per_second.max(0.1);
|
||||
Self {
|
||||
last_request: Instant::now() - Duration::from_secs(1), // Allow immediate first request
|
||||
min_interval: Duration::from_secs_f64(1.0 / requests_per_second),
|
||||
min_interval: Duration::from_secs_f64(1.0 / rps),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +69,10 @@ fn rand_jitter() -> u64 {
|
||||
}
|
||||
|
||||
/// GitLab API client with rate limiting.
|
||||
///
|
||||
/// Cloning shares the underlying HTTP client and rate limiter,
|
||||
/// making it cheap and safe for concurrent use across projects.
|
||||
#[derive(Clone)]
|
||||
pub struct GitLabClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
@@ -112,28 +118,58 @@ impl GitLabClient {
|
||||
self.request("/api/v4/version").await
|
||||
}
|
||||
|
||||
/// Make an authenticated API request.
|
||||
/// Maximum number of retries on 429 Too Many Requests.
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// Make an authenticated API request with automatic 429 retry.
|
||||
async fn request<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let delay = self.rate_limiter.lock().await.check_delay();
|
||||
if let Some(d) = delay {
|
||||
sleep(d).await;
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
for attempt in 0..=Self::MAX_RETRIES {
|
||||
let delay = self.rate_limiter.lock().await.check_delay();
|
||||
if let Some(d) = delay {
|
||||
sleep(d).await;
|
||||
}
|
||||
|
||||
debug!(url = %url, attempt, "GitLab request");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("PRIVATE-TOKEN", &self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LoreError::GitLabNetworkError {
|
||||
base_url: self.base_url.clone(),
|
||||
source: Some(e),
|
||||
})?;
|
||||
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {
|
||||
let retry_after = Self::parse_retry_after(&response);
|
||||
tracing::warn!(
|
||||
retry_after_secs = retry_after,
|
||||
attempt,
|
||||
path,
|
||||
"Rate limited by GitLab, retrying"
|
||||
);
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
return self.handle_response(response, path).await;
|
||||
}
|
||||
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
debug!(url = %url, "GitLab request");
|
||||
unreachable!("loop always returns")
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("PRIVATE-TOKEN", &self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LoreError::GitLabNetworkError {
|
||||
base_url: self.base_url.clone(),
|
||||
source: Some(e),
|
||||
})?;
|
||||
|
||||
self.handle_response(response, path).await
|
||||
/// Parse retry-after header from a 429 response, defaulting to 60s.
|
||||
fn parse_retry_after(response: &Response) -> u64 {
|
||||
response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60)
|
||||
}
|
||||
|
||||
/// Handle API response, converting errors appropriately.
|
||||
@@ -150,19 +186,22 @@ impl GitLabClient {
|
||||
}),
|
||||
|
||||
StatusCode::TOO_MANY_REQUESTS => {
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60);
|
||||
|
||||
let retry_after = Self::parse_retry_after(&response);
|
||||
Err(LoreError::GitLabRateLimited { retry_after })
|
||||
}
|
||||
|
||||
status if status.is_success() => {
|
||||
let body = response.json().await?;
|
||||
Ok(body)
|
||||
let text = response.text().await?;
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
let preview = if text.len() > 500 {
|
||||
&text[..500]
|
||||
} else {
|
||||
&text
|
||||
};
|
||||
LoreError::Other(format!(
|
||||
"Failed to decode response from {path}: {e}\nResponse preview: {preview}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
status => Err(LoreError::Other(format!(
|
||||
@@ -498,35 +537,52 @@ impl GitLabClient {
|
||||
}
|
||||
|
||||
/// Make an authenticated API request with query parameters, returning headers.
|
||||
/// Automatically retries on 429 Too Many Requests.
|
||||
async fn request_with_headers<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
params: &[(&str, String)],
|
||||
) -> Result<(T, HeaderMap)> {
|
||||
let delay = self.rate_limiter.lock().await.check_delay();
|
||||
if let Some(d) = delay {
|
||||
sleep(d).await;
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
|
||||
for attempt in 0..=Self::MAX_RETRIES {
|
||||
let delay = self.rate_limiter.lock().await.check_delay();
|
||||
if let Some(d) = delay {
|
||||
sleep(d).await;
|
||||
}
|
||||
|
||||
debug!(url = %url, ?params, attempt, "GitLab paginated request");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(params)
|
||||
.header("PRIVATE-TOKEN", &self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LoreError::GitLabNetworkError {
|
||||
base_url: self.base_url.clone(),
|
||||
source: Some(e),
|
||||
})?;
|
||||
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {
|
||||
let retry_after = Self::parse_retry_after(&response);
|
||||
tracing::warn!(
|
||||
retry_after_secs = retry_after,
|
||||
attempt,
|
||||
path,
|
||||
"Rate limited by GitLab, retrying"
|
||||
);
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let headers = response.headers().clone();
|
||||
let body = self.handle_response(response, path).await?;
|
||||
return Ok((body, headers));
|
||||
}
|
||||
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
debug!(url = %url, ?params, "GitLab paginated request");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(params)
|
||||
.header("PRIVATE-TOKEN", &self.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LoreError::GitLabNetworkError {
|
||||
base_url: self.base_url.clone(),
|
||||
source: Some(e),
|
||||
})?;
|
||||
|
||||
let headers = response.headers().clone();
|
||||
let body = self.handle_response(response, path).await?;
|
||||
|
||||
Ok((body, headers))
|
||||
unreachable!("loop always returns")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user