Skip to content

Commit

Permalink
cohost2json: implement retries with exponential backoff (#33)
Browse files Browse the repository at this point in the history
  • Loading branch information
delan committed Dec 30, 2024
1 parent 44e7950 commit b0618ea
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 6 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ eula = false
[dependencies]
ammonia = "4.0.0"
askama = "0.12.1"
bytes = "1.7.1"
chrono = "0.4.38"
clap = { version = "4.5.23", features = ["derive"] }
comrak = "0.28.0"
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ rustPlatform.buildRustPackage {
};

# don't forget to update this hash when Cargo.lock or ${version} changes!
cargoHash = "sha256-kkzroGu5+h5g3qcjwsXsgBSF3uFaECfYmfm1hHHb1uE=";
cargoHash = "sha256-r4ZZ3IdM4DGeBtfx9DsBiy4vijM2UmT19G3YpnTHmzA=";

meta = {
description = "cohost-compatible blog engine and feed reader";
Expand Down
76 changes: 71 additions & 5 deletions src/command/cohost2json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ use std::{
env::{self},
fs::{create_dir_all, File},
path::Path,
str,
time::Duration,
};

use bytes::Bytes;
use jane_eyre::eyre::{self, bail, OptionExt};
use reqwest::{
header::{self, HeaderMap, HeaderValue},
Client,
Client, Response,
};
use scraper::{selector::Selector, Html};
use serde::de::DeserializeOwned;
use tokio::time::sleep;
use tracing::{error, info, warn};

use crate::cohost::{
Expand Down Expand Up @@ -170,12 +174,74 @@ pub async fn main(args: Cohost2json) -> eyre::Result<()> {
Ok(())
}

async fn get_text(client: &Client, url: &str) -> eyre::Result<String> {
get_with_retries(client, url, text).await
}

async fn get_json<T: DeserializeOwned>(client: &Client, url: &str) -> eyre::Result<T> {
info!("GET {url}");
Ok(client.get(url).send().await?.json().await?)
get_with_retries(client, url, json).await
}

async fn get_text(client: &Client, url: &str) -> eyre::Result<String> {
async fn get_with_retries<T>(
client: &Client,
url: &str,
mut and_then: impl FnMut(Bytes) -> eyre::Result<T>,
) -> eyre::Result<T> {
let mut retries = 4;
let mut wait = Duration::from_secs(4);
loop {
let result = get_response_once(client, url).await;
let status = result
.as_ref()
.map_or(None, |response| Some(response.status()));
let result = match match result {
Ok(response) => Ok(response.bytes().await),
Err(error) => Err(error),
} {
Ok(Ok(bytes)) => Ok(bytes),
Ok(Err(error)) | Err(error) => Err::<Bytes, eyre::Report>(error.into()),
};
// retry requests if they are neither client errors (http 4xx), nor if they are successful
// (http 2xx) and the given fallible transformation fails. this includes server errors
// (http 5xx), and requests that failed in a way that yields no response.
let error = if status.is_some_and(|s| s.is_client_error()) {
// client errors (http 4xx) should not be retried.
bail!("GET request failed (no retries): http {:?}: {url}", status);
} else if status.is_some_and(|s| s.is_success()) {
// apply the given fallible transformation to the response body.
// if that succeeds, we succeed, otherwise we retry.
let result = result.and_then(&mut and_then);
if result.is_ok() {
return result;
}
result.err()
} else {
// when retrying server errors (http 5xx), error is None.
// when retrying failures with no response, error is Some.
result.err()
};
if retries == 0 {
bail!(
"GET request failed (after retries): http {:?}: {url}",
status,
);
}
warn!(?wait, ?status, url, ?error, "retrying failed GET request");
sleep(wait).await;
wait *= 2;
retries -= 1;
}
}

async fn get_response_once(client: &Client, url: &str) -> reqwest::Result<Response> {
info!("GET {url}");
Ok(client.get(url).send().await?.text().await?)
client.get(url).send().await
}

fn text(body: Bytes) -> eyre::Result<String> {
Ok(str::from_utf8(&body)?.to_owned())
}

fn json<T: DeserializeOwned>(body: Bytes) -> eyre::Result<T> {
Ok(serde_json::from_slice(&body)?)
}

0 comments on commit b0618ea

Please sign in to comment.