Skip to content

Commit 461486f

Browse files
authored
Makes retry logic configurable (#4)
* Added updated logic to handle retries * mask api key * Added example with customised retry strategy * Added more examples and docs
1 parent deb7c80 commit 461486f

File tree

12 files changed

+272
-121
lines changed

12 files changed

+272
-121
lines changed

examples/customise_client.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
use lastfm::ClientBuilder;
2-
use std::env;
32

43
#[tokio::main]
54
async fn main() -> Result<(), Box<dyn std::error::Error>> {
6-
let api_key = env::var("LASTFM_API_KEY")?;
7-
8-
let client = ClientBuilder::new(api_key, "loige")
5+
let client = ClientBuilder::new("some-api-key", "loige")
96
.reqwest_client(reqwest::Client::new())
107
.base_url("http://localhost:8080".parse().unwrap())
118
.build();

src/artist.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
//! # Artist
2+
//!
3+
//! defines the [`Artist`] struct and its methods.
4+
use crate::imageset::ImageSet;
15
use serde::{Deserialize, Serialize};
26

3-
use super::imageset::ImageSet;
4-
7+
/// A Last.fm artist.
58
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)]
69
pub struct Artist {
710
pub image: ImageSet,

src/client.rs

Lines changed: 137 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1-
use super::{
1+
//! # Client
2+
//!
3+
//! The main client module for the Last.fm API.
4+
//!
5+
//! This module contains the [`Client`] struct and its methods.
6+
//! It also provides a [`ClientBuilder`] to create a new [`Client`].
7+
use crate::{
28
errors::Error,
3-
recent_tracks_page::RecentTracksPage,
9+
recent_tracks_page::{RecentTracksPage, RecentTracksResponse},
10+
retry_strategy::{JitteredBackoff, RetryStrategy},
411
track::{NowPlayingTrack, RecordedTrack, Track},
512
};
6-
use crate::{recent_tracks_page::RecentTracksResponse, retry_delay::RetryDelay};
713
use async_stream::try_stream;
814
use std::{
915
env::{self, VarError},
16+
fmt::Debug,
1017
time::Duration,
1118
};
1219
use tokio_stream::Stream;
1320
use url::Url;
1421

15-
const BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/";
22+
/// The default base URL for the Last.fm API.
23+
pub const DEFAULT_BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/";
1624

1725
lazy_static! {
1826
static ref DEFAULT_CLIENT: reqwest::Client = reqwest::ClientBuilder::new()
@@ -26,71 +34,126 @@ lazy_static! {
2634
.expect("Cannot initialize HTTP client");
2735
}
2836

37+
/// Utility function that masks the API key by replacing all but the first 3 characters with `*`.
38+
fn mask_api_key(api_key: &str) -> String {
39+
api_key
40+
.chars()
41+
.enumerate()
42+
.map(|(i, c)| match i {
43+
0 | 1 | 2 => c,
44+
_ => '*',
45+
})
46+
.collect()
47+
}
48+
49+
/// A builder for the [`Client`] struct.
2950
pub struct ClientBuilder {
3051
api_key: String,
3152
username: String,
3253
reqwest_client: Option<reqwest::Client>,
3354
base_url: Option<Url>,
55+
retry_strategy: Option<Box<dyn RetryStrategy>>,
3456
}
3557

3658
impl ClientBuilder {
59+
/// Creates a new [`ClientBuilder`] with the given API key and username.
3760
pub fn new<A: AsRef<str>, U: AsRef<str>>(api_key: A, username: U) -> Self {
3861
Self {
3962
api_key: api_key.as_ref().to_string(),
4063
username: username.as_ref().to_string(),
4164
reqwest_client: None,
4265
base_url: None,
66+
retry_strategy: None,
4367
}
4468
}
4569

70+
/// Creates a new [`ClientBuilder`] with the given username.
71+
/// This is a shortcut for [`ClientBuilder::try_from_env`] that panics instead of returning an error.
72+
///
73+
/// # Panics
74+
/// This methods expects the `LASTFM_API_KEY` environment variable to be set and it would panic otherwise.
4675
pub fn from_env<U: AsRef<str>>(username: U) -> Self {
47-
Self::try_from_env(username).unwrap()
76+
Self::try_from_env(username).expect("Cannot read LASTFM_API_KEY from environment")
4877
}
4978

79+
/// Creates a new [`ClientBuilder`] with the given username.
80+
/// This methods expects the `LASTFM_API_KEY` environment variable to be set and it would return an error otherwise.
5081
pub fn try_from_env<U: AsRef<str>>(username: U) -> Result<Self, VarError> {
5182
let api_key = env::var("LASTFM_API_KEY")?;
5283
Ok(ClientBuilder::new(api_key, username))
5384
}
5485

86+
/// Sets the [`reqwest::Client`] to use for the requests.
5587
pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
5688
self.reqwest_client = Some(client);
5789
self
5890
}
5991

92+
/// Sets the base URL for the Last.fm API.
6093
pub fn base_url(mut self, base_url: Url) -> Self {
6194
self.base_url = Some(base_url);
6295
self
6396
}
6497

98+
/// Sets the retry strategy to use for the requests.
99+
///
100+
/// For more details on how you can create a custom retry strategy, consult the [`crate::retry_strategy::RetryStrategy`] trait.
101+
pub fn retry_strategy(mut self, retry_strategy: Box<dyn RetryStrategy>) -> Self {
102+
self.retry_strategy = Some(retry_strategy);
103+
self
104+
}
105+
106+
/// Builds the [`Client`] instance.
65107
pub fn build(self) -> Client {
66108
Client {
67109
api_key: self.api_key,
68110
username: self.username,
69111
reqwest_client: self
70112
.reqwest_client
71113
.unwrap_or_else(|| DEFAULT_CLIENT.clone()),
72-
base_url: self.base_url.unwrap_or_else(|| BASE_URL.parse().unwrap()),
114+
base_url: self
115+
.base_url
116+
.unwrap_or_else(|| DEFAULT_BASE_URL.parse().unwrap()),
117+
retry_strategy: self
118+
.retry_strategy
119+
.unwrap_or_else(|| Box::from(JitteredBackoff::default())),
73120
}
74121
}
75122
}
76123

77-
#[derive(Debug, Clone)]
124+
/// A client for the Last.fm API.
78125
pub struct Client {
79126
api_key: String,
80127
username: String,
81128
reqwest_client: reqwest::Client,
82129
base_url: Url,
130+
retry_strategy: Box<dyn RetryStrategy>,
83131
}
84132

133+
impl Debug for Client {
134+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135+
f.debug_struct("Client")
136+
.field("api_key", &mask_api_key(&self.api_key).as_str())
137+
.field("username", &self.username)
138+
.field("reqwest_client", &self.reqwest_client)
139+
.field("base_url", &self.base_url)
140+
.finish()
141+
}
142+
}
143+
144+
/// Structs that can be used to get a stream of [`RecordedTrack`]s.
145+
#[non_exhaustive]
85146
pub struct RecentTracksFetcher {
86147
api_key: String,
87148
username: String,
88149
current_page: Vec<RecordedTrack>,
89150
from: Option<i64>,
90151
to: Option<i64>,
152+
/// The total number of tracks available in the stream.
91153
pub total_tracks: u64,
92154
reqwest_client: reqwest::Client,
93155
base_url: Url,
156+
retry_strategy: Box<dyn RetryStrategy>,
94157
}
95158

96159
impl RecentTracksFetcher {
@@ -109,6 +172,7 @@ impl RecentTracksFetcher {
109172
self.to = to;
110173
}
111174

175+
/// Converts the current instance into a stream of [`RecordedTrack`]s.
112176
pub fn into_stream(mut self) -> impl Stream<Item = Result<RecordedTrack, Error>> {
113177
let recent_tracks = try_stream! {
114178
loop {
@@ -117,7 +181,16 @@ impl RecentTracksFetcher {
117181
yield t;
118182
}
119183
None => {
120-
let next_page = get_page(&self.reqwest_client, &self.base_url, &self.api_key, &self.username, 200, self.from, self.to).await?;
184+
let next_page = get_page(GetPageOptions {
185+
client: &self.reqwest_client,
186+
retry_strategy: &*self.retry_strategy,
187+
base_url: &self.base_url,
188+
api_key: &self.api_key,
189+
username: &self.username,
190+
limit: 200,
191+
from: self.from,
192+
to: self.to
193+
}).await?;
121194
if next_page.tracks.is_empty() {
122195
break;
123196
}
@@ -131,38 +204,43 @@ impl RecentTracksFetcher {
131204
}
132205
}
133206

134-
async fn get_page<A: AsRef<str>, U: AsRef<str>>(
135-
client: &reqwest::Client,
136-
base_url: &Url,
137-
api_key: A,
138-
username: U,
207+
/// Configuration options used for the [`Client::get_page`] function.
208+
struct GetPageOptions<'a> {
209+
client: &'a reqwest::Client,
210+
retry_strategy: &'a dyn RetryStrategy,
211+
base_url: &'a Url,
212+
api_key: &'a str,
213+
username: &'a str,
139214
limit: u32,
140215
from: Option<i64>,
141216
to: Option<i64>,
142-
) -> Result<RecentTracksPage, Error> {
217+
}
218+
219+
/// Gets a page of tracks from the Last.fm API.
220+
async fn get_page(options: GetPageOptions<'_>) -> Result<RecentTracksPage, Error> {
143221
let mut url_query = vec![
144222
("method", "user.getrecenttracks".to_string()),
145-
("user", username.as_ref().to_string()),
223+
("user", options.username.to_string()),
146224
("format", "json".to_string()),
147225
("extended", "1".to_string()),
148-
("limit", limit.to_string()),
149-
("api_key", api_key.as_ref().to_string()),
226+
("limit", options.limit.to_string()),
227+
("api_key", options.api_key.to_string()),
150228
];
151229

152-
if let Some(from) = from {
230+
if let Some(from) = options.from {
153231
url_query.push(("from", from.to_string()));
154232
}
155233

156-
if let Some(to) = to {
234+
if let Some(to) = options.to {
157235
url_query.push(("to", to.to_string()));
158236
}
159237

160-
let url = Url::parse_with_params(base_url.as_str(), &url_query).unwrap();
238+
let url = Url::parse_with_params(options.base_url.as_str(), &url_query).unwrap();
161239

162-
let retry = RetryDelay::default();
163240
let mut errors: Vec<Error> = Vec::new();
164-
for sleep_time in retry {
165-
let res = client.get(&(url).to_string()).send().await;
241+
let mut num_retries: usize = 0;
242+
while let Some(retry_delay) = options.retry_strategy.should_retry_after(num_retries) {
243+
let res = options.client.get(&(url).to_string()).send().await;
166244
match res {
167245
Ok(res) => {
168246
let page: RecentTracksResponse = res.json().await?;
@@ -175,40 +253,52 @@ async fn get_page<A: AsRef<str>, U: AsRef<str>>(
175253
if !e.is_retriable() {
176254
return Err(e.into());
177255
}
178-
tokio::time::sleep(sleep_time).await;
256+
tokio::time::sleep(retry_delay).await;
179257
}
180258
}
181259
}
182260
Err(e) => {
183261
tracing::error!("Error: {}", e);
184262
errors.push(e.into());
185-
tokio::time::sleep(sleep_time).await;
263+
tokio::time::sleep(retry_delay).await;
186264
}
187265
}
266+
num_retries += 1;
188267
}
189268

190269
Err(Error::TooManyRetry(errors))
191270
}
192271

193272
impl Client {
273+
/// Creates a new [`Client`] with the given username.
274+
/// The API key is read from the `LASTFM_API_KEY` environment variable.
275+
/// This method is a shortcut for [`ClientBuilder::from_env`] but, in case of failure, it will panic rather than returning an error.
276+
///
277+
/// # Panics
278+
/// If the environment variable is not set, this function will panic.
194279
pub fn from_env<U: AsRef<str>>(username: U) -> Self {
195280
ClientBuilder::try_from_env(username).unwrap().build()
196281
}
197282

283+
/// Creates a new [`Client`] with the given username.
284+
/// The API key is read from the `LASTFM_API_KEY` environment variable.
285+
/// If the environment variable is not set, this function will return an error.
198286
pub fn try_from_env<U: AsRef<str>>(username: U) -> Result<Self, VarError> {
199287
Ok(ClientBuilder::try_from_env(username)?.build())
200288
}
201289

290+
/// Fetches the currently playing track for the user (if any)
202291
pub async fn now_playing(&self) -> Result<Option<NowPlayingTrack>, Error> {
203-
let page = get_page(
204-
&self.reqwest_client,
205-
&self.base_url,
206-
&self.api_key,
207-
&self.username,
208-
1,
209-
None,
210-
None,
211-
)
292+
let page = get_page(GetPageOptions {
293+
client: &self.reqwest_client,
294+
retry_strategy: &*self.retry_strategy,
295+
base_url: &self.base_url,
296+
api_key: &self.api_key,
297+
username: &self.username,
298+
limit: 1,
299+
from: None,
300+
to: None,
301+
})
212302
.await?;
213303

214304
match page.tracks.first() {
@@ -217,24 +307,29 @@ impl Client {
217307
}
218308
}
219309

310+
/// Creates a new [`RecentTracksFetcher`] that can be used to fetch all of the user's recent tracks.
220311
pub async fn all_tracks(self) -> Result<RecentTracksFetcher, Error> {
221312
self.recent_tracks(None, None).await
222313
}
223314

315+
/// Creates a new [`RecentTracksFetcher`] that can be used to fetch the user's recent tracks in a given time range.
316+
///
317+
/// The `from` and `to` parameters are Unix timestamps (in seconds).
224318
pub async fn recent_tracks(
225319
self,
226320
from: Option<i64>,
227321
to: Option<i64>,
228322
) -> Result<RecentTracksFetcher, Error> {
229-
let page = get_page(
230-
&self.reqwest_client,
231-
&self.base_url,
232-
&self.api_key,
233-
&self.username,
234-
200,
323+
let page = get_page(GetPageOptions {
324+
client: &self.reqwest_client,
325+
retry_strategy: &*self.retry_strategy,
326+
base_url: &self.base_url,
327+
api_key: &self.api_key,
328+
username: &self.username,
329+
limit: 200,
235330
from,
236331
to,
237-
)
332+
})
238333
.await?;
239334

240335
let mut fetcher = RecentTracksFetcher {
@@ -246,6 +341,7 @@ impl Client {
246341
total_tracks: page.total_tracks,
247342
reqwest_client: self.reqwest_client,
248343
base_url: self.base_url,
344+
retry_strategy: self.retry_strategy,
249345
};
250346

251347
fetcher.update_current_page(page);

0 commit comments

Comments
 (0)