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