use std::time::Duration;
use reqwest::{Method, StatusCode};
use secrecy::{ExposeSecret, SecretString};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::error::{
ErrorCode, OlError, OL_4230_BACKEND_4XX, OL_4231_BACKEND_5XX, OL_4232_BACKEND_UNAUTHORIZED,
OL_4233_BACKEND_FORBIDDEN, OL_4234_BACKEND_NOT_FOUND, OL_4235_BACKEND_CONFLICT,
OL_4236_BACKEND_GONE, OL_4237_BACKEND_INTERNAL, OL_4238_BACKEND_BAD_GATEWAY,
OL_4239_BACKEND_UNAVAILABLE, OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG,
OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG, OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG,
OL_4283_PLATFORM_DUPLICATE_BINDING, OL_4290_RATE_LIMIT,
};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const RETRY_BACKOFF: [Duration; 3] = [
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
];
pub struct ApiClient {
inner: reqwest::Client,
base_url: String,
token: SecretString,
}
impl ApiClient {
pub fn new(base_url: impl Into<String>, token: SecretString) -> Result<Self, OlError> {
let inner = reqwest::Client::builder()
.use_rustls_tls()
.timeout(DEFAULT_TIMEOUT)
.build()
.map_err(|e| OlError::new(OL_4231_BACKEND_5XX, format!("reqwest builder: {e}")))?;
Ok(Self {
inner,
base_url: base_url.into().trim_end_matches('/').to_string(),
token,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R, OlError> {
let url = self.url(path);
self.send_with_retry(Method::GET, &url, None::<&()>).await
}
pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R, OlError> {
let url = self.url(path);
self.send_with_retry(Method::DELETE, &url, None::<&()>)
.await
}
pub async fn post<B: Serialize, R: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<R, OlError> {
let url = self.url(path);
self.send_with_retry(Method::POST, &url, Some(body)).await
}
pub async fn patch<B: Serialize, R: DeserializeOwned>(
&self,
path: &str,
body: &B,
) -> Result<R, OlError> {
let url = self.url(path);
self.send_with_retry(Method::PATCH, &url, Some(body)).await
}
fn url(&self, path: &str) -> String {
let path = path.strip_prefix('/').unwrap_or(path);
format!("{}/{}", self.base_url, path)
}
async fn send_with_retry<B: Serialize, R: DeserializeOwned>(
&self,
method: Method,
url: &str,
body: Option<&B>,
) -> Result<R, OlError> {
for (attempt, backoff) in std::iter::once(Duration::ZERO)
.chain(RETRY_BACKOFF.iter().copied())
.enumerate()
{
if !backoff.is_zero() {
tokio::time::sleep(backoff).await;
}
let mut req = self
.inner
.request(method.clone(), url)
.bearer_auth(self.token.expose_secret());
if let Some(b) = body {
req = req.json(b);
}
let resp = match req.send().await {
Ok(r) => r,
Err(e) if attempt < RETRY_BACKOFF.len() => {
tracing::warn!(error = %e, "api request failed; retrying");
continue;
}
Err(e) => {
return Err(OlError::new(
OL_4231_BACKEND_5XX,
format!("network error after {} attempts: {e}", attempt + 1),
));
}
};
let status = resp.status();
if status.is_server_error() && attempt < RETRY_BACKOFF.len() {
tracing::warn!(status = status.as_u16(), "5xx response; retrying");
continue;
}
return decode(resp).await;
}
unreachable!("retry loop must always return")
}
}
#[derive(serde::Deserialize)]
struct PlatformErrorEnvelope {
error: PlatformErrorBody,
}
#[derive(serde::Deserialize)]
struct PlatformErrorBody {
#[serde(default)]
code: String,
#[serde(default)]
message: String,
}
async fn decode<R: DeserializeOwned>(resp: reqwest::Response) -> Result<R, OlError> {
let status = resp.status();
if status.is_success() {
return resp
.json::<R>()
.await
.map_err(|e| OlError::new(OL_4230_BACKEND_4XX, format!("parse response: {e}")));
}
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body = resp.text().await.unwrap_or_default();
if let Ok(env) = serde_json::from_str::<PlatformErrorEnvelope>(&body) {
if let Some(cli_code) = map_platform_code(&env.error.code) {
let message = if env.error.message.is_empty() {
format!("platform reported {}", env.error.code)
} else {
env.error.message.clone()
};
let mut err = OlError::new(cli_code, message);
err = err.with_context(serde_json::json!({
"platform_code": env.error.code,
"http_status": status.as_u16(),
}));
return Err(err);
}
}
Err(map_status(status, &body, retry_after.as_deref()))
}
fn map_platform_code(platform: &str) -> Option<ErrorCode> {
match platform {
"OL-4203" => Some(OL_4280_PLATFORM_DUPLICATE_TOOL_SLUG),
"OL-4204" => Some(OL_4281_PLATFORM_DUPLICATE_PROVIDER_SLUG),
"OL-4214" => Some(OL_4282_PLATFORM_DUPLICATE_EDITOR_SLUG),
"OL-4215" => Some(OL_4283_PLATFORM_DUPLICATE_BINDING),
_ => None,
}
}
fn map_status(status: StatusCode, body: &str, retry_after: Option<&str>) -> OlError {
let snippet = truncate(body, 240);
match status {
StatusCode::UNAUTHORIZED => {
OlError::new(OL_4232_BACKEND_UNAUTHORIZED, format!("401: {snippet}"))
}
StatusCode::FORBIDDEN => OlError::new(OL_4233_BACKEND_FORBIDDEN, format!("403: {snippet}")),
StatusCode::NOT_FOUND => OlError::new(OL_4234_BACKEND_NOT_FOUND, format!("404: {snippet}")),
StatusCode::CONFLICT => OlError::new(OL_4235_BACKEND_CONFLICT, format!("409: {snippet}")),
StatusCode::GONE => OlError::new(OL_4236_BACKEND_GONE, format!("410: {snippet}")),
StatusCode::TOO_MANY_REQUESTS => {
let mut e = OlError::new(OL_4290_RATE_LIMIT, format!("429 rate-limited: {snippet}"));
if let Some(s) = retry_after {
e = e.with_suggestion(format!("Retry after {s}s."));
}
e
}
StatusCode::INTERNAL_SERVER_ERROR => {
OlError::new(OL_4237_BACKEND_INTERNAL, format!("500: {snippet}"))
}
StatusCode::BAD_GATEWAY => {
OlError::new(OL_4238_BACKEND_BAD_GATEWAY, format!("502: {snippet}"))
}
StatusCode::SERVICE_UNAVAILABLE => {
OlError::new(OL_4239_BACKEND_UNAVAILABLE, format!("503: {snippet}"))
}
s if s.is_client_error() => {
OlError::new(OL_4230_BACKEND_4XX, format!("{}: {snippet}", s.as_u16()))
}
s => OlError::new(OL_4231_BACKEND_5XX, format!("{}: {snippet}", s.as_u16())),
}
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}…", &s[..n])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn map_status_routes_401_to_unauthorized() {
let e = map_status(StatusCode::UNAUTHORIZED, "{}", None);
assert_eq!(e.code.code, "OL-4232");
}
#[test]
fn map_status_routes_404_to_not_found() {
let e = map_status(StatusCode::NOT_FOUND, "missing", None);
assert_eq!(e.code.code, "OL-4234");
}
#[test]
fn map_status_routes_429_with_retry_after() {
let e = map_status(StatusCode::TOO_MANY_REQUESTS, "slow down", Some("60"));
assert_eq!(e.code.code, "OL-4290");
assert!(e.suggestion.unwrap().contains("60"));
}
#[test]
fn map_status_routes_500_to_internal() {
let e = map_status(StatusCode::INTERNAL_SERVER_ERROR, "boom", None);
assert_eq!(e.code.code, "OL-4237");
}
#[test]
fn map_platform_code_remaps_known_codes() {
assert_eq!(map_platform_code("OL-4203").unwrap().code, "OL-4280");
assert_eq!(map_platform_code("OL-4204").unwrap().code, "OL-4281");
assert_eq!(map_platform_code("OL-4214").unwrap().code, "OL-4282");
assert_eq!(map_platform_code("OL-4215").unwrap().code, "OL-4283");
assert!(map_platform_code("OL-4242").is_none());
assert!(map_platform_code("").is_none());
}
#[test]
fn platform_error_envelope_parses() {
let body = r#"{"error":{"code":"OL-4203","message":"Tool slug already in use."}}"#;
let env: PlatformErrorEnvelope = serde_json::from_str(body).unwrap();
assert_eq!(env.error.code, "OL-4203");
assert_eq!(env.error.message, "Tool slug already in use.");
}
#[test]
fn truncate_caps_long_strings() {
let s = "a".repeat(500);
let got = truncate(&s, 240);
assert_eq!(got.len(), 240 + '…'.len_utf8());
assert!(got.ends_with('…'));
}
}