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