feat(runtime): replace tokio+reqwest with asupersync async runtime
- Add HTTP adapter layer (src/http.rs) wrapping asupersync h1 client - Migrate gitlab client, graphql, and ollama to HTTP adapter - Swap entrypoint from #[tokio::main] to RuntimeBuilder::new().block_on() - Rewrite signal handler for asupersync (RuntimeHandle::spawn + ctrl_c()) - Migrate rate limiter sleeps to asupersync::time::sleep(wall_now(), d) - Add asupersync-native HTTP integration tests - Convert timeline_seed_tests to RuntimeBuilder pattern Phases 1-3 of asupersync migration (atomic: code won't compile without all pieces).
This commit is contained in:
@@ -1,20 +1,19 @@
|
||||
use asupersync::time::{sleep, wall_now};
|
||||
use async_stream::stream;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
use reqwest::header::{ACCEPT, HeaderMap, HeaderValue};
|
||||
use reqwest::{Client, Response, StatusCode};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::debug;
|
||||
|
||||
use super::types::{
|
||||
GitLabDiscussion, GitLabIssue, GitLabIssueRef, GitLabLabelEvent, GitLabMergeRequest,
|
||||
GitLabMilestoneEvent, GitLabMrDiff, GitLabProject, GitLabStateEvent, GitLabUser, GitLabVersion,
|
||||
};
|
||||
use crate::core::error::{LoreError, Result};
|
||||
use crate::http;
|
||||
|
||||
struct RateLimiter {
|
||||
last_request: Instant,
|
||||
@@ -56,9 +55,8 @@ fn rand_jitter() -> u64 {
|
||||
(n ^ nanos) % 50
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GitLabClient {
|
||||
client: Client,
|
||||
client: http::Client,
|
||||
base_url: String,
|
||||
token: String,
|
||||
rate_limiter: Arc<Mutex<RateLimiter>>,
|
||||
@@ -66,27 +64,8 @@ pub struct GitLabClient {
|
||||
|
||||
impl GitLabClient {
|
||||
pub fn new(base_url: &str, token: &str, requests_per_second: Option<f64>) -> Self {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
|
||||
let client = Client::builder()
|
||||
.default_headers(headers.clone())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
warn!(
|
||||
error = %e,
|
||||
"Failed to build configured HTTP client; falling back to default client with timeout"
|
||||
);
|
||||
Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new())
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
client: http::Client::with_timeout(Duration::from_secs(30)),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
rate_limiter: Arc::new(Mutex::new(RateLimiter::new(
|
||||
@@ -142,24 +121,23 @@ impl GitLabClient {
|
||||
limiter.check_delay()
|
||||
};
|
||||
if let Some(d) = delay {
|
||||
sleep(d).await;
|
||||
sleep(wall_now(), 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(),
|
||||
kind: crate::core::error::NetworkErrorKind::Other,
|
||||
detail: Some(format!("{e:?}")),
|
||||
})?;
|
||||
.get(
|
||||
&url,
|
||||
&[
|
||||
("PRIVATE-TOKEN", self.token.as_str()),
|
||||
("Accept", "application/json"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {
|
||||
if response.status == 429 && attempt < Self::MAX_RETRIES {
|
||||
let retry_after = Self::parse_retry_after(&response);
|
||||
tracing::info!(
|
||||
path = %path,
|
||||
@@ -168,7 +146,7 @@ impl GitLabClient {
|
||||
status_code = 429u16,
|
||||
"Rate limited, retrying"
|
||||
);
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
sleep(wall_now(), Duration::from_secs(retry_after)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -177,60 +155,35 @@ impl GitLabClient {
|
||||
}
|
||||
|
||||
self.handle_response(last_response.expect("retry loop ran at least once"), path)
|
||||
.await
|
||||
}
|
||||
|
||||
fn parse_retry_after(response: &Response) -> u64 {
|
||||
fn parse_retry_after(response: &http::Response) -> u64 {
|
||||
response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.header("retry-after")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(60)
|
||||
}
|
||||
|
||||
async fn handle_response<T: serde::de::DeserializeOwned>(
|
||||
fn handle_response<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
response: Response,
|
||||
response: http::Response,
|
||||
path: &str,
|
||||
) -> Result<T> {
|
||||
match response.status() {
|
||||
StatusCode::UNAUTHORIZED => Err(LoreError::GitLabAuthFailed),
|
||||
|
||||
StatusCode::NOT_FOUND => Err(LoreError::GitLabNotFound {
|
||||
match response.status {
|
||||
401 => Err(LoreError::GitLabAuthFailed),
|
||||
404 => Err(LoreError::GitLabNotFound {
|
||||
resource: path.to_string(),
|
||||
}),
|
||||
|
||||
StatusCode::TOO_MANY_REQUESTS => {
|
||||
429 => {
|
||||
let retry_after = Self::parse_retry_after(&response);
|
||||
Err(LoreError::GitLabRateLimited { retry_after })
|
||||
}
|
||||
|
||||
status if status.is_success() => {
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| LoreError::GitLabNetworkError {
|
||||
base_url: self.base_url.clone(),
|
||||
kind: crate::core::error::NetworkErrorKind::Other,
|
||||
detail: Some(format!("{e:?}")),
|
||||
})?;
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
let preview = if text.len() > 500 {
|
||||
&text[..text.floor_char_boundary(500)]
|
||||
} else {
|
||||
&text
|
||||
};
|
||||
LoreError::Other(format!(
|
||||
"Failed to decode response from {path}: {e}\nResponse preview: {preview}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
status => Err(LoreError::Other(format!(
|
||||
"GitLab API error: {} {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("Unknown")
|
||||
_ if response.is_success() => response.json::<T>().map_err(|e| {
|
||||
LoreError::Other(format!("Failed to decode response from {path}: {e}"))
|
||||
}),
|
||||
s => Err(LoreError::Other(format!(
|
||||
"GitLab API error: {s} {}",
|
||||
response.reason
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -278,9 +231,7 @@ impl GitLabClient {
|
||||
yield Ok(issue);
|
||||
}
|
||||
|
||||
let next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
let next_page = header_value(&headers, "x-next-page")
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
match next_page {
|
||||
@@ -334,9 +285,7 @@ impl GitLabClient {
|
||||
yield Ok(discussion);
|
||||
}
|
||||
|
||||
let next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
let next_page = header_value(&headers, "x-next-page")
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
match next_page {
|
||||
@@ -439,10 +388,7 @@ impl GitLabClient {
|
||||
.await?;
|
||||
|
||||
let link_next = parse_link_header_next(&headers);
|
||||
let x_next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
let x_next_page = header_value(&headers, "x-next-page").and_then(|s| s.parse::<u32>().ok());
|
||||
let full_page = items.len() as u32 == per_page;
|
||||
|
||||
let (next_page, is_last_page) = match (link_next.is_some(), x_next_page, full_page) {
|
||||
@@ -490,9 +436,7 @@ impl GitLabClient {
|
||||
}
|
||||
|
||||
let link_next = parse_link_header_next(&headers);
|
||||
let x_next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
let x_next_page = header_value(&headers, "x-next-page")
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
let should_continue = match (link_next.is_some(), x_next_page, full_page) {
|
||||
@@ -528,7 +472,7 @@ impl GitLabClient {
|
||||
&self,
|
||||
path: &str,
|
||||
params: &[(&str, String)],
|
||||
) -> Result<(T, HeaderMap)> {
|
||||
) -> Result<(T, Vec<(String, String)>)> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let mut last_response = None;
|
||||
|
||||
@@ -544,25 +488,24 @@ impl GitLabClient {
|
||||
limiter.check_delay()
|
||||
};
|
||||
if let Some(d) = delay {
|
||||
sleep(d).await;
|
||||
sleep(wall_now(), 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(),
|
||||
kind: crate::core::error::NetworkErrorKind::Other,
|
||||
detail: Some(format!("{e:?}")),
|
||||
})?;
|
||||
.get_with_query(
|
||||
&url,
|
||||
params,
|
||||
&[
|
||||
("PRIVATE-TOKEN", self.token.as_str()),
|
||||
("Accept", "application/json"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {
|
||||
if response.status == 429 && attempt < Self::MAX_RETRIES {
|
||||
let retry_after = Self::parse_retry_after(&response);
|
||||
tracing::info!(
|
||||
path = %path,
|
||||
@@ -571,7 +514,7 @@ impl GitLabClient {
|
||||
status_code = 429u16,
|
||||
"Rate limited, retrying"
|
||||
);
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
sleep(wall_now(), Duration::from_secs(retry_after)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -580,8 +523,8 @@ impl GitLabClient {
|
||||
}
|
||||
|
||||
let response = last_response.expect("retry loop ran at least once");
|
||||
let headers = response.headers().clone();
|
||||
let body = self.handle_response(response, path).await?;
|
||||
let headers = response.headers.clone();
|
||||
let body = self.handle_response(response, path)?;
|
||||
Ok((body, headers))
|
||||
}
|
||||
}
|
||||
@@ -640,10 +583,8 @@ impl GitLabClient {
|
||||
let full_page = items.len() as u32 == per_page;
|
||||
results.extend(items);
|
||||
|
||||
let next_page = headers
|
||||
.get("x-next-page")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
let next_page =
|
||||
header_value(&headers, "x-next-page").and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
match next_page {
|
||||
Some(next) if next > page => page = next,
|
||||
@@ -788,22 +729,26 @@ pub struct MergeRequestPage {
|
||||
pub is_last_page: bool,
|
||||
}
|
||||
|
||||
fn parse_link_header_next(headers: &HeaderMap) -> Option<String> {
|
||||
fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
headers
|
||||
.get("link")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|link_str| {
|
||||
for part in link_str.split(',') {
|
||||
let part = part.trim();
|
||||
if (part.contains("rel=\"next\"") || part.contains("rel=next"))
|
||||
&& let Some(start) = part.find('<')
|
||||
&& let Some(end) = part.find('>')
|
||||
{
|
||||
return Some(part[start + 1..end].to_string());
|
||||
}
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(name))
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
fn parse_link_header_next(headers: &[(String, String)]) -> Option<String> {
|
||||
header_value(headers, "link").and_then(|link_str| {
|
||||
for part in link_str.split(',') {
|
||||
let part = part.trim();
|
||||
if (part.contains("rel=\"next\"") || part.contains("rel=next"))
|
||||
&& let Some(start) = part.find('<')
|
||||
&& let Some(end) = part.find('>')
|
||||
{
|
||||
return Some(part[start + 1..end].to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn coalesce_not_found<T>(result: Result<Vec<T>>) -> Result<Vec<T>> {
|
||||
@@ -863,13 +808,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_extracts_next_url() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static(
|
||||
r#"<https://gitlab.example.com/api/v4/projects/1/merge_requests?page=2>; rel="next", <https://gitlab.example.com/api/v4/projects/1/merge_requests?page=5>; rel="last""#,
|
||||
),
|
||||
);
|
||||
let headers = vec![(
|
||||
"link".to_string(),
|
||||
r#"<https://gitlab.example.com/api/v4/projects/1/merge_requests?page=2>; rel="next", <https://gitlab.example.com/api/v4/projects/1/merge_requests?page=5>; rel="last""#.to_string(),
|
||||
)];
|
||||
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert_eq!(
|
||||
@@ -880,11 +822,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_handles_unquoted_rel() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static(r#"<https://example.com/next>; rel=next"#),
|
||||
);
|
||||
let headers = vec![(
|
||||
"link".to_string(),
|
||||
r#"<https://example.com/next>; rel=next"#.to_string(),
|
||||
)];
|
||||
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert_eq!(result, Some("https://example.com/next".to_string()));
|
||||
@@ -892,11 +833,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_returns_none_when_no_next() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"link",
|
||||
HeaderValue::from_static(r#"<https://example.com/last>; rel="last""#),
|
||||
);
|
||||
let headers = vec![(
|
||||
"link".to_string(),
|
||||
r#"<https://example.com/last>; rel="last""#.to_string(),
|
||||
)];
|
||||
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert!(result.is_none());
|
||||
@@ -904,7 +844,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_link_header_returns_none_when_missing() {
|
||||
let headers = HeaderMap::new();
|
||||
let headers: Vec<(String, String)> = vec![];
|
||||
let result = parse_link_header_next(&headers);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user