Skip to main content

agile_config_client/
client.rs

1//! Long-lived `AgileConfig` client: HTTP pull, cache, and WebSocket updates.
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex};
5
6use futures_util::StreamExt;
7use tokio::sync::watch;
8use tokio_util::sync::CancellationToken;
9use tracing::{debug, trace, warn};
10
11use crate::cache::{read_cache, write_cache};
12use crate::error::Error;
13use crate::http::fetch_config;
14use crate::nodes::RandomNodes;
15use crate::options::{ClientBuilder, ClientOptions};
16use crate::protocol::ConfigItem;
17use crate::source::Source;
18use crate::store::{ConfigSnapshot, empty_snapshot};
19use crate::websocket::{self, WsSink};
20
21/// Client for an `AgileConfig` cluster.
22///
23/// `Client` owns HTTP loading, the optional local cache, and the WebSocket
24/// session used for live reload notifications. Obtain a [`Source`] with
25/// [`Self::source`] to integrate with the [`config`] crate.
26///
27/// Keep at least one `Client` clone alive for as long as you want the
28/// WebSocket connection to stay up. The last drop cancels background tasks.
29#[derive(Clone, Debug)]
30pub struct Client {
31    inner: Arc<Inner>,
32}
33
34pub(crate) struct Inner {
35    pub(crate) options: ClientOptions,
36    http: reqwest::Client,
37    store: watch::Sender<Arc<ConfigSnapshot>>,
38    loaded: AtomicBool,
39    pub(crate) cancel: CancellationToken,
40    session: Mutex<CancellationToken>,
41    pub(crate) writer: tokio::sync::Mutex<Option<WsSink>>,
42    pub(crate) loops_started: AtomicBool,
43    pub(crate) reconnect_enabled: AtomicBool,
44}
45
46impl std::fmt::Debug for Inner {
47    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        formatter
49            .debug_struct("Inner")
50            .field("app_id", &self.options.app_id)
51            .field("loaded", &self.loaded)
52            .finish_non_exhaustive()
53    }
54}
55
56impl Drop for Inner {
57    fn drop(&mut self) {
58        self.reconnect_enabled.store(false, Ordering::SeqCst);
59        self.cancel.cancel();
60    }
61}
62
63impl Client {
64    /// Creates a client from an options struct.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`Error::EmptyAppId`] or [`Error::EmptyNodes`] when required
69    /// fields are missing.
70    pub fn new(options: ClientOptions) -> Result<Self, Error> {
71        let options = options.normalized()?;
72        let http = reqwest::Client::builder()
73            .timeout(options.http_timeout)
74            .build()?;
75        let (store, _) = watch::channel(empty_snapshot());
76        let cancel = CancellationToken::new();
77        let session = cancel.child_token();
78        session.cancel();
79        Ok(Self {
80            inner: Arc::new(Inner {
81                options,
82                http,
83                store,
84                loaded: AtomicBool::new(false),
85                cancel,
86                session: Mutex::new(session),
87                writer: tokio::sync::Mutex::new(None),
88                loops_started: AtomicBool::new(false),
89                reconnect_enabled: AtomicBool::new(false),
90            }),
91        })
92    }
93
94    /// Starts a builder that constructs [`ClientOptions`] then this client.
95    pub fn builder() -> ClientBuilder {
96        ClientBuilder::default()
97    }
98
99    /// Returns the normalized options used by this client.
100    #[must_use]
101    pub fn options(&self) -> &ClientOptions {
102        &self.inner.options
103    }
104
105    /// Returns an [`AsyncSource`](config::AsyncSource) bound to this client.
106    #[must_use]
107    pub fn source(&self) -> Source {
108        Source::new(Arc::clone(&self.inner))
109    }
110
111    /// Returns the latest configuration snapshot.
112    #[must_use]
113    pub fn snapshot(&self) -> Arc<ConfigSnapshot> {
114        self.inner.snapshot()
115    }
116
117    /// Subscribes to snapshot updates.
118    ///
119    /// The library never rebuilds a [`config::Config`] value. After receiving
120    /// a change, the caller should snapshot again and apply it to application
121    /// state.
122    #[must_use]
123    pub fn subscribe(&self) -> watch::Receiver<Arc<ConfigSnapshot>> {
124        self.inner.store.subscribe()
125    }
126
127    /// Pulls configuration via HTTP, falling back to the local cache.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`Error::LoadFailed`] when every node fails and no cache exists.
132    pub async fn load(&self) -> Result<(), Error> {
133        self.inner.load().await
134    }
135
136    /// Loads configuration and starts the WebSocket session.
137    ///
138    /// A WebSocket failure is not fatal; the method still succeeds when HTTP
139    /// or the cache produced a snapshot. Background reconnect and heartbeat
140    /// loops keep running until [`Self::disconnect`] or the last clone is
141    /// dropped.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`Error::LoadFailed`] when no configuration could be obtained.
146    pub async fn connect(&self) -> Result<(), Error> {
147        self.inner.reconnect_enabled.store(true, Ordering::SeqCst);
148        websocket::spawn_background_loops(&self.inner);
149        if let Err(error) = self.inner.connect_websocket().await {
150            warn!(
151                ?error,
152                "websocket connect failed; continuing with HTTP load"
153            );
154        }
155        self.inner.load().await
156    }
157
158    /// Stops reconnecting and closes the WebSocket.
159    pub async fn disconnect(&self) {
160        self.inner.disconnect().await;
161    }
162}
163
164impl Inner {
165    pub(crate) fn snapshot(&self) -> Arc<ConfigSnapshot> {
166        Arc::clone(&self.store.borrow())
167    }
168
169    pub(crate) async fn ensure_loaded(&self) -> Result<(), Error> {
170        if self.loaded.load(Ordering::SeqCst) {
171            return Ok(());
172        }
173        self.load().await
174    }
175
176    pub(crate) async fn load(&self) -> Result<(), Error> {
177        let mut last_error: Option<Error> = None;
178        for node in RandomNodes::new(&self.options.nodes) {
179            match fetch_config(&self.http, &node, &self.options).await {
180                Ok(payload) => match parse_items(&payload.json) {
181                    Ok(items) => {
182                        self.apply_snapshot(items, payload.publish_time_line_id, false);
183                        if let Err(error) = write_cache(
184                            &self.options.cache,
185                            &self.options.app_id,
186                            &self.options.secret,
187                            &payload.json,
188                        ) {
189                            warn!(
190                                ?error,
191                                "client try to cache all configs to local but failed"
192                            );
193                        }
194                        if let Err(error) = websocket::send_text(self, "loaded").await {
195                            debug!(?error, "client try to send loaded msg to server but failed");
196                        } else {
197                            trace!("client send loaded to server by websocket");
198                        }
199                        return Ok(());
200                    }
201                    Err(error) => {
202                        warn!(?error, node, "invalid configuration payload");
203                        last_error = Some(error);
204                    }
205                },
206                Err(error) => {
207                    warn!(
208                        ?error,
209                        node, "client try to load all the configs but failed"
210                    );
211                    last_error = Some(error);
212                }
213            }
214        }
215
216        match self.load_from_cache() {
217            Ok(()) => {
218                trace!("client load all configs from local file");
219                Ok(())
220            }
221            Err(cache_error) => {
222                debug!(?cache_error, "local cache unavailable");
223                Err(last_error.unwrap_or(Error::LoadFailed))
224            }
225        }
226    }
227
228    fn load_from_cache(&self) -> Result<(), Error> {
229        let Some(json) = read_cache(
230            &self.options.cache,
231            &self.options.app_id,
232            &self.options.secret,
233        )?
234        else {
235            return Err(Error::LoadFailed);
236        };
237        let items = parse_items(&json)?;
238        self.apply_snapshot(items, None, true);
239        Ok(())
240    }
241
242    fn apply_snapshot(
243        &self,
244        items: Vec<ConfigItem>,
245        publish_time_line_id: Option<String>,
246        from_cache: bool,
247    ) {
248        let snapshot = Arc::new(ConfigSnapshot::from_items(
249            items,
250            publish_time_line_id,
251            from_cache,
252        ));
253        self.store.send_replace(snapshot);
254        self.loaded.store(true, Ordering::SeqCst);
255    }
256
257    pub(crate) async fn connect_websocket(self: &Arc<Self>) -> Result<(), Error> {
258        let mut last_error = Error::LoadFailed;
259        for node in RandomNodes::new(&self.options.nodes) {
260            match websocket::connect(&node, &self.options).await {
261                Ok(stream) => {
262                    self.install_socket(stream).await;
263                    trace!(node, "client connect websocket successful");
264                    return Ok(());
265                }
266                Err(error) => {
267                    warn!(?error, node, "client try to connect server occur error");
268                    last_error = error;
269                }
270            }
271        }
272        Err(last_error)
273    }
274
275    async fn install_socket(self: &Arc<Self>, stream: websocket::WsStream) {
276        let session = self.cancel.child_token();
277        if let Ok(mut current) = self.session.lock() {
278            current.cancel();
279            *current = session.clone();
280        }
281        websocket::close_writer(self).await;
282        let (sink, reader) = stream.split();
283        *self.writer.lock().await = Some(sink);
284        websocket::spawn_reader(Arc::downgrade(self), session, reader);
285    }
286
287    pub(crate) async fn disconnect(&self) {
288        self.reconnect_enabled.store(false, Ordering::SeqCst);
289        if let Ok(session) = self.session.lock() {
290            session.cancel();
291        }
292        websocket::close_writer(self).await;
293    }
294
295    pub(crate) async fn handle_inbound(&self, text: &str) {
296        websocket::handle_inbound(self, text).await;
297    }
298}
299
300fn parse_items(json: &str) -> Result<Vec<ConfigItem>, Error> {
301    Ok(serde_json::from_str(json)?)
302}
303
304#[cfg(test)]
305mod tests {
306    use super::Client;
307    use crate::options::{CacheOptions, ClientOptions};
308
309    #[test]
310    fn new_rejects_empty_app_id() {
311        let error = Client::new(ClientOptions {
312            nodes: vec!["http://localhost:5000".into()],
313            ..ClientOptions::default()
314        })
315        .unwrap_err();
316        assert_eq!(error.to_string(), "app_id must not be empty");
317    }
318
319    #[test]
320    fn new_rejects_empty_nodes() {
321        let error = Client::new(ClientOptions {
322            app_id: "app".into(),
323            ..ClientOptions::default()
324        })
325        .unwrap_err();
326        assert_eq!(error.to_string(), "at least one server node is required");
327    }
328
329    #[test]
330    fn builder_and_struct_construction_are_equivalent() {
331        let from_struct = Client::new(ClientOptions {
332            app_id: "app".into(),
333            secret: "s".into(),
334            nodes: vec!["http://localhost:5000".into()],
335            env: "dev".into(),
336            cache: CacheOptions {
337                enabled: false,
338                ..CacheOptions::default()
339            },
340            ..ClientOptions::default()
341        })
342        .unwrap();
343        let from_builder = Client::builder()
344            .app_id("app")
345            .secret("s")
346            .nodes(["http://localhost:5000"])
347            .env("dev")
348            .cache(CacheOptions {
349                enabled: false,
350                ..CacheOptions::default()
351            })
352            .build()
353            .unwrap();
354        assert_eq!(from_struct.options().env, "DEV");
355        assert_eq!(from_builder.options().app_id, "app");
356        assert_eq!(from_builder.options().nodes, from_struct.options().nodes);
357    }
358}