Skip to content

Commit 3e66c53

Browse files
committed
Harden adapters with retries and normalize config
1 parent 769463e commit 3e66c53

File tree

5 files changed

+197
-46
lines changed

5 files changed

+197
-46
lines changed

src/adapters/anthropic.rs

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use anyhow::{Context, Result};
22
use async_trait::async_trait;
3-
use reqwest::Client;
3+
use reqwest::{Client, StatusCode};
44
use serde::{Deserialize, Serialize};
5+
use std::time::Duration;
6+
use tokio::time::sleep;
57
use crate::adapters::llm::{LLMAdapter, LLMRequest, LLMResponse, ModelConfig, Usage};
68

79
pub struct AnthropicAdapter {
@@ -66,6 +68,42 @@ impl AnthropicAdapter {
6668
base_url,
6769
})
6870
}
71+
72+
async fn send_with_retry<F>(&self, mut make_request: F) -> Result<reqwest::Response>
73+
where
74+
F: FnMut() -> reqwest::RequestBuilder,
75+
{
76+
const MAX_RETRIES: usize = 2;
77+
const BASE_DELAY_MS: u64 = 250;
78+
79+
for attempt in 0..=MAX_RETRIES {
80+
match make_request().send().await {
81+
Ok(response) => {
82+
if response.status().is_success() {
83+
return Ok(response);
84+
}
85+
86+
let status = response.status();
87+
let body = response.text().await.unwrap_or_default();
88+
if is_retryable_status(status) && attempt < MAX_RETRIES {
89+
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
90+
continue;
91+
}
92+
93+
anyhow::bail!("Anthropic API error ({}): {}", status, body);
94+
}
95+
Err(err) => {
96+
if attempt < MAX_RETRIES {
97+
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
98+
continue;
99+
}
100+
return Err(err.into());
101+
}
102+
}
103+
}
104+
105+
anyhow::bail!("Anthropic request failed after retries");
106+
}
69107
}
70108

71109
#[async_trait]
@@ -86,21 +124,18 @@ impl LLMAdapter for AnthropicAdapter {
86124
system: request.system_prompt,
87125
};
88126

89-
let response = self.client
90-
.post(format!("{}/messages", self.base_url))
91-
.header("x-api-key", &self.api_key)
92-
.header("anthropic-version", "2023-06-01")
93-
.header("anthropic-beta", "messages-2023-12-15")
94-
.header("Content-Type", "application/json")
95-
.json(&anthropic_request)
96-
.send()
97-
.await
98-
.context("Failed to send request to Anthropic")?;
99-
100-
if !response.status().is_success() {
101-
let error_text = response.text().await?;
102-
anyhow::bail!("Anthropic API error: {}", error_text);
103-
}
127+
let url = format!("{}/messages", self.base_url);
128+
let response = self.send_with_retry(|| {
129+
self.client
130+
.post(&url)
131+
.header("x-api-key", &self.api_key)
132+
.header("anthropic-version", "2023-06-01")
133+
.header("anthropic-beta", "messages-2023-12-15")
134+
.header("Content-Type", "application/json")
135+
.json(&anthropic_request)
136+
})
137+
.await
138+
.context("Failed to send request to Anthropic")?;
104139

105140
let anthropic_response: AnthropicResponse = response.json().await
106141
.context("Failed to parse Anthropic response")?;
@@ -131,4 +166,8 @@ impl LLMAdapter for AnthropicAdapter {
131166
fn _model_name(&self) -> &str {
132167
&self.config.model_name
133168
}
134-
}
169+
}
170+
171+
fn is_retryable_status(status: StatusCode) -> bool {
172+
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
173+
}

src/adapters/ollama.rs

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use anyhow::{Context, Result};
22
use async_trait::async_trait;
3-
use reqwest::Client;
3+
use reqwest::{Client, StatusCode};
44
use serde::{Deserialize, Serialize};
5+
use std::time::Duration;
6+
use tokio::time::sleep;
57
use crate::adapters::llm::{LLMAdapter, LLMRequest, LLMResponse, ModelConfig, Usage};
68

79
pub struct OllamaAdapter {
@@ -46,6 +48,42 @@ impl OllamaAdapter {
4648
base_url,
4749
})
4850
}
51+
52+
async fn send_with_retry<F>(&self, mut make_request: F) -> Result<reqwest::Response>
53+
where
54+
F: FnMut() -> reqwest::RequestBuilder,
55+
{
56+
const MAX_RETRIES: usize = 2;
57+
const BASE_DELAY_MS: u64 = 250;
58+
59+
for attempt in 0..=MAX_RETRIES {
60+
match make_request().send().await {
61+
Ok(response) => {
62+
if response.status().is_success() {
63+
return Ok(response);
64+
}
65+
66+
let status = response.status();
67+
let body = response.text().await.unwrap_or_default();
68+
if is_retryable_status(status) && attempt < MAX_RETRIES {
69+
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
70+
continue;
71+
}
72+
73+
anyhow::bail!("Ollama API error ({}): {}", status, body);
74+
}
75+
Err(err) => {
76+
if attempt < MAX_RETRIES {
77+
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
78+
continue;
79+
}
80+
return Err(err.into());
81+
}
82+
}
83+
}
84+
85+
anyhow::bail!("Ollama request failed after retries");
86+
}
4987
}
5088

5189
#[async_trait]
@@ -64,17 +102,14 @@ impl LLMAdapter for OllamaAdapter {
64102
stream: false,
65103
};
66104

67-
let response = self.client
68-
.post(format!("{}/api/generate", self.base_url))
69-
.json(&ollama_request)
70-
.send()
71-
.await
72-
.context("Failed to send request to Ollama")?;
73-
74-
if !response.status().is_success() {
75-
let error_text = response.text().await?;
76-
anyhow::bail!("Ollama API error: {}", error_text);
77-
}
105+
let url = format!("{}/api/generate", self.base_url);
106+
let response = self.send_with_retry(|| {
107+
self.client
108+
.post(&url)
109+
.json(&ollama_request)
110+
})
111+
.await
112+
.context("Failed to send request to Ollama")?;
78113

79114
let ollama_response: OllamaResponse = response.json().await
80115
.context("Failed to parse Ollama response")?;
@@ -97,4 +132,8 @@ impl LLMAdapter for OllamaAdapter {
97132
fn _model_name(&self) -> &str {
98133
&self.config.model_name
99134
}
100-
}
135+
}
136+
137+
fn is_retryable_status(status: StatusCode) -> bool {
138+
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
139+
}

src/adapters/openai.rs

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use anyhow::{Context, Result};
22
use async_trait::async_trait;
3-
use reqwest::Client;
3+
use reqwest::{Client, StatusCode};
44
use serde::{Deserialize, Serialize};
5+
use std::time::Duration;
6+
use tokio::time::sleep;
57
use crate::adapters::llm::{LLMAdapter, LLMRequest, LLMResponse, ModelConfig, Usage};
68

79
pub struct OpenAIAdapter {
@@ -64,6 +66,42 @@ impl OpenAIAdapter {
6466
base_url,
6567
})
6668
}
69+
70+
async fn send_with_retry<F>(&self, mut make_request: F) -> Result<reqwest::Response>
71+
where
72+
F: FnMut() -> reqwest::RequestBuilder,
73+
{
74+
const MAX_RETRIES: usize = 2;
75+
const BASE_DELAY_MS: u64 = 250;
76+
77+
for attempt in 0..=MAX_RETRIES {
78+
match make_request().send().await {
79+
Ok(response) => {
80+
if response.status().is_success() {
81+
return Ok(response);
82+
}
83+
84+
let status = response.status();
85+
let body = response.text().await.unwrap_or_default();
86+
if is_retryable_status(status) && attempt < MAX_RETRIES {
87+
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
88+
continue;
89+
}
90+
91+
anyhow::bail!("OpenAI API error ({}): {}", status, body);
92+
}
93+
Err(err) => {
94+
if attempt < MAX_RETRIES {
95+
sleep(Duration::from_millis(BASE_DELAY_MS * (attempt as u64 + 1))).await;
96+
continue;
97+
}
98+
return Err(err.into());
99+
}
100+
}
101+
}
102+
103+
anyhow::bail!("OpenAI request failed after retries");
104+
}
67105
}
68106

69107
#[async_trait]
@@ -87,19 +125,16 @@ impl LLMAdapter for OpenAIAdapter {
87125
max_tokens: request.max_tokens.unwrap_or(self.config.max_tokens),
88126
};
89127

90-
let response = self.client
91-
.post(format!("{}/chat/completions", self.base_url))
92-
.header("Authorization", format!("Bearer {}", self.api_key))
93-
.header("Content-Type", "application/json")
94-
.json(&openai_request)
95-
.send()
96-
.await
97-
.context("Failed to send request to OpenAI")?;
98-
99-
if !response.status().is_success() {
100-
let error_text = response.text().await?;
101-
anyhow::bail!("OpenAI API error: {}", error_text);
102-
}
128+
let url = format!("{}/chat/completions", self.base_url);
129+
let response = self.send_with_retry(|| {
130+
self.client
131+
.post(&url)
132+
.header("Authorization", format!("Bearer {}", self.api_key))
133+
.header("Content-Type", "application/json")
134+
.json(&openai_request)
135+
})
136+
.await
137+
.context("Failed to send request to OpenAI")?;
103138

104139
let openai_response: OpenAIResponse = response.json().await
105140
.context("Failed to parse OpenAI response")?;
@@ -123,4 +158,8 @@ impl LLMAdapter for OpenAIAdapter {
123158
fn _model_name(&self) -> &str {
124159
&self.config.model_name
125160
}
126-
}
161+
}
162+
163+
fn is_retryable_status(status: StatusCode) -> bool {
164+
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
165+
}

src/config.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ impl Config {
125125
self.system_prompt = Some(prompt);
126126
}
127127
}
128+
129+
pub fn normalize(&mut self) {
130+
if self.model.trim().is_empty() {
131+
self.model = default_model();
132+
}
133+
134+
if !self.temperature.is_finite() || self.temperature < 0.0 || self.temperature > 2.0 {
135+
self.temperature = default_temperature();
136+
}
137+
138+
if self.max_tokens == 0 {
139+
self.max_tokens = default_max_tokens();
140+
}
141+
}
128142

129143
pub fn get_path_config(&self, file_path: &PathBuf) -> Option<&PathConfig> {
130144
let file_path_str = file_path.to_string_lossy();
@@ -181,6 +195,25 @@ impl Config {
181195
}
182196
}
183197

198+
#[cfg(test)]
199+
mod tests {
200+
use super::*;
201+
202+
#[test]
203+
fn normalize_clamps_values() {
204+
let mut config = Config::default();
205+
config.model = " ".to_string();
206+
config.temperature = 5.0;
207+
config.max_tokens = 0;
208+
209+
config.normalize();
210+
211+
assert_eq!(config.model, default_model());
212+
assert_eq!(config.temperature, default_temperature());
213+
assert_eq!(config.max_tokens, default_max_tokens());
214+
}
215+
}
216+
184217
fn default_model() -> String {
185218
"gpt-4o".to_string()
186219
}
@@ -195,4 +228,4 @@ fn default_max_tokens() -> usize {
195228

196229
fn default_true() -> bool {
197230
true
198-
}
231+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ async fn main() -> Result<()> {
146146
if let Some(tokens) = cli.max_tokens {
147147
config.max_tokens = tokens;
148148
}
149+
config.normalize();
149150

150151
match cli.command {
151152
Commands::Review { diff, patch, output } => {

0 commit comments

Comments
 (0)