launchdarkly-server-sdk 1.0.0-beta.2

LaunchDarkly Server-Side SDK
Documentation
use crate::reqwest::is_http_error_recoverable;

use super::data_store::AllData;
use launchdarkly_server_sdk_evaluation::{Flag, Segment};
use r::{
    header::{HeaderValue, ETAG},
    StatusCode,
};
use reqwest as r;

#[derive(Debug, PartialEq)]
pub enum FeatureRequesterError {
    Temporary,
    Permanent,
}

#[derive(Clone, Debug)]
struct CachedEntry(AllData<Flag, Segment>, String);

pub trait FeatureRequester: Send {
    fn get_all(&mut self) -> Result<AllData<Flag, Segment>, FeatureRequesterError>;
}

pub struct ReqwestFeatureRequester {
    http: r::Client,
    url: r::Url,
    sdk_key: String,
    cache: Option<CachedEntry>,
}

impl ReqwestFeatureRequester {
    pub fn new(http: r::Client, url: r::Url, sdk_key: String) -> Self {
        Self {
            http,
            url,
            sdk_key,
            cache: None,
        }
    }
}

impl FeatureRequester for ReqwestFeatureRequester {
    fn get_all(&mut self) -> Result<AllData<Flag, Segment>, FeatureRequesterError> {
        let mut request_builder = self
            .http
            .get(self.url.clone())
            .header("Content-Type", "application/json")
            .header("Authorization", self.sdk_key.clone())
            .header("User-Agent", &*crate::USER_AGENT);

        if let Some(cache) = &self.cache {
            request_builder = request_builder.header("If-None-Match", cache.1.clone());
        }

        let resp = request_builder.send();

        let mut response = match resp {
            Ok(response) => response,
            Err(e) => {
                error!("An error occurred while retrieving flag information: {}", e);
                return Err(match e.status() {
                    Some(status_code) if is_http_error_recoverable(status_code) => {
                        FeatureRequesterError::Temporary
                    }
                    _ => FeatureRequesterError::Permanent,
                });
            }
        };

        if response.status() == StatusCode::NOT_MODIFIED && self.cache.is_some() {
            let cache = self.cache.clone().unwrap();
            debug!("Returning cached data. Etag: {}", cache.1);
            return Ok(cache.0);
        }

        let etag: String = response
            .headers()
            .get(ETAG)
            .unwrap_or(&HeaderValue::from_static(""))
            .to_str()
            .map_or_else(|_| "".into(), |s| s.into());

        if response.status().is_success() {
            return match response.json::<AllData<Flag, Segment>>() {
                Ok(all_data) => {
                    if !etag.is_empty() {
                        debug!("Caching data for future use with etag: {}", etag);
                        self.cache = Some(CachedEntry(all_data.clone(), etag));
                    }
                    Ok(all_data)
                }
                Err(e) => {
                    error!("An error occurred while parsing the json response: {}", e);
                    Err(FeatureRequesterError::Permanent)
                }
            };
        } else if !is_http_error_recoverable(response.status()) {
            return Err(FeatureRequesterError::Permanent);
        }

        Err(FeatureRequesterError::Temporary)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mockito::mock;
    use test_case::test_case;

    #[test]
    fn updates_etag_as_appropriate() {
        let _initial_request = mock("GET", "/")
            .with_status(200)
            .with_header("etag", "INITIAL-TAG")
            .with_body(r#"{"flags": {}, "segments": {}}"#)
            .expect(1)
            .create();
        let _second_request = mock("GET", "/")
            .with_status(304)
            .match_header("If-None-Match", "INITIAL-TAG")
            .expect(1)
            .create();
        let _third_request = mock("GET", "/")
            .with_status(200)
            .match_header("If-None-Match", "INITIAL-TAG")
            .with_header("etag", "UPDATED-TAG")
            .with_body(r#"{"flags": {}, "segments": {}}"#)
            .create();

        let mut requester = build_feature_requester();
        let result = requester.get_all();

        assert!(result.is_ok());
        if let Some(cache) = &requester.cache {
            assert_eq!("INITIAL-TAG", cache.1);
        }

        let result = requester.get_all();
        assert!(result.is_ok());
        if let Some(cache) = &requester.cache {
            assert_eq!("INITIAL-TAG", cache.1);
        }

        let result = requester.get_all();
        assert!(result.is_ok());
        if let Some(cache) = &requester.cache {
            assert_eq!("UPDATED-TAG", cache.1);
        }
    }

    #[test_case(400, FeatureRequesterError::Temporary)]
    #[test_case(401, FeatureRequesterError::Permanent)]
    #[test_case(408, FeatureRequesterError::Temporary)]
    #[test_case(409, FeatureRequesterError::Permanent)]
    #[test_case(429, FeatureRequesterError::Temporary)]
    #[test_case(430, FeatureRequesterError::Permanent)]
    #[test_case(500, FeatureRequesterError::Temporary)]
    fn correctly_determines_unrecoverable_errors(status: usize, error: FeatureRequesterError) {
        let _initial_request = mock("GET", "/").with_status(status).create();

        let mut requester = build_feature_requester();
        let result = requester.get_all();

        if let Err(err) = result {
            assert_eq!(err, error);
        } else {
            panic!("get_all returned the wrong response");
        }
    }

    fn build_feature_requester() -> ReqwestFeatureRequester {
        let http = r::Client::builder()
            .build()
            .expect("Failed building the client");
        let url = reqwest::Url::parse(&mockito::server_url())
            .expect("Failed parsing the mock server url");

        ReqwestFeatureRequester::new(http, url, "sdk-key".to_string())
    }
}