Skip to main content

amber_api/
client.rs

1//! # Amber Electric API Client
2//!
3//! This module provides a client for interacting with the [Amber Electric
4//! Public API](https://api.amber.com.au/v1).
5
6use crate::{error::Result, models};
7use serde::de::DeserializeOwned;
8use tracing::{debug, instrument};
9
10/// The base URL for the Amber Electric API.
11const API_BASE_URL: &str = "https://api.amber.com.au/v1/";
12
13/// Main client for the Amber Electric API.
14///
15/// This client provides a high-level interface to all Amber Electric API
16/// endpoints with automatic retry logic for rate limit errors.
17///
18/// Internally, it uses `reqwest` as the HTTP client, which is stored behind
19/// an [`Arc`][std::sync::Arc] and can be cloned cheaply.
20///
21/// # Rate Limit Handling
22///
23/// By default, the client automatically retries requests that hit rate limits
24/// (HTTP 429). The client reads the `RateLimit-Reset` header to determine how
25/// long to wait before retrying. By default, up to 3 retry attempts will be
26/// made. You can configure this behavior:
27///
28/// ```
29/// use amber_api::Amber;
30///
31/// // Disable automatic retries
32/// let client = Amber::builder()
33///     .retry_on_rate_limit(false)
34///     .build();
35///
36/// // Customize max retry attempts (default: 3)
37/// let client = Amber::builder()
38///     .max_retries(5)
39///     .build();
40/// ```
41///
42/// When rate limit retries are disabled or exhausted, the client returns an
43/// error containing the suggested retry-after duration.
44///
45/// # Examples
46///
47/// ```
48/// use amber_api::Amber;
49///
50/// // Create a client with default retry behavior (3 retries, enabled)
51/// let client = Amber::default();
52/// ```
53#[derive(Debug, Clone, bon::Builder)]
54pub struct Amber {
55    /// HTTP client for making requests.
56    client: reqwest::Client,
57    /// Optional API key for authenticated requests.
58    api_key: Option<String>,
59    /// Base URL for the Amber API.
60    base_url: String,
61    /// Maximum number of retry attempts for rate limit errors.
62    ///
63    /// When the API returns HTTP 429 (rate limit exceeded), the client will
64    /// automatically retry up to this many times. Set to 0 to disable retries,
65    /// or use `retry_on_rate_limit(false)` for clearer intent.
66    ///
67    /// Defaults to 3.
68    #[builder(default = 3)]
69    max_retries: u32,
70    /// Whether to automatically retry on rate limit errors.
71    ///
72    /// When enabled (default), the client automatically waits and retries when
73    /// hitting rate limits. The wait time is read from the `RateLimit-Reset`
74    /// header, or defaults to 60 seconds if not present.
75    ///
76    /// When disabled, rate limit errors are returned immediately as
77    /// [`AmberError::RateLimitExceeded`].
78    ///
79    /// Default to `true`.
80    #[builder(default = true)]
81    retry_on_rate_limit: bool,
82}
83
84impl Default for Amber {
85    /// Create a new default Amber API client.
86    ///
87    /// This create a default client that is authenticated if an API key is set
88    /// in the `AMBER_API_KEY` environment variable.
89    ///
90    /// The default client has automatic rate limit retry enabled with up to 3
91    /// retry attempts.
92    #[inline]
93    #[expect(
94        clippy::expect_used,
95        reason = "reqwest::Client::builder() with basic config cannot fail"
96    )]
97    fn default() -> Self {
98        debug!("Creating default Amber API client");
99        let client = reqwest::Client::builder()
100            .user_agent(format!("amber-api/{}", env!("CARGO_PKG_VERSION")))
101            .timeout(core::time::Duration::from_secs(30))
102            .build()
103            .expect("Failed to build HTTP client");
104
105        Self {
106            client,
107            api_key: std::env::var("AMBER_API_KEY")
108                .ok()
109                .filter(|s| !s.is_empty()),
110            base_url: API_BASE_URL.to_owned(),
111            max_retries: 3,
112            retry_on_rate_limit: true,
113        }
114    }
115}
116
117#[bon::bon]
118impl Amber {
119    /// Perform a GET request to the Amber API with automatic retry on rate
120    /// limits.
121    ///
122    /// This method automatically retries requests that hit rate limits (HTTP
123    /// 429), reading the `RateLimit-Reset` header from the 429 response to
124    /// determine the exact number of seconds to wait before retrying. If the
125    /// header is missing or invalid, it falls back to 60 seconds.
126    ///
127    /// The number of retries is controlled by the `max_retries` and
128    /// `retry_on_rate_limit` configuration options.
129    #[instrument(skip(self, query), level = "debug")]
130    async fn get<T: DeserializeOwned, I, K, V>(&self, path: &str, query: I) -> Result<T>
131    where
132        I: IntoIterator<Item = (K, V)>,
133        K: AsRef<str>,
134        V: AsRef<str>,
135    {
136        let endpoint = format!("{}{}", self.base_url, path);
137        let query_params: Vec<(String, String)> = query
138            .into_iter()
139            .map(|(k, v)| (k.as_ref().to_owned(), v.as_ref().to_owned()))
140            .collect();
141        let mut attempt: u32 = 0;
142
143        loop {
144            let current_attempt = attempt.saturating_add(1);
145            let max_attempts = self.max_retries.saturating_add(1);
146            debug!("GET {endpoint} (attempt {current_attempt}/{max_attempts})");
147
148            // Build request
149            let mut request = self.client.get(&endpoint);
150
151            if let Some(api_key) = &self.api_key {
152                request = request.bearer_auth(api_key);
153            }
154
155            if !query_params.is_empty() {
156                for (key, value) in &query_params {
157                    debug!("Query parameter: {}={}", key, value);
158                }
159                request = request.query(&query_params);
160            }
161
162            // Make request
163            match request.send().await {
164                Ok(response) => {
165                    let status = response.status();
166                    debug!("Status code: {}", status);
167
168                    // Log rate limit info if available
169                    if let Some(remaining) = response
170                        .headers()
171                        .get("RateLimit-Remaining")
172                        .and_then(|v| v.to_str().ok())
173                        .and_then(|s| s.parse::<u64>().ok())
174                    {
175                        debug!("Rate limit remaining: {}", remaining);
176                    }
177
178                    // Handle rate limiting
179                    if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
180                        let retry_after = response
181                            .headers()
182                            .get("RateLimit-Reset")
183                            .and_then(|v| v.to_str().ok())
184                            .and_then(|s| s.parse::<u64>().ok())
185                            .unwrap_or(60);
186
187                        if !self.retry_on_rate_limit {
188                            return Err(crate::error::AmberError::RateLimitExceeded(retry_after));
189                        }
190
191                        if attempt >= self.max_retries {
192                            return Err(crate::error::AmberError::RateLimitExhausted {
193                                attempts: attempt,
194                                retry_after,
195                            });
196                        }
197
198                        // Wait and retry
199                        debug!(
200                            "Rate limit hit. Waiting {} seconds before retry",
201                            retry_after
202                        );
203                        tokio::time::sleep(tokio::time::Duration::from_secs(retry_after)).await;
204                        attempt = attempt.saturating_add(1);
205                        continue;
206                    }
207
208                    // Check for success
209                    if status.is_success() {
210                        return response.json::<T>().await.map_err(Into::into);
211                    }
212
213                    // Other error statuses
214                    let body = response
215                        .text()
216                        .await
217                        .unwrap_or_else(|_| String::from("<body not available>"));
218                    return Err(crate::error::AmberError::UnexpectedStatus {
219                        status: status.as_u16(),
220                        body,
221                    });
222                }
223                Err(e) => {
224                    // Network or other transport errors
225                    return Err(e.into());
226                }
227            }
228        }
229    }
230
231    /// Returns the current percentage of renewables in the grid for a specific
232    /// state.
233    ///
234    /// This method retrieves renewable energy data for the specified Australian
235    /// state. The data shows the current percentage of renewable energy in the
236    /// grid and can optionally include historical and forecast data.
237    ///
238    /// # Parameters
239    ///
240    /// - `state`: The Australian state (NSW, VIC, QLD, SA)
241    /// - `next`: Optional number of forecast intervals to return
242    /// - `previous`: Optional number of historical intervals to return
243    /// - `resolution`: Optional interval duration (5 or 30 minutes, default 30)
244    ///
245    /// # Authentication
246    ///
247    /// This endpoint does not require authentication and can be called without
248    /// an API key.
249    ///
250    /// # Returns
251    ///
252    /// Returns a [`Result`] containing a [`Vec`] of [`Renewable`] objects on
253    /// success.
254    ///
255    /// # Errors
256    ///
257    /// This method will return an error if:
258    ///
259    /// - There's a network error communicating with the API
260    /// - The API returns an internal server error (HTTP 500)
261    ///
262    /// # Example
263    ///
264    /// ```
265    /// use amber_api::Amber;
266    /// use amber_api::models::{State, Resolution};
267    ///
268    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
269    /// let client = Amber::default();
270    ///
271    /// // Get current renewables data for Victoria
272    /// let renewables = client.current_renewables()
273    ///     .state(State::Vic)
274    ///     .call()?;
275    ///
276    /// for renewable in renewables {
277    ///     println!("{}", renewable);
278    /// }
279    ///
280    /// // Get current data with 8 forecast intervals
281    /// let renewables_with_forecast = client.current_renewables()
282    ///     .state(State::Nsw)
283    ///     .next(8)
284    ///     .resolution(Resolution::FiveMinute)
285    ///     .call()?;
286    /// # Ok(())
287    /// # }
288    /// ```
289    ///
290    /// [`Renewable`]: crate::models::Renewable
291    #[inline]
292    #[builder]
293    pub async fn current_renewables(
294        &self,
295        state: models::State,
296        next: Option<u32>,
297        previous: Option<u32>,
298        resolution: Option<models::Resolution>,
299    ) -> Result<Vec<models::Renewable>> {
300        self.get(
301            &format!("state/{state}/renewables/current"),
302            [
303                ("next", next.map(|n| n.to_string())),
304                ("previous", previous.map(|p| p.to_string())),
305                ("resolution", resolution.map(|r| r.to_string())),
306            ]
307            .into_iter()
308            .filter_map(|(k, v)| v.map(|val| (k, val))),
309        )
310        .await
311    }
312
313    /// Return all sites linked to your account.
314    ///
315    /// This method returns information about all electricity sites associated
316    /// with your Amber account. Each site represents a location where you have
317    /// electricity service.
318    ///
319    /// # Authentication
320    ///
321    /// This method requires authentication via API key. The API key can be
322    /// provided either through the `AMBER_API_KEY` environment variable (when
323    /// using [`Amber::default()`]) or by explicitly setting it when building
324    /// the client.
325    ///
326    /// # Returns
327    ///
328    /// Returns a [`Result`] containing a [`Vec`] of [`Site`] objects on
329    /// success.
330    ///
331    /// # Errors
332    ///
333    /// This method will return an error if:
334    ///
335    /// - The API key is missing or invalid (HTTP 401)
336    /// - There's a network error communicating with the API
337    /// - The API returns an internal server error (HTTP 500)
338    ///
339    /// # Example
340    ///
341    /// ```
342    /// use amber_api::Amber;
343    ///
344    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
345    /// let client = Amber::default();
346    /// let sites = client.sites()?;
347    ///
348    /// for site in sites {
349    ///     println!("Site {}: {} ({})", site.id, site.network, site.status);
350    /// }
351    /// # Ok(())
352    /// # }
353    /// ```
354    ///
355    /// [`Site`]: crate::models::Site
356    #[inline]
357    pub async fn sites(&self) -> Result<Vec<crate::models::Site>> {
358        self.get("sites", core::iter::empty::<(&str, &str)>()).await
359    }
360
361    /// Returns all the prices between the start and end dates for a specific
362    /// site.
363    ///
364    /// This method retrieves historical pricing data for the specified site
365    /// between the given date range. The date range cannot exceed 7 days.
366    ///
367    /// # Parameters
368    ///
369    /// - `site_id`: ID of the site you are fetching prices for (obtained from
370    ///   [`sites()`])
371    /// - `start_date`: Optional start date for the price range (defaults to
372    ///   today)
373    /// - `end_date`: Optional end date for the price range (defaults to today)
374    /// - `resolution`: Optional interval duration (5 or 30 minutes, defaults to
375    ///   your billing interval)
376    ///
377    /// # Authentication
378    ///
379    /// This method requires authentication via API key. The API key can be
380    /// provided either through the `AMBER_API_KEY` environment variable (when
381    /// using [`Amber::default()`]) or by explicitly setting it when building
382    /// the client.
383    ///
384    /// # Returns
385    ///
386    /// Returns a [`Result`] containing a [`Vec`] of [`Interval`] objects on
387    /// success. Intervals are returned in order: General > Controlled Load >
388    /// Feed In.
389    ///
390    /// # Errors
391    ///
392    /// This method will return an error if:
393    ///
394    /// - The API key is missing or invalid (HTTP 401)
395    /// - The site ID is invalid (HTTP 400)
396    /// - The site is not found (HTTP 404)
397    /// - The date range exceeds 7 days (HTTP 422)
398    /// - There's a network error communicating with the API
399    /// - The API returns an internal server error (HTTP 500)
400    ///
401    /// # Example
402    ///
403    /// ```
404    /// use std::str::FromStr;
405    ///
406    /// use amber_api::Amber;
407    /// use amber_api::models::Resolution;
408    /// use jiff::civil::Date;
409    ///
410    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
411    /// let client = Amber::default();
412    /// let sites = client.sites()?;
413    /// let site_id = &sites[0].id;
414    ///
415    /// // Get prices for today
416    /// let prices = client.prices()
417    ///     .site_id(site_id)
418    ///     .call()?;
419    ///
420    /// // Get prices for a specific date range
421    /// let start_date = Date::from_str("2021-05-01").expect("Invalid start date");
422    /// let end_date = Date::from_str("2021-05-03").expect("Invalid end date");
423    /// let prices = client.prices()
424    ///     .site_id(site_id)
425    ///     .start_date(start_date)
426    ///     .end_date(end_date)
427    ///     .resolution(Resolution::FiveMinute)
428    ///     .call()?;
429    ///
430    /// for interval in prices {
431    ///     match interval {
432    ///         amber_api::models::Interval::ActualInterval(actual) => {
433    ///             println!("Actual price: {:.2}c/kWh", actual.base.per_kwh);
434    ///         }
435    ///         _ => {} // Handle other interval types as needed
436    ///     }
437    /// }
438    /// # Ok(())
439    /// # }
440    /// ```
441    ///
442    /// [`sites()`]: Self::sites
443    /// [`Interval`]: crate::models::Interval
444    #[inline]
445    #[builder]
446    pub async fn prices(
447        &self,
448        site_id: &str,
449        start_date: Option<jiff::civil::Date>,
450        end_date: Option<jiff::civil::Date>,
451        resolution: Option<models::Resolution>,
452    ) -> Result<Vec<models::Interval>> {
453        self.get(
454            &format!("sites/{site_id}/prices"),
455            [
456                ("startDate", start_date.map(|d| d.to_string())),
457                ("endDate", end_date.map(|d| d.to_string())),
458                ("resolution", resolution.map(|r| r.to_string())),
459            ]
460            .into_iter()
461            .filter_map(|(k, v)| v.map(|val| (k, val))),
462        )
463        .await
464    }
465
466    /// Returns the current price for a specific site.
467    ///
468    /// This method retrieves the current pricing data for the specified site,
469    /// optionally including historical and forecast data.
470    ///
471    /// # Parameters
472    ///
473    /// - `site_id`: ID of the site you are fetching prices for (obtained from
474    ///   [`sites()`])
475    /// - `next`: Optional number of forecast intervals to return (max 2048
476    ///   total)
477    /// - `previous`: Optional number of historical intervals to return (max
478    ///   2048 total)
479    /// - `resolution`: Optional interval duration (5 or 30 minutes, defaults to
480    ///   your billing interval)
481    ///
482    /// # Authentication
483    ///
484    /// This method requires authentication via API key. The API key can be
485    /// provided either through the `AMBER_API_KEY` environment variable (when
486    /// using [`Amber::default()`]) or by explicitly setting it when building
487    /// the client.
488    ///
489    /// # Returns
490    ///
491    /// Returns a [`Result`] containing a [`Vec`] of [`Interval`] objects on
492    /// success. Intervals are returned in order: General > Controlled Load >
493    /// Feed In.
494    ///
495    /// # Errors
496    ///
497    /// This method will return an error if:
498    ///
499    /// - The API key is missing or invalid (HTTP 401)
500    /// - The site ID is invalid (HTTP 400)
501    /// - The site is not found (HTTP 404)
502    /// - The total number of intervals exceeds 2048 (HTTP 422)
503    /// - There's a network error communicating with the API
504    /// - The API returns an internal server error (HTTP 500)
505    ///
506    /// # Example
507    ///
508    /// ```
509    /// use amber_api::Amber;
510    /// use amber_api::models::Resolution;
511    ///
512    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
513    /// let client = Amber::default();
514    /// let sites = client.sites()?;
515    /// let site_id = &sites[0].id;
516    ///
517    /// // Get current prices only
518    /// let current_prices = client.current_prices()
519    ///     .site_id(site_id)
520    ///     .call()?;
521    ///
522    /// // Get current prices with forecast
523    /// let prices_with_forecast = client.current_prices()
524    ///     .site_id(site_id)
525    ///     .next(8)
526    ///     .resolution(Resolution::ThirtyMinute)
527    ///     .call()?;
528    ///
529    /// // Get current prices with history and forecast
530    /// let full_prices = client.current_prices()
531    ///     .site_id(site_id)
532    ///     .previous(8)
533    ///     .next(8)
534    ///     .call()?;
535    ///
536    /// for interval in current_prices {
537    ///     match interval {
538    ///         amber_api::models::Interval::CurrentInterval(current) => {
539    ///             println!("Current price: {:.2}c/kWh (estimate: {})",
540    ///                      current.base.per_kwh, current.estimate);
541    ///         }
542    ///         _ => {} // Handle other interval types as needed
543    ///     }
544    /// }
545    /// # Ok(())
546    /// # }
547    /// ```
548    ///
549    /// [`sites()`]: Self::sites
550    /// [`Interval`]: crate::models::Interval
551    #[inline]
552    #[builder]
553    pub async fn current_prices(
554        &self,
555        site_id: &str,
556        next: Option<u32>,
557        previous: Option<u32>,
558        resolution: Option<models::Resolution>,
559    ) -> Result<Vec<models::Interval>> {
560        self.get(
561            &format!("sites/{site_id}/prices/current"),
562            [
563                ("next", next.map(|n| n.to_string())),
564                ("previous", previous.map(|p| p.to_string())),
565                ("resolution", resolution.map(|r| r.to_string())),
566            ]
567            .into_iter()
568            .filter_map(|(k, v)| v.map(|val| (k, val))),
569        )
570        .await
571    }
572
573    /// Returns all usage data between the start and end dates for a specific
574    /// site.
575    ///
576    /// This method retrieves historical usage data for the specified site
577    /// between the given date range. The date range cannot exceed 7 days, and
578    /// the API can only return 90 days worth of data.
579    ///
580    /// # Parameters
581    ///
582    /// - `site_id`: ID of the site you are fetching usage for (obtained from
583    ///   [`sites()`])
584    /// - `start_date`: Start date for the usage data (required)
585    /// - `end_date`: End date for the usage data (required)
586    /// - `resolution`: Optional interval duration (deprecated, will be ignored)
587    ///
588    /// # Authentication
589    ///
590    /// This method requires authentication via API key. The API key can be
591    /// provided either through the `AMBER_API_KEY` environment variable (when
592    /// using [`Amber::default()`]) or by explicitly setting it when building
593    /// the client.
594    ///
595    /// # Returns
596    ///
597    /// Returns a [`Result`] containing a [`Vec`] of [`Usage`] objects on
598    /// success. Usage data is returned in order: General > Controlled Load >
599    /// Feed In.
600    ///
601    /// # Errors
602    ///
603    /// This method will return an error if:
604    ///
605    /// - The API key is missing or invalid (HTTP 401)
606    /// - The site ID is invalid (HTTP 400)
607    /// - The site is not found (HTTP 404)
608    /// - The date range exceeds 7 days (HTTP 422)
609    /// - There's a network error communicating with the API
610    /// - The API returns an internal server error (HTTP 500)
611    ///
612    /// # Example
613    ///
614    /// ```
615    /// use std::str::FromStr;
616    ///
617    /// use amber_api::Amber;
618    /// use jiff::civil::Date;
619    ///
620    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
621    /// let client = Amber::default();
622    /// let sites = client.sites()?;
623    /// let site_id = &sites[0].id;
624    ///
625    /// // Get usage data for a specific date range
626    /// let start_date = Date::from_str("2021-05-01").expect("Invalid start date");
627    /// let end_date = Date::from_str("2021-05-03").expect("Invalid end date");
628    /// let usage_data = client.usage()
629    ///     .site_id(site_id)
630    ///     .start_date(start_date)
631    ///     .end_date(end_date)
632    ///     .call()?;
633    ///
634    /// for usage in usage_data {
635    ///     println!("Channel {}: {:.2} kWh, Cost: ${:.2}",
636    ///              usage.channel_identifier, usage.kwh, usage.cost);
637    /// }
638    /// # Ok(())
639    /// # }
640    /// ```
641    ///
642    /// [`sites()`]: Self::sites
643    /// [`Usage`]: crate::models::Usage
644    #[inline]
645    #[builder]
646    pub async fn usage(
647        &self,
648        site_id: &str,
649        start_date: jiff::civil::Date,
650        end_date: jiff::civil::Date,
651    ) -> Result<Vec<models::Usage>> {
652        let start_date_str = start_date.to_string();
653        let end_date_str = end_date.to_string();
654        let query_params = [
655            ("startDate", start_date_str.as_str()),
656            ("endDate", end_date_str.as_str()),
657        ];
658
659        self.get(&format!("sites/{site_id}/usage"), query_params)
660            .await
661    }
662}