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}