systemprompt_sync/api_client/
mod.rs1mod response;
11mod retry;
12mod token;
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use reqwest::Client;
18use serde::de::DeserializeOwned;
19use serde::{Deserialize, Serialize};
20use systemprompt_models::net::{HTTP_CONNECT_TIMEOUT, HTTP_SYNC_DEPLOY_TIMEOUT};
21use tokio::sync::Mutex;
22use tokio::time::sleep;
23
24use crate::error::{SyncError, SyncResult};
25pub use retry::RetryConfig;
26use token::is_unauthorized;
27pub use token::{exchange_subject_token, exchange_subject_token_at};
28
29#[derive(Clone, Debug)]
30pub struct SyncApiClient {
31 client: Client,
32 api_url: String,
33 token: String,
34 direct_sync_origin: Option<String>,
35 cached_sync_token: Arc<Mutex<Option<String>>>,
36 retry_config: RetryConfig,
37}
38
39#[derive(Debug, Deserialize)]
40pub struct RegistryToken {
41 pub registry: String,
42 pub username: String,
43 pub token: String,
44}
45
46#[derive(Debug, Clone, Copy, Deserialize)]
47pub struct UploadResponse {
48 pub files_uploaded: usize,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct DeployResponse {
53 pub status: String,
54 pub app_url: Option<String>,
55}
56
57impl SyncApiClient {
58 pub fn new(api_url: &str, token: &str) -> SyncResult<Self> {
59 Ok(Self {
60 client: Client::builder()
61 .connect_timeout(HTTP_CONNECT_TIMEOUT)
62 .timeout(HTTP_SYNC_DEPLOY_TIMEOUT)
63 .build()?,
64 api_url: api_url.to_owned(),
65 token: token.to_owned(),
66 direct_sync_origin: None,
67 cached_sync_token: Arc::new(Mutex::new(None)),
68 retry_config: RetryConfig::default(),
69 })
70 }
71
72 pub fn with_direct_sync(self, hostname: Option<String>) -> Self {
73 let origin = hostname.map(|h| format!("https://{h}"));
74 self.with_direct_sync_origin(origin)
75 }
76
77 pub fn with_direct_sync_origin(mut self, origin: Option<String>) -> Self {
78 self.direct_sync_origin = origin;
79 self
80 }
81
82 pub const fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
83 self.retry_config = retry_config;
84 self
85 }
86
87 pub(crate) fn direct_sync_origin(&self) -> Option<&str> {
88 self.direct_sync_origin.as_deref()
89 }
90
91 const fn is_direct_sync(&self) -> bool {
92 self.direct_sync_origin.is_some()
93 }
94
95 fn files_url(&self, tenant_id: &systemprompt_identifiers::TenantId) -> String {
96 self.direct_sync_origin.as_ref().map_or_else(
97 || format!("{}/api/v1/cloud/tenants/{}/files", self.api_url, tenant_id),
98 |origin| format!("{origin}/api/v1/sync/files"),
99 )
100 }
101
102 fn calculate_next_delay(&self, current: Duration) -> Duration {
103 self.retry_config.next_delay(current)
104 }
105
106 pub async fn upload_files(
107 &self,
108 tenant_id: &systemprompt_identifiers::TenantId,
109 data: Vec<u8>,
110 ) -> SyncResult<UploadResponse> {
111 let url = self.files_url(tenant_id);
112 let direct = self.is_direct_sync();
113 let mut bearer = self.bearer_token(false).await?;
114 let mut reminted = false;
115
116 let mut current_delay = self.retry_config.initial_delay;
117
118 for attempt in 1..=self.retry_config.max_attempts {
119 let response = self
120 .client
121 .post(&url)
122 .header("Authorization", format!("Bearer {bearer}"))
123 .header("Content-Type", "application/octet-stream")
124 .body(data.clone())
125 .send()
126 .await?;
127
128 match response::handle_json::<UploadResponse>(response).await {
129 Ok(upload) => return Ok(upload),
130 Err(error) if direct && !reminted && is_unauthorized(&error) => {
131 reminted = true;
132 bearer = self.bearer_token(true).await?;
133 },
134 Err(error) if error.is_retryable() && attempt < self.retry_config.max_attempts => {
135 tracing::warn!(
136 attempt = attempt,
137 max_attempts = self.retry_config.max_attempts,
138 delay_ms = current_delay.as_millis() as u64,
139 error = %error,
140 "Retryable sync error, waiting before retry"
141 );
142 sleep(current_delay).await;
143 current_delay = self.calculate_next_delay(current_delay);
144 },
145 Err(error) => return Err(error),
146 }
147 }
148
149 Err(SyncError::ApiError {
150 status: 503,
151 message: "Max retry attempts exceeded".to_owned(),
152 })
153 }
154
155 pub async fn download_files(
156 &self,
157 tenant_id: &systemprompt_identifiers::TenantId,
158 ) -> SyncResult<Vec<u8>> {
159 let url = self.files_url(tenant_id);
160 let direct = self.is_direct_sync();
161 let mut bearer = self.bearer_token(false).await?;
162 let mut reminted = false;
163
164 let mut current_delay = self.retry_config.initial_delay;
165
166 for attempt in 1..=self.retry_config.max_attempts {
167 let response = self
168 .client
169 .get(&url)
170 .header("Authorization", format!("Bearer {bearer}"))
171 .send()
172 .await?;
173
174 match response::handle_binary(response).await {
175 Ok(data) => return Ok(data),
176 Err(error) if direct && !reminted && is_unauthorized(&error) => {
177 reminted = true;
178 bearer = self.bearer_token(true).await?;
179 },
180 Err(error) if error.is_retryable() && attempt < self.retry_config.max_attempts => {
181 tracing::warn!(
182 attempt = attempt,
183 max_attempts = self.retry_config.max_attempts,
184 delay_ms = current_delay.as_millis() as u64,
185 error = %error,
186 "Retryable sync error, waiting before retry"
187 );
188 sleep(current_delay).await;
189 current_delay = self.calculate_next_delay(current_delay);
190 },
191 Err(error) => return Err(error),
192 }
193 }
194
195 Err(SyncError::ApiError {
196 status: 503,
197 message: "Max retry attempts exceeded".to_owned(),
198 })
199 }
200
201 pub async fn get_registry_token(
202 &self,
203 tenant_id: &systemprompt_identifiers::TenantId,
204 ) -> SyncResult<RegistryToken> {
205 let url = format!(
206 "{}/api/v1/cloud/tenants/{}/registry-token",
207 self.api_url, tenant_id
208 );
209 self.get(&url).await
210 }
211
212 pub async fn deploy(
213 &self,
214 tenant_id: &systemprompt_identifiers::TenantId,
215 image: &str,
216 ) -> SyncResult<DeployResponse> {
217 let url = format!("{}/api/v1/cloud/tenants/{}/deploy", self.api_url, tenant_id);
218 self.post(&url, &serde_json::json!({ "image": image }))
219 .await
220 }
221
222 pub async fn get_tenant_app_id(
223 &self,
224 tenant_id: &systemprompt_identifiers::TenantId,
225 ) -> SyncResult<String> {
226 #[derive(Deserialize)]
227 struct TenantInfo {
228 fly_app_name: Option<String>,
229 }
230 let url = format!("{}/api/v1/cloud/tenants/{}", self.api_url, tenant_id);
231 let info: TenantInfo = self.get(&url).await?;
232 info.fly_app_name.ok_or(SyncError::TenantNoApp)
233 }
234
235 pub async fn get_database_url(
236 &self,
237 tenant_id: &systemprompt_identifiers::TenantId,
238 ) -> SyncResult<String> {
239 #[derive(Deserialize)]
240 struct DatabaseInfo {
241 database_url: Option<String>,
242 }
243 let url = format!(
244 "{}/api/v1/cloud/tenants/{}/database",
245 self.api_url, tenant_id
246 );
247 let info: DatabaseInfo = self.get(&url).await?;
248 info.database_url.ok_or_else(|| SyncError::ApiError {
249 status: 404,
250 message: "Database URL not available for tenant".to_owned(),
251 })
252 }
253
254 pub(crate) async fn get<T: DeserializeOwned>(&self, url: &str) -> SyncResult<T> {
255 let resp = self
256 .client
257 .get(url)
258 .header("Authorization", format!("Bearer {}", self.token))
259 .send()
260 .await?;
261 response::handle_json(resp).await
262 }
263
264 pub(crate) async fn post<T: DeserializeOwned, B: Serialize + Sync>(
265 &self,
266 url: &str,
267 body: &B,
268 ) -> SyncResult<T> {
269 let resp = self
270 .client
271 .post(url)
272 .header("Authorization", format!("Bearer {}", self.token))
273 .json(body)
274 .send()
275 .await?;
276 response::handle_json(resp).await
277 }
278}