From d1a016d4a7f766d8be85c6d32b726beceaab94ce Mon Sep 17 00:00:00 2001 From: Alex Hamilton Date: Sun, 23 Dec 2018 20:25:01 -0600 Subject: [PATCH] Implement request retrying. This closes #9 and opens #11. --- src/http/client/effects.rs | 26 ++++- src/http/client/mod.rs | 197 +++++++++++++++++++++++++++++++++++-- src/http/client/scenes.rs | 15 ++- src/http/client/states.rs | 63 +++++++++++- 4 files changed, 291 insertions(+), 10 deletions(-) diff --git a/src/http/client/effects.rs b/src/http/client/effects.rs index 4b27784..bdabdc3 100644 --- a/src/http/client/effects.rs +++ b/src/http/client/effects.rs @@ -1,5 +1,7 @@ +use std::num::NonZeroU8; + use crate::http::{ - client::{AsRequest, Client, Selected}, + client::{unity, AsRequest, Attempts, Client, Selected}, selector::Select, state::{Color, Duration}, }; @@ -63,6 +65,7 @@ impl<'a, T: Select> BreathePayload<'a, T> { pub struct Breathe<'a, T: Select> { pub(crate) parent: &'a Selected<'a, T>, inner: BreathePayload<'a, T>, + attempts: Option, } impl<'a, T: Select> Breathe<'a, T> { @@ -70,6 +73,7 @@ impl<'a, T: Select> Breathe<'a, T> { Self { parent, inner: BreathePayload::new(&parent.selector, color), + attempts: None, } } /// Sets the starting color. @@ -190,6 +194,12 @@ impl<'a, T: Select> Breathe<'a, T> { } } +impl<'a, T: Select> Attempts for Breathe<'a, T> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a, T: Select> AsRequest> for Breathe<'a, T> { fn method() -> reqwest::Method { Method::POST @@ -203,6 +213,9 @@ impl<'a, T: Select> AsRequest> for Breathe<'a, T> { fn body(&self) -> &'_ BreathePayload<'a, T> { &self.inner } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } #[derive(Clone, Serialize)] @@ -241,6 +254,7 @@ impl<'a, T: Select> PulsePayload<'a, T> { pub struct Pulse<'a, T: Select> { parent: &'a Selected<'a, T>, inner: PulsePayload<'a, T>, + attempts: Option, } impl<'a, T: Select> Pulse<'a, T> { @@ -248,6 +262,7 @@ impl<'a, T: Select> Pulse<'a, T> { Self { parent, inner: PulsePayload::new(&parent.selector, color), + attempts: None, } } /// Sets the starting color. @@ -349,6 +364,12 @@ impl<'a, T: Select> Pulse<'a, T> { } } +impl<'a, T: Select> Attempts for Pulse<'a, T> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a, T: Select> AsRequest> for Pulse<'a, T> { fn method() -> reqwest::Method { Method::POST @@ -362,4 +383,7 @@ impl<'a, T: Select> AsRequest> for Pulse<'a, T> { fn body(&self) -> &'_ PulsePayload<'a, T> { &self.inner } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } diff --git a/src/http/client/mod.rs b/src/http/client/mod.rs index 71f188f..cd0fc97 100644 --- a/src/http/client/mod.rs +++ b/src/http/client/mod.rs @@ -1,9 +1,16 @@ +use std::num::NonZeroU8; use std::string::ToString; +use std::time::{Duration, Instant, SystemTime}; use crate::http::{selector::Select, state::Color}; use reqwest::{Client as ReqwestClient, Method}; use serde::Serialize; +#[inline] +pub(crate) fn unity() -> NonZeroU8 { + NonZeroU8::new(1).expect("1 == 0") +} + mod effects; mod scenes; mod states; @@ -21,15 +28,32 @@ pub trait AsRequest { fn path(&self) -> String; /// The request body to be used, as configured by the user. fn body(&self) -> &'_ S; + /// The number of attempts to be made. + fn attempts(&self) -> NonZeroU8; } /// The result type for all requests made with the client. -pub type ClientResult = Result; +pub type ClientResult = Result; /// The crux of the HTTP API. Start here. /// /// The client is the entry point for the web API interface. First construct a client, then use it /// to perform whatever tasks necessary. +/// +/// ## Example +/// ``` +/// use lifxi::http::*; +/// # fn run() { +/// let client = Client::new("foo"); +/// let result = client +/// .select(Selector::All) +/// .set_state() +/// .color(Color::Red) +/// .power(true) +/// .retry() +/// .send(); +/// # } +/// ``` pub struct Client { client: ReqwestClient, token: String, @@ -91,6 +115,7 @@ impl Client { path: format!("/color?string={}", color), body: (), method: Method::GET, + attempts: unity(), } } /// Entry point for working with scenes. @@ -101,14 +126,97 @@ impl Client { } } +/// Represents an error encountered when sending a request. +/// +/// Errors may come from a variety of sources, but the ones handled most directly by this crate are +/// client errors. If a client error occurs, we map it to a user-friendly error variant; if another +/// error occurs, we just wrap it and return it. This means that errors stemming from your mistakes +/// are easier to diagnose than errors from the middleware stack. +pub enum Error { + /// The API is enforcing a rate limit. The associated value is the time at which the rate limit + /// will be lifted, if it was specified. + RateLimited(Option), + /// The request was malformed and should not be reattempted (HTTP 400 or 422). + /// If this came from library methods, please + /// [create an issue](https://github.com/Aehmlo/lifxi/issues/new). If you're using a custom + /// color somewhere, please first [validate it](struct.Client.html#method.validate). Otherwise, + /// check for empty strings. + BadRequest, + /// The specified access token was invalid (HTTP 401). + BadAccessToken, + /// The requested OAuth scope was invalid (HTTP 403). + BadOAuthScope, + /// The given selector (or scene UUID) did not match anything associated with this account + /// (HTTP 404). The URL is returned as well, if possible, to help with troubleshooting. + NotFound(Option), + /// The API server encountered an error, but the request was (seemingly) valid (HTTP 5xx). + Server(Option, reqwest::Error), + /// An HTTP stack error was encountered. + Http(reqwest::Error), + /// A serialization error was encountered. + Serialization(reqwest::Error), + /// A bad redirect was encountered. + Redirect(reqwest::Error), + /// A miscellaneous client error occurred (HTTP 4xx). + Client(Option, reqwest::Error), + /// Some other error occured. + Other(reqwest::Error), +} + +impl Error { + /// Whether the error is a client error (indicating that the request should not be retried + /// without modification). + fn is_client_error(&self) -> bool { + use self::Error::*; + match self { + RateLimited(_) + | BadRequest + | BadAccessToken + | BadOAuthScope + | NotFound(_) + | Client(_, _) => true, + _ => false, + } + } +} + +impl From for Error { + fn from(err: reqwest::Error) -> Self { + use self::Error::*; + use reqwest::StatusCode; + if err.is_client_error() { + match err.status() { + Some(StatusCode::BAD_REQUEST) | Some(StatusCode::UNPROCESSABLE_ENTITY) => { + BadRequest + } + Some(StatusCode::UNAUTHORIZED) => BadAccessToken, + Some(StatusCode::FORBIDDEN) => BadOAuthScope, + Some(StatusCode::NOT_FOUND) => NotFound(err.url().map(|u| u.as_str().to_string())), + s => Client(s, err), + } + } else if err.is_http() { + Http(err) + } else if err.is_serialization() { + Serialization(err) + } else if err.is_redirect() { + Redirect(err) + } else if err.is_server_error() { + Server(err.status(), err) + } else { + Other(err) + } + } +} + /// Represents a terminal request. /// -/// The only thing to be done with this request is [send it](#method.send); no further configuration is possible. +/// The only thing to be done with this request is [send it](#method.send). pub struct Request<'a, S> { client: &'a Client, path: String, body: S, method: Method, + attempts: NonZeroU8, } impl<'a, S> Request<'a, S> @@ -119,16 +227,57 @@ where /// /// Requests are synchronous, so this method blocks. pub fn send(&self) -> ClientResult { + use reqwest::StatusCode; + let header = |name: &'static str| reqwest::header::HeaderName::from_static(name); let token = self.client.token.as_str(); let client = &self.client.client; let url = &format!("https://api.lifx.com/v1{}", self.path); let method = self.method.clone(); - client + let result = client .request(method, url) .bearer_auth(token) .json(&self.body) - .send()? - .error_for_status() + .send()?; + let headers = result.headers(); + let reset = headers.get(&header("x-ratelimit-reset")).map(|s| { + if let Ok(val) = s.to_str() { + if let Ok(future) = val.parse::() { + let now = (SystemTime::now(), Instant::now()); + if let Ok(timestamp) = now + .0 + .duration_since(SystemTime::UNIX_EPOCH) + .map(|t| t.as_secs()) + { + return now.1 + Duration::from_secs(future - timestamp); + } + } + } + Instant::now() + Duration::from_secs(60) + }); + let mut result = result.error_for_status().map_err(|e| { + if e.status() == Some(StatusCode::TOO_MANY_REQUESTS) { + Error::RateLimited(reset) + } else { + e.into() + } + }); + for _ in 1..self.attempts.get() { + match result { + Ok(r) => { + return Ok(r); + } + Err(e) => { + if let Error::RateLimited(Some(t)) = e { + // Wait until we're allowed to try again. + ::std::thread::sleep(t - Instant::now()); + } else if e.is_client_error() { + return Err(e); + } + result = self.send(); + } + } + } + result } } @@ -143,7 +292,7 @@ pub trait Send { impl<'a, T, S> Send for T where - T: AsRequest, + T: AsRequest + Retry, S: Serialize, { /// Delegates to [`Request::send`](struct.Request.html#method.send). @@ -153,11 +302,46 @@ where client: self.client(), method: Self::method(), path: self.path(), + attempts: self.attempts(), }; request.send() } } +/// Enables automatic implementation of [`Retry`](trait.Retry.html). +#[doc(hidden)] +pub trait Attempts { + /// Updates the number of times to retry the request. + fn set_attempts(&mut self, attempts: NonZeroU8); +} + +impl<'a, S: Serialize> Attempts for Request<'a, S> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = attempts; + } +} + +/// Trait enabling retrying of failed requests. +pub trait Retry { + /// Retries the corresponding request once. + fn retry(&mut self) -> &'_ mut Self; + /// Retries the corresponding request the given number of times. + fn retries(&mut self, n: NonZeroU8) -> &'_ mut Self; +} + +impl Retry for T +where + T: Attempts, +{ + fn retry(&mut self) -> &'_ mut Self { + self.retries(unity()) + } + fn retries(&mut self, n: NonZeroU8) -> &'_ mut Self { + self.set_attempts(n); + self + } +} + /// A scoped request that can be used to get or set light states. /// /// Created by [`Client::select`](struct.Client.html#method.select). @@ -189,6 +373,7 @@ where path: format!("/lights/{}", self.selector), body: (), method: Method::GET, + attempts: unity(), } } /// Creates a request to set a uniform state on one or more lights. diff --git a/src/http/client/scenes.rs b/src/http/client/scenes.rs index c72a811..893e258 100644 --- a/src/http/client/scenes.rs +++ b/src/http/client/scenes.rs @@ -1,8 +1,9 @@ use crate::http::{ - client::{AsRequest, Client, Request}, + client::{unity, AsRequest, Attempts, Client, Request}, state::{Duration, State}, }; use reqwest::Method; +use std::num::NonZeroU8; /// A waypoint in working with scenes. /// @@ -31,6 +32,7 @@ impl<'a> Scenes<'a> { path: "/scenes".to_string(), body: (), method: Method::GET, + attempts: unity(), } } /// Creates a configurable request for activating a specific scene. @@ -87,6 +89,7 @@ pub struct Activate<'a> { parent: &'a Scenes<'a>, uuid: String, inner: ActivatePayload, + attempts: Option, } impl<'a> Activate<'a> { @@ -95,6 +98,7 @@ impl<'a> Activate<'a> { parent, uuid, inner: ActivatePayload::default(), + attempts: None, } } /// Sets the transition time for the scene activation. @@ -154,6 +158,12 @@ impl<'a> Activate<'a> { } } +impl<'a> Attempts for Activate<'a> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a> AsRequest for Activate<'a> { fn method() -> reqwest::Method { Method::PUT @@ -167,4 +177,7 @@ impl<'a> AsRequest for Activate<'a> { fn body(&self) -> &'_ ActivatePayload { &self.inner } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } diff --git a/src/http/client/states.rs b/src/http/client/states.rs index e032fdb..97847d0 100644 --- a/src/http/client/states.rs +++ b/src/http/client/states.rs @@ -1,9 +1,10 @@ use crate::http::{ - client::{AsRequest, Client, Request, Selected}, + client::{unity, AsRequest, Attempts, Client, Request, Selected}, state::{Color, Duration, Power, State, StateChange}, Select, }; use reqwest::Method; +use std::num::NonZeroU8; /// A scoped request to toggle specific lights which may be further customized. /// @@ -32,11 +33,15 @@ use reqwest::Method; /// # } pub struct Toggle<'a, T: Select> { parent: &'a Selected<'a, T>, + attempts: Option, } impl<'a, T: Select> Toggle<'a, T> { pub(crate) fn new(parent: &'a Selected<'a, T>) -> Self { - Self { parent } + Self { + parent, + attempts: None, + } } /// Sets the transition time for the toggle. /// @@ -57,10 +62,17 @@ impl<'a, T: Select> Toggle<'a, T> { path: format!("/lights/{}/toggle", self.parent.selector), body: duration.into(), method: Method::POST, + attempts: self.attempts.unwrap_or_else(unity), } } } +impl<'a, T: Select> Attempts for Toggle<'a, T> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a, T: Select> AsRequest<()> for Toggle<'a, T> { fn method() -> reqwest::Method { Method::POST @@ -74,6 +86,9 @@ impl<'a, T: Select> AsRequest<()> for Toggle<'a, T> { fn body(&self) -> &'_ () { &() } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } /// A scoped request to uniformly set the state for all selected bulbs. @@ -98,6 +113,7 @@ impl<'a, T: Select> AsRequest<()> for Toggle<'a, T> { /// ``` pub struct SetState<'a, T: Select> { parent: &'a Selected<'a, T>, + attempts: Option, new: State, } @@ -106,6 +122,7 @@ impl<'a, T: Select> SetState<'a, T> { Self { parent, new: State::default(), + attempts: None, } } /// Sets the power state of all selected bulbs. @@ -200,6 +217,12 @@ impl<'a, T: Select> SetState<'a, T> { } } +impl<'a, T: Select> Attempts for SetState<'a, T> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a, T: Select> AsRequest for SetState<'a, T> { fn method() -> reqwest::Method { Method::PUT @@ -213,6 +236,9 @@ impl<'a, T: Select> AsRequest for SetState<'a, T> { fn body(&self) -> &'_ State { &self.new } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } #[derive(Clone, Serialize)] @@ -253,6 +279,7 @@ pub struct SetStatesPayload { pub struct SetStates<'a> { parent: &'a Client, inner: SetStatesPayload, + attempts: Option, } impl<'a> SetStates<'a> { @@ -260,6 +287,7 @@ impl<'a> SetStates<'a> { Self { parent, inner: SetStatesPayload::default(), + attempts: None, } } /// Adds the given state to the list. @@ -278,6 +306,12 @@ impl<'a> SetStates<'a> { } } +impl<'a> Attempts for SetStates<'a> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a> AsRequest for SetStates<'a> { fn method() -> reqwest::Method { Method::PUT @@ -291,6 +325,9 @@ impl<'a> AsRequest for SetStates<'a> { fn body(&self) -> &'_ SetStatesPayload { &self.inner } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } /// A scoped request to uniformly change the state for all selected bulbs. @@ -316,6 +353,7 @@ impl<'a> AsRequest for SetStates<'a> { pub struct ChangeState<'a, T: Select> { parent: &'a Selected<'a, T>, change: StateChange, + attempts: Option, } impl<'a, T: Select> ChangeState<'a, T> { @@ -323,6 +361,7 @@ impl<'a, T: Select> ChangeState<'a, T> { Self { parent, change: StateChange::default(), + attempts: None, } } /// Sets target power state. @@ -457,6 +496,12 @@ impl<'a, T: Select> ChangeState<'a, T> { } } +impl<'a, T: Select> Attempts for ChangeState<'a, T> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a, T: Select> AsRequest for ChangeState<'a, T> { fn method() -> reqwest::Method { Method::POST @@ -470,6 +515,9 @@ impl<'a, T: Select> AsRequest for ChangeState<'a, T> { fn body(&self) -> &'_ StateChange { &self.change } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } } /// Specifies a list of effects to cycle through. Each request causes the cycle to advance. @@ -502,6 +550,7 @@ impl<'a, T: Select> AsRequest for ChangeState<'a, T> { pub struct Cycle<'a, T: Select> { parent: &'a Selected<'a, T>, inner: CyclePayload<'a, T>, + attempts: Option, } impl<'a, T: Select> Cycle<'a, T> { @@ -509,6 +558,7 @@ impl<'a, T: Select> Cycle<'a, T> { Self { parent, inner: CyclePayload::new(&parent.selector), + attempts: None, } } /// Adds a state to the cycle. @@ -554,6 +604,12 @@ impl<'a, T: Select> CyclePayload<'a, T> { } } +impl<'a, T: Select> Attempts for Cycle<'a, T> { + fn set_attempts(&mut self, attempts: NonZeroU8) { + self.attempts = Some(attempts); + } +} + impl<'a, T: Select> AsRequest> for Cycle<'a, T> { fn method() -> reqwest::Method { Method::POST @@ -567,4 +623,7 @@ impl<'a, T: Select> AsRequest> for Cycle<'a, T> { fn body(&self) -> &'_ CyclePayload<'a, T> { &self.inner } + fn attempts(&self) -> NonZeroU8 { + self.attempts.unwrap_or_else(unity) + } }