Skip to content

Commit

Permalink
Implement request retrying.
Browse files Browse the repository at this point in the history
This closes #9 and opens #11.
  • Loading branch information
Aehmlo committed Dec 24, 2018
1 parent 405520c commit d1a016d
Show file tree
Hide file tree
Showing 4 changed files with 291 additions and 10 deletions.
26 changes: 25 additions & 1 deletion src/http/client/effects.rs
Original file line number Diff line number Diff line change
@@ -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},
};
Expand Down Expand Up @@ -63,13 +65,15 @@ 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<NonZeroU8>,
}

impl<'a, T: Select> Breathe<'a, T> {
pub(crate) fn new(parent: &'a Selected<'a, T>, color: Color) -> Self {
Self {
parent,
inner: BreathePayload::new(&parent.selector, color),
attempts: None,
}
}
/// Sets the starting color.
Expand Down Expand Up @@ -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<BreathePayload<'a, T>> for Breathe<'a, T> {
fn method() -> reqwest::Method {
Method::POST
Expand All @@ -203,6 +213,9 @@ impl<'a, T: Select> AsRequest<BreathePayload<'a, T>> 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)]
Expand Down Expand Up @@ -241,13 +254,15 @@ impl<'a, T: Select> PulsePayload<'a, T> {
pub struct Pulse<'a, T: Select> {
parent: &'a Selected<'a, T>,
inner: PulsePayload<'a, T>,
attempts: Option<NonZeroU8>,
}

impl<'a, T: Select> Pulse<'a, T> {
pub(crate) fn new(parent: &'a Selected<'a, T>, color: Color) -> Self {
Self {
parent,
inner: PulsePayload::new(&parent.selector, color),
attempts: None,
}
}
/// Sets the starting color.
Expand Down Expand Up @@ -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<PulsePayload<'a, T>> for Pulse<'a, T> {
fn method() -> reqwest::Method {
Method::POST
Expand All @@ -362,4 +383,7 @@ impl<'a, T: Select> AsRequest<PulsePayload<'a, T>> for Pulse<'a, T> {
fn body(&self) -> &'_ PulsePayload<'a, T> {
&self.inner
}
fn attempts(&self) -> NonZeroU8 {
self.attempts.unwrap_or_else(unity)
}
}
197 changes: 191 additions & 6 deletions src/http/client/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,15 +28,32 @@ pub trait AsRequest<S: Serialize> {
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<reqwest::Response, reqwest::Error>;
pub type ClientResult = Result<reqwest::Response, Error>;

/// 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,
Expand Down Expand Up @@ -91,6 +115,7 @@ impl Client {
path: format!("/color?string={}", color),
body: (),
method: Method::GET,
attempts: unity(),
}
}
/// Entry point for working with scenes.
Expand All @@ -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<Instant>),
/// 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<String>),
/// The API server encountered an error, but the request was (seemingly) valid (HTTP 5xx).
Server(Option<reqwest::StatusCode>, 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::StatusCode>, 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<reqwest::Error> 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>
Expand All @@ -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::<u64>() {
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
}
}

Expand All @@ -143,7 +292,7 @@ pub trait Send<S> {

impl<'a, T, S> Send<S> for T
where
T: AsRequest<S>,
T: AsRequest<S> + Retry,
S: Serialize,
{
/// Delegates to [`Request::send`](struct.Request.html#method.send).
Expand All @@ -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<T> 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).
Expand Down Expand Up @@ -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.
Expand Down
Loading

0 comments on commit d1a016d

Please sign in to comment.