1use std::time::Duration;
4
5use reqwest::{Client, Method, StatusCode};
6use serde_json::Value;
7use tracing::{debug, instrument, warn};
8
9use rigg_core::config::SearchServiceConfig;
10use rigg_core::resources::ResourceKind;
11
12use crate::auth::{AuthProvider, get_auth_provider};
13use crate::error::ClientError;
14
15const MAX_RETRIES: u32 = 3;
17
18const INITIAL_BACKOFF_SECS: u64 = 1;
20
21fn retry_delay(error: &ClientError, attempt: u32) -> Duration {
26 match error {
27 ClientError::RateLimited { retry_after } => Duration::from_secs(*retry_after),
28 _ => Duration::from_secs(INITIAL_BACKOFF_SECS * 2u64.pow(attempt)),
29 }
30}
31
32pub struct AzureSearchClient {
34 http: Client,
35 auth: Box<dyn AuthProvider>,
36 base_url: String,
37 api_version: String,
38 preview_api_version: String,
39}
40
41const HTTP_TIMEOUT_SECS: u64 = 120;
45
46impl AzureSearchClient {
47 pub fn from_connection(
49 conn: &rigg_core::workspace::SearchConnection,
50 ) -> Result<Self, ClientError> {
51 let auth = get_auth_provider()?;
52 let http = Client::builder()
53 .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
54 .build()?;
55 Ok(Self {
56 http,
57 auth,
58 base_url: conn.url(),
59 api_version: conn
60 .api_version
61 .clone()
62 .unwrap_or_else(|| rigg_core::registry::SEARCH_STABLE_API_VERSION.to_string()),
63 preview_api_version: conn
64 .preview_api_version
65 .clone()
66 .unwrap_or_else(|| rigg_core::registry::SEARCH_PREVIEW_API_VERSION.to_string()),
67 })
68 }
69
70 pub fn from_service_config(service: &SearchServiceConfig) -> Result<Self, ClientError> {
72 let auth = get_auth_provider()?;
73 let http = Client::builder()
74 .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
75 .build()?;
76
77 Ok(Self {
78 http,
79 auth,
80 base_url: service.service_url(),
81 api_version: rigg_core::registry::SEARCH_STABLE_API_VERSION.to_string(),
82 preview_api_version: service.preview_api_version.clone(),
83 })
84 }
85
86 pub fn with_auth(
88 base_url: String,
89 api_version: String,
90 preview_api_version: String,
91 auth: Box<dyn AuthProvider>,
92 ) -> Result<Self, ClientError> {
93 let http = Client::builder()
94 .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
95 .build()?;
96
97 Ok(Self {
98 http,
99 auth,
100 base_url,
101 api_version,
102 preview_api_version,
103 })
104 }
105
106 fn api_version_for(&self, kind: ResourceKind) -> &str {
111 match rigg_core::registry::meta(kind).channel {
112 rigg_core::registry::Channel::Stable => &self.api_version,
113 rigg_core::registry::Channel::Preview => &self.preview_api_version,
114 }
115 }
116
117 fn collection_url(&self, kind: ResourceKind) -> String {
119 format!(
120 "{}/{}?api-version={}",
121 self.base_url,
122 kind.api_path(),
123 self.api_version_for(kind)
124 )
125 }
126
127 fn resource_url(&self, kind: ResourceKind, name: &str) -> String {
129 format!(
130 "{}/{}/{}?api-version={}",
131 self.base_url,
132 kind.api_path(),
133 urlencoding::encode(name),
134 self.api_version_for(kind)
135 )
136 }
137
138 async fn request(
140 &self,
141 method: Method,
142 url: &str,
143 body: Option<&Value>,
144 ) -> Result<Option<Value>, ClientError> {
145 let token = self.auth.get_token()?;
146
147 let mut request = self
148 .http
149 .request(method.clone(), url)
150 .header("Authorization", format!("Bearer {}", token))
151 .header("Content-Type", "application/json");
152
153 if let Some(json) = body {
154 request = request.json(json);
155 }
156
157 debug!("Request: {} {}", method, url);
158 let response = request.send().await?;
159 let status = response.status();
160
161 if status == StatusCode::NO_CONTENT {
162 return Ok(None);
163 }
164
165 let body = response.text().await?;
166
167 if status.is_success() {
168 if body.is_empty() {
169 Ok(None)
170 } else {
171 let value: Value = serde_json::from_str(&body)?;
172 Ok(Some(value))
173 }
174 } else {
175 match status {
176 StatusCode::NOT_FOUND => Err(ClientError::NotFound {
177 kind: "resource".to_string(),
178 name: url.to_string(),
179 }),
180 StatusCode::CONFLICT => Err(ClientError::AlreadyExists {
181 kind: "resource".to_string(),
182 name: url.to_string(),
183 }),
184 StatusCode::TOO_MANY_REQUESTS => {
185 let retry_after = 60; Err(ClientError::RateLimited { retry_after })
187 }
188 StatusCode::SERVICE_UNAVAILABLE => Err(ClientError::ServiceUnavailable(body)),
189 _ => Err(ClientError::from_response_with_url(
190 status.as_u16(),
191 &body,
192 Some(url),
193 )),
194 }
195 }
196 }
197
198 async fn request_with_retry(
204 &self,
205 method: Method,
206 url: &str,
207 body: Option<&Value>,
208 ) -> Result<Option<Value>, ClientError> {
209 let mut attempt = 0u32;
210 loop {
211 match self.request(method.clone(), url, body).await {
212 Ok(value) => return Ok(value),
213 Err(err) if err.is_retryable() && attempt < MAX_RETRIES => {
214 let delay = retry_delay(&err, attempt);
215 warn!(
216 "Request {} {} failed (attempt {}/{}): {}. Retrying in {:?}",
217 method,
218 url,
219 attempt + 1,
220 MAX_RETRIES + 1,
221 err,
222 delay,
223 );
224 tokio::time::sleep(delay).await;
225 attempt += 1;
226 }
227 Err(err) => return Err(err),
228 }
229 }
230 }
231
232 #[instrument(skip(self))]
234 pub async fn list(&self, kind: ResourceKind) -> Result<Vec<Value>, ClientError> {
235 let url = self.collection_url(kind);
236 let response = self.request_with_retry(Method::GET, &url, None).await?;
237
238 match response {
239 Some(value) => {
240 let items = value
242 .get("value")
243 .and_then(|v| v.as_array())
244 .cloned()
245 .unwrap_or_default();
246 Ok(items)
247 }
248 None => Ok(Vec::new()),
249 }
250 }
251
252 #[instrument(skip(self))]
254 pub async fn get(&self, kind: ResourceKind, name: &str) -> Result<Value, ClientError> {
255 let url = self.resource_url(kind, name);
256 let response = self.request_with_retry(Method::GET, &url, None).await?;
257
258 response.ok_or_else(|| ClientError::NotFound {
259 kind: kind.display_name().to_string(),
260 name: name.to_string(),
261 })
262 }
263
264 #[instrument(skip(self, definition))]
270 pub async fn create_or_update(
271 &self,
272 kind: ResourceKind,
273 name: &str,
274 definition: &Value,
275 ) -> Result<Option<Value>, ClientError> {
276 let url = self.resource_url(kind, name);
277 self.request_with_retry(Method::PUT, &url, Some(definition))
278 .await
279 }
280
281 #[instrument(skip(self))]
283 pub async fn delete(&self, kind: ResourceKind, name: &str) -> Result<(), ClientError> {
284 let url = self.resource_url(kind, name);
285 self.request_with_retry(Method::DELETE, &url, None).await?;
286 Ok(())
287 }
288
289 pub async fn exists(&self, kind: ResourceKind, name: &str) -> Result<bool, ClientError> {
291 match self.get(kind, name).await {
292 Ok(_) => Ok(true),
293 Err(ClientError::NotFound { .. }) => Ok(false),
294 Err(e) => Err(e),
295 }
296 }
297
298 pub fn auth_method(&self) -> &'static str {
300 self.auth.method_name()
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::auth::{AuthError, AuthProvider};
308
309 struct FakeAuth;
310 impl AuthProvider for FakeAuth {
311 fn get_token(&self) -> Result<String, AuthError> {
312 Ok("fake-token".to_string())
313 }
314 fn method_name(&self) -> &'static str {
315 "Fake"
316 }
317 }
318
319 fn make_client() -> AzureSearchClient {
320 AzureSearchClient::with_auth(
321 "https://test-svc.search.windows.net".to_string(),
322 "2026-04-01".to_string(),
323 "2026-05-01-preview".to_string(),
324 Box::new(FakeAuth),
325 )
326 .unwrap()
327 }
328
329 #[test]
330 fn stable_kinds_use_stable_version() {
331 let client = make_client();
332 let url = client.collection_url(ResourceKind::Index);
333 assert_eq!(
334 url,
335 "https://test-svc.search.windows.net/indexes?api-version=2026-04-01"
336 );
337 let url = client.collection_url(ResourceKind::KnowledgeBase);
339 assert_eq!(
340 url,
341 "https://test-svc.search.windows.net/knowledgeBases?api-version=2026-04-01"
342 );
343 let url = client.resource_url(ResourceKind::KnowledgeSource, "ks");
344 assert_eq!(
345 url,
346 "https://test-svc.search.windows.net/knowledgeSources/ks?api-version=2026-04-01"
347 );
348 }
349
350 #[test]
351 fn resource_url_percent_encodes_name() {
352 let client = make_client();
353 let url = client.resource_url(ResourceKind::Index, "my index");
354 assert!(url.contains("/indexes/my%20index?"));
355 }
356
357 #[test]
358 fn search_kinds_route_via_registry_channel() {
359 let client = make_client();
360 for kind in ResourceKind::search_kinds() {
361 let url = client.collection_url(kind);
362 let expected = match rigg_core::registry::meta(kind).channel {
363 rigg_core::registry::Channel::Stable => "2026-04-01",
364 rigg_core::registry::Channel::Preview => "2026-05-01-preview",
365 };
366 assert!(
367 url.contains(expected),
368 "{kind:?} should use {expected}, got: {url}"
369 );
370 }
371 }
372
373 #[test]
374 fn test_retry_delay_exponential_backoff_attempt_0() {
375 let err = ClientError::ServiceUnavailable("down".to_string());
376 let delay = retry_delay(&err, 0);
377 assert_eq!(delay, Duration::from_secs(1));
378 }
379
380 #[test]
381 fn test_retry_delay_exponential_backoff_attempt_1() {
382 let err = ClientError::ServiceUnavailable("down".to_string());
383 let delay = retry_delay(&err, 1);
384 assert_eq!(delay, Duration::from_secs(2));
385 }
386
387 #[test]
388 fn test_retry_delay_exponential_backoff_attempt_2() {
389 let err = ClientError::ServiceUnavailable("down".to_string());
390 let delay = retry_delay(&err, 2);
391 assert_eq!(delay, Duration::from_secs(4));
392 }
393
394 #[test]
395 fn test_retry_delay_rate_limited_uses_retry_after() {
396 let err = ClientError::RateLimited { retry_after: 30 };
397 assert_eq!(retry_delay(&err, 0), Duration::from_secs(30));
399 assert_eq!(retry_delay(&err, 1), Duration::from_secs(30));
400 assert_eq!(retry_delay(&err, 2), Duration::from_secs(30));
401 }
402
403 #[test]
404 fn test_retry_delay_rate_limited_default_retry_after() {
405 let err = ClientError::RateLimited { retry_after: 60 };
406 let delay = retry_delay(&err, 0);
407 assert_eq!(delay, Duration::from_secs(60));
408 }
409
410 #[test]
411 fn test_retry_constants() {
412 assert_eq!(MAX_RETRIES, 3);
413 assert_eq!(INITIAL_BACKOFF_SECS, 1);
414 }
415
416 #[test]
417 fn test_retry_delay_backoff_sequence() {
418 let err = ClientError::ServiceUnavailable("temporarily unavailable".to_string());
419 let delays: Vec<Duration> = (0..MAX_RETRIES).map(|i| retry_delay(&err, i)).collect();
420 assert_eq!(
421 delays,
422 vec![
423 Duration::from_secs(1),
424 Duration::from_secs(2),
425 Duration::from_secs(4),
426 ]
427 );
428 }
429
430 #[test]
431 fn test_non_retryable_error_still_computes_delay() {
432 let err = ClientError::Api {
435 status: 400,
436 message: "bad request".to_string(),
437 };
438 let delay = retry_delay(&err, 0);
439 assert_eq!(delay, Duration::from_secs(1));
440 }
441}