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:
teernisse
2026-03-06 15:23:55 -05:00
parent bf977eca1a
commit e8d6c5b15f
16 changed files with 1974 additions and 1189 deletions

View File

@@ -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());
}