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