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}