rate limited and cache controll attempt

This commit is contained in:
Robin Appelman 2024-12-26 00:25:35 +01:00
commit 988f2e4603
7 changed files with 544 additions and 199 deletions

172
Cargo.lock generated
View file

@ -4,18 +4,18 @@ version = 3
[[package]]
name = "addr2line"
version = "0.21.0"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
"gimli",
]
[[package]]
name = "adler"
version = "1.0.2"
name = "adler2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "android-tzdata"
@ -63,17 +63,17 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "backtrace"
version = "0.3.71"
version = "0.3.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
dependencies = [
"addr2line",
"cc",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-targets",
]
[[package]]
@ -102,9 +102,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
[[package]]
name = "cc"
version = "1.2.4"
version = "1.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf"
checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e"
dependencies = [
"shlex",
]
@ -135,33 +135,6 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "color-eyre"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55146f5e46f237f7423d74111267d4597b59b0dad0ffaf7303bce9945d843ad5"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@ -276,7 +249,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -294,16 +267,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "failure"
version = "0.1.8"
@ -389,9 +352,9 @@ dependencies = [
[[package]]
name = "gimli"
version = "0.28.1"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "hashbrown"
@ -441,9 +404,9 @@ checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946"
[[package]]
name = "hyper"
version = "1.5.1"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f"
checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0"
dependencies = [
"bytes",
"futures-channel",
@ -460,9 +423,9 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.3"
version = "0.27.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333"
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
dependencies = [
"futures-util",
"http",
@ -633,7 +596,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -663,12 +626,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]]
name = "indexmap"
version = "2.7.0"
@ -709,9 +666,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.168"
version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "litemap"
@ -725,6 +682,12 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "main_error"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "155db5e86c6e45ee456bf32fad5a290ee1f7151c2faca27ea27097568da67d1a"
[[package]]
name = "memchr"
version = "2.7.4"
@ -739,11 +702,11 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.7.4"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394"
dependencies = [
"adler",
"adler2",
]
[[package]]
@ -784,9 +747,9 @@ dependencies = [
[[package]]
name = "object"
version = "0.32.2"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
@ -803,12 +766,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "owo-colors"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -886,7 +843,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.7",
"thiserror 2.0.9",
"tokio",
"tracing",
]
@ -905,7 +862,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.7",
"thiserror 2.0.9",
"tinyvec",
"tracing",
"web-time",
@ -913,9 +870,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.8"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd4b1eff68bf27940dd39811292c49e007f4d0b4c357358dc9b0197be6b527"
checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904"
dependencies = [
"cfg_aliases",
"libc",
@ -1042,12 +999,13 @@ dependencies = [
name = "rss-webhook-trigger"
version = "0.1.0"
dependencies = [
"color-eyre",
"main_error",
"reqwest",
"secretfile",
"serde",
"serde_json",
"syndication",
"thiserror 2.0.9",
"time",
"tokio",
"toml",
@ -1142,14 +1100,14 @@ checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
name = "serde_json"
version = "1.0.133"
version = "1.0.134"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d"
dependencies = [
"itoa",
"memchr",
@ -1275,9 +1233,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.90"
version = "2.0.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31"
checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
dependencies = [
"proc-macro2",
"quote 1.0.37",
@ -1332,7 +1290,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -1346,11 +1304,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.7"
version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767"
checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc"
dependencies = [
"thiserror-impl 2.0.7",
"thiserror-impl 2.0.9",
]
[[package]]
@ -1361,18 +1319,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
name = "thiserror-impl"
version = "2.0.7"
version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36"
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -1428,9 +1386,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.8.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8"
dependencies = [
"tinyvec_macros",
]
@ -1466,7 +1424,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -1538,7 +1496,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -1551,16 +1509,6 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
@ -1681,7 +1629,7 @@ dependencies = [
"log",
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
"wasm-bindgen-shared",
]
@ -1716,7 +1664,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -1940,7 +1888,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
"synstructure 0.13.1",
]
@ -1962,7 +1910,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]
[[package]]
@ -1982,7 +1930,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
"synstructure 0.13.1",
]
@ -2011,5 +1959,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote 1.0.37",
"syn 2.0.90",
"syn 2.0.91",
]

View file

@ -11,10 +11,11 @@ syndication = "0.5.0"
reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread", "signal"] }
serde = { version = "1.0.216", features = ["derive"] }
serde_json = "1.0.133"
serde_json = "1.0.134"
toml = "0.8.19"
color-eyre = "0.6.3"
main_error = "0.1.2"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
time = { version = "0.3.37", features = ["serde", "serde-well-known"] }
thiserror = "2.0.9"
secretfile = "0.1.0"

View file

@ -1,4 +1,3 @@
use color_eyre::{eyre::WrapErr, Result};
use reqwest::header::{HeaderValue, InvalidHeaderValue};
use secretfile::{load, SecretError};
use serde::de::Error;
@ -9,6 +8,7 @@ use std::convert::{TryFrom, TryInto};
use std::fs::read_to_string;
use std::path::Path;
use tokio::time::Duration;
use crate::error::ConfigError;
#[derive(Debug, Deserialize)]
pub struct Config {
@ -27,10 +27,12 @@ pub struct FeedConfig {
}
impl Config {
pub fn from_file(path: &str) -> Result<Self> {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let path = path.as_ref();
let file = read_to_string(path)
.wrap_err_with(|| format!("Failed to open config file {}", path))?;
toml::from_str(&file).wrap_err_with(|| format!("Failed to open config file {}", path))
.map_err(|error| ConfigError::Read {error, path: path.into()})?;
toml::from_str(&file)
.map_err(|error| ConfigError::Parse {error, path: path.into()})
}
pub fn interval(&self) -> Duration {
@ -55,7 +57,7 @@ impl<'de> Deserialize<'de> for HeaderVal {
impl TryFrom<&HeaderVal> for HeaderValue {
type Error = InvalidHeaderValue;
fn try_from(header: &HeaderVal) -> std::result::Result<Self, Self::Error> {
fn try_from(header: &HeaderVal) -> Result<Self, Self::Error> {
header.0.as_str().try_into()
}
}

62
src/error.rs Normal file
View file

@ -0,0 +1,62 @@
use std::path::PathBuf;
use std::str::FromStr;
use reqwest::StatusCode;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ParseFeedError {
#[error("{0}")]
Parse(<syndication::Feed as FromStr>::Err),
#[error("Empty feed")]
Empty,
#[error("No guid, pubDate or link set on feed item")]
MissingKey,
}
#[derive(Debug, Error)]
pub enum FetchFeedError {
#[error("Error while fetching feed: {0:#}")]
Network(#[from] reqwest::Error),
#[error("Error while parsing feed: {0:#}")]
Parse(#[from] ParseFeedError),
#[error("Docker hub returned a server error {0}")]
ServerError(StatusCode),
#[error("Docker hub returned a client error {0}")]
ClientError(StatusCode),
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Error while reading config file {}: {:#}", path.display(), error)]
Read {
error: std::io::Error,
path: PathBuf,
},
#[error("Error while parse config file {}: {:#}", path.display(), error)]
Parse {
error: toml::de::Error,
path: PathBuf,
},
}
#[derive(Debug, Error)]
pub enum HubError {
#[error("Error while fetching docker hub info: {0:#}")]
Network(#[from] reqwest::Error),
#[error("Error while parsing hub response: {0:#}")]
Parse(#[from] serde_json::Error),
#[error("Docker hub returned a server error {0}")]
ServerError(StatusCode),
#[error("Docker hub returned a client error {0}")]
ClientError(StatusCode),
#[error("Invalid hub url format")]
InvalidFormat,
}
#[derive(Debug, Error)]
pub enum FetchError {
#[error(transparent)]
Feed(#[from] FetchFeedError),
#[error(transparent)]
Hub(#[from] HubError),
}

263
src/fetcher.rs Normal file
View file

@ -0,0 +1,263 @@
use reqwest::header::{
HeaderMap, HeaderValue, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, RETRY_AFTER,
};
use reqwest::{Response, StatusCode};
use std::future::Future;
use std::time::{Duration, Instant};
use time::format_description::well_known::Rfc2822;
use time::OffsetDateTime;
/// waiting 6 hours after a 429 should be slow enough for everyone
const DEFAULT_BACKOFF: Duration = Duration::from_secs(6 * 60 * 60);
const ONE_SEC: Duration = Duration::from_secs(1);
pub enum FetchPlanInput {
Retry {
time: Instant,
headers: CacheHeaders,
},
WithCache {
headers: CacheHeaders,
},
}
impl FetchPlanInput {
#[allow(dead_code)]
pub fn into_cache_headers(self) -> CacheHeaders {
match self {
FetchPlanInput::Retry { headers, .. } => headers,
FetchPlanInput::WithCache { headers } => headers,
}
}
}
#[derive(Default, Debug)]
pub struct CacheHeaders {
etag: Option<String>,
last_modified: Option<OffsetDateTime>,
}
impl CacheHeaders {
pub fn from_headers(headers: &HeaderMap) -> CacheHeaders {
let etag = headers
.get(ETAG)
.and_then(|header| header.to_str().ok())
.map(String::from);
let last_modified = headers
.get(LAST_MODIFIED)
.and_then(|header| header.to_str().ok())
.and_then(|s| OffsetDateTime::parse(s, &Rfc2822).ok());
CacheHeaders {
etag,
last_modified,
}
}
pub fn set_headers(&self, headers: &mut HeaderMap) {
match (&self.last_modified, &self.etag) {
(_, Some(etag)) => {
headers.insert(
IF_NONE_MATCH,
HeaderValue::from_str(etag).expect("malformed etag"),
);
}
(Some(last_modified), None) => {
headers.insert(
IF_MODIFIED_SINCE,
HeaderValue::from_str(&last_modified.format(&Rfc2822).unwrap()).unwrap(),
);
}
_ => {}
}
}
pub fn headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
self.set_headers(&mut headers);
headers
}
}
pub struct FetchPlan {
pub time: Instant,
pub headers: CacheHeaders,
}
impl FetchPlan {
pub fn elapsed(&self) -> bool {
Instant::now() > self.time
}
}
impl Default for FetchPlan {
fn default() -> FetchPlan {
FetchPlan {
time: Instant::now(),
headers: CacheHeaders::default(),
}
}
}
/// plan the next fetch, either on startup or right after we finished the previous fetch
pub fn next_fetch(base_interval: Duration, last_result: Option<FetchPlanInput>) -> FetchPlan {
let now = Instant::now();
match last_result {
Some(FetchPlanInput::Retry { time, headers }) => FetchPlan {
time: now.max(time),
headers,
},
Some(FetchPlanInput::WithCache { headers }) => FetchPlan {
time: now + base_interval,
headers,
},
None => FetchPlan {
time: now + base_interval,
headers: CacheHeaders::default(),
},
}
}
pub enum FetchResponse<T, E> {
Retry {
time: Instant,
headers: CacheHeaders,
},
Ok {
headers: CacheHeaders,
response: T,
},
Error {
headers: CacheHeaders,
error: E,
},
}
impl<T, E> FetchResponse<T, E> {
#[allow(dead_code)]
pub fn plan(self) -> FetchPlanInput {
match self {
FetchResponse::Retry { time, headers } => FetchPlanInput::Retry { time, headers },
FetchResponse::Ok { headers, .. } => FetchPlanInput::WithCache { headers },
FetchResponse::Error { headers, .. } => FetchPlanInput::WithCache { headers },
}
}
pub fn into_result(self) -> Result<(Option<T>, FetchPlanInput), (E, FetchPlanInput)> {
match self {
FetchResponse::Retry { time, headers } => {
Ok((None, FetchPlanInput::Retry { time, headers }))
}
FetchResponse::Ok { headers, response } => {
Ok((Some(response), FetchPlanInput::WithCache { headers }))
}
FetchResponse::Error { headers, error } => {
Err((error, FetchPlanInput::WithCache { headers }))
}
}
}
}
impl<E> FetchResponse<Response, E> {
pub fn from_result(result: Result<Response, E>) -> FetchResponse<Response, E> {
match result {
Ok(response) => {
let cache_header = CacheHeaders::from_headers(response.headers());
if response.status() == StatusCode::TOO_MANY_REQUESTS {
let after = response
.headers()
.get(RETRY_AFTER)
.and_then(|header| header.to_str().ok())
.and_then(|str| str.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(DEFAULT_BACKOFF);
FetchResponse::Retry {
time: Instant::now() + after + ONE_SEC,
headers: cache_header,
}
} else {
FetchResponse::Ok {
headers: cache_header,
response,
}
}
}
Err(err) => FetchResponse::Error {
error: err,
headers: CacheHeaders::default(),
},
}
}
pub fn check_status_code<Fc, Fs>(self, client_error: Fc, server_error: Fs) -> Self
where
Fc: Fn(StatusCode) -> E,
Fs: Fn(StatusCode) -> E,
{
match self {
FetchResponse::Ok { headers, response } => {
let status = response.status();
if status.is_client_error() {
FetchResponse::Error {
error: client_error(status),
headers,
}
} else if status.is_server_error() {
FetchResponse::Error {
error: server_error(status),
headers,
}
} else {
FetchResponse::Ok { headers, response }
}
}
rest => rest,
}
}
}
impl<T, E> FetchResponse<T, E> {
pub async fn map<U, Fut, F>(self, f: F) -> FetchResponse<U, E>
where
Fut: Future<Output = U>,
F: Fn(T) -> Fut,
{
match self {
FetchResponse::Retry { time, headers } => FetchResponse::Retry { time, headers },
FetchResponse::Ok { headers, response } => FetchResponse::Ok {
headers,
response: f(response).await,
},
FetchResponse::Error { error, headers } => FetchResponse::Error { error, headers },
}
}
pub fn map_err<U, F>(self, f: F) -> FetchResponse<T, U>
where
F: Fn(E) -> U,
{
match self {
FetchResponse::Retry { time, headers } => FetchResponse::Retry { time, headers },
FetchResponse::Ok { headers, response } => FetchResponse::Ok { headers, response },
FetchResponse::Error { error, headers } => FetchResponse::Error {
error: f(error),
headers,
},
}
}
}
impl<T, E> FetchResponse<Result<T, E>, E> {
pub fn flatten(self) -> FetchResponse<T, E> {
match self {
FetchResponse::Retry { time, headers } => FetchResponse::Retry { time, headers },
FetchResponse::Ok {
headers,
response: Ok(response),
} => FetchResponse::Ok { headers, response },
FetchResponse::Ok {
headers,
response: Err(error),
} => FetchResponse::Error { error, headers },
FetchResponse::Error { error, headers } => FetchResponse::Error { error, headers },
}
}
}

View file

@ -1,33 +1,37 @@
use color_eyre::eyre::WrapErr;
use color_eyre::{eyre::ensure, Result};
use crate::error::HubError;
use crate::fetcher::{CacheHeaders, FetchResponse};
use reqwest::Client;
use serde::Deserialize;
use time::OffsetDateTime;
use tracing::instrument;
#[instrument(skip(client))]
pub async fn tags(client: &Client, user: &str, repo: &str) -> Result<Vec<HubTag>> {
pub async fn tags(
client: &Client,
user: &str,
repo: &str,
cache_headers: &CacheHeaders,
) -> FetchResponse<Vec<HubTag>, HubError> {
let result = client
.get(format!(
"https://hub.docker.com/v2/repositories/{}/{}/tags",
user, repo
))
.headers(cache_headers.headers())
.send()
.await;
FetchResponse::from_result(result)
.map_err(HubError::Network)
.check_status_code(HubError::ClientError, HubError::ServerError)
.map(|response| async {
response
.text()
.await
.wrap_err("error with sending docker hub request")?;
ensure!(
!result.status().is_client_error(),
"error with sending docker hub request {}/{}: {}", user, repo, result.status()
);
ensure!(
!result.status().is_server_error(),
"docker hub request returned an error {}/{}: {}", user, repo, result.status()
);
Ok(result
.json::<HubTagResponse>()
.await
.wrap_err("failed to parse hub response")?
.results)
.map_err(HubError::Network)
.and_then(|text| serde_json::from_str::<HubTagResponse>(&text).map_err(HubError::Parse))
.map(|result| result.results)
}).await.flatten()
}
#[derive(Debug, Deserialize)]

View file

@ -1,24 +1,27 @@
mod config;
mod error;
mod fetcher;
mod hub;
use crate::config::{Config, FeedConfig};
use color_eyre::{
eyre::{eyre, WrapErr},
Result,
};
use reqwest::Client;
use syndication::Feed;
use crate::error::{FetchError, FetchFeedError, HubError, ParseFeedError};
use crate::fetcher::{next_fetch, CacheHeaders, FetchPlan, FetchResponse};
use main_error::MainResult;
use reqwest::{Client, Response};
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::future::ready;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use tokio::time::sleep;
use tokio::signal::ctrl_c;
use std::time::{Duration};
use syndication::Feed;
use tokio::select;
use tracing::{debug, error, info, instrument};
use tokio::signal::ctrl_c;
use tokio::time::sleep;
use tracing::{debug, error, info, instrument, warn};
#[tokio::main]
async fn main() -> Result<()> {
async fn main() -> MainResult {
tracing_subscriber::fmt::init();
let mut args = std::env::args();
let bin = args.next().unwrap();
@ -33,9 +36,11 @@ async fn main() -> Result<()> {
let config = Config::from_file(&file)?;
println!("Running rss trigger for {} feeds", config.feed.len());
info!("Running rss trigger for {} feeds", config.feed.len());
let ctrl_c = async { ctrl_c().await.ok(); };
let ctrl_c = async {
ctrl_c().await.ok();
};
select! {
_ = ctrl_c => {},
@ -45,7 +50,7 @@ async fn main() -> Result<()> {
}
async fn main_loop(config: Config) {
let mut fetcher = FeedFetcher::default();
let mut fetcher = FeedFetcher::new(config.interval());
loop {
for feed in config.feed.iter() {
@ -65,7 +70,9 @@ async fn main_loop(config: Config) {
#[instrument(skip_all, fields(feed = feed.feed))]
async fn trigger(client: &Client, feed: &FeedConfig) {
info!("Triggering hook");
let mut req = client.post(&feed.hook).header("user-agent", "rss-webhook-trigger");
let mut req = client
.post(&feed.hook)
.header("user-agent", "rss-webhook-trigger");
for (key, value) in &feed.headers {
req = req.header(key, value);
}
@ -78,76 +85,135 @@ async fn trigger(client: &Client, feed: &FeedConfig) {
}
}
#[derive(Default)]
pub struct FeedFetcher {
client: Client,
base_interval: Duration,
cache: HashMap<String, u64>,
fetch_plans: HashMap<String, FetchPlan>,
}
impl FeedFetcher {
#[instrument(skip(self))]
pub async fn check_feed_updated(&mut self, feed: &str) -> Result<bool> {
let new_key = self.get_feed_key(feed).await?;
pub fn new(interval: Duration) -> Self {
FeedFetcher {
client: Client::default(),
base_interval: interval,
cache: HashMap::default(),
fetch_plans: HashMap::default(),
}
}
Ok(match self.cache.get_mut(feed) {
Some(cached) => {
pub fn should_update(&self, feed: &str) -> bool {
self.fetch_plans.get(feed).filter(|plan| FetchPlan::elapsed(plan)).is_some()
}
#[instrument(skip(self))]
pub async fn check_feed_updated(&mut self, feed: &str) -> Result<bool, FetchError> {
if !self.should_update(feed) {
warn!("skipping feed util rate limited expires");
return Ok(false);
}
let plan = self.fetch_plans.remove(feed).unwrap_or_default();
let fetch_result = self.get_feed_key(feed, &plan.headers).await;
let new_key = match fetch_result.into_result() {
Ok((new_key, new_plan)) => {
self.fetch_plans.insert(feed.into(), next_fetch(self.base_interval, Some(new_plan)));
new_key
}
Err((err, new_plan)) => {
self.fetch_plans.insert(feed.into(), next_fetch(self.base_interval, Some(new_plan)));
return Err(err);
}
};
Ok(match (self.cache.get_mut(feed), new_key) {
(Some(cached), Some(new_key)) => {
debug!(cached, new_key, "checked existing feed");
if *cached != new_key {
if new_key != *cached {
*cached = new_key;
true
} else {
false
}
}
None => {
(None, Some(new_key)) => {
debug!(feed, "new feed");
self.cache.insert(feed.into(), new_key);
// don't trigger the actions on start
false
}
(_, None) => {
warn!("rate limited by server");
false
}
})
}
#[instrument(skip(self))]
async fn get_feed_key(&self, feed: &str) -> Result<u64> {
async fn get_feed_key(
&self,
feed: &str,
cache_headers: &CacheHeaders,
) -> FetchResponse<u64, FetchError> {
if let Some(hub) = feed.strip_prefix("docker-hub://") {
if let Some((user, repo)) = hub.split_once('/') {
let tags = hub::tags(&self.client, user, repo).await?;
hub::tags(&self.client, user, repo, cache_headers)
.await
.map(|tags| {
let mut hasher = DefaultHasher::new();
for tag in tags {
tag.id.hash(&mut hasher);
tag.last_updated.hash(&mut hasher);
}
Ok(hasher.finish())
ready(hasher.finish())
}).await
.map_err(FetchError::Hub)
} else {
Err(eyre!("Invalid hub format {}", feed))
FetchResponse::Error {
error: HubError::InvalidFormat.into(),
headers: CacheHeaders::default(),
}
}
} else {
self.get_rss_feed_key(feed).await
self.get_rss_feed_key(feed, cache_headers)
.await
.map_err(FetchError::Feed)
}
}
#[instrument(skip(self))]
async fn get_rss_feed_key(&self, feed: &str) -> Result<u64> {
let content = self
async fn get_rss_feed_key(
&self,
feed: &str,
cache_headers: &CacheHeaders,
) -> FetchResponse<u64, FetchFeedError> {
let response = self
.client
.get(feed)
.headers(cache_headers.headers())
.send()
.await;
let plan_result = FetchResponse::from_result(response);
plan_result
.map_err(FetchFeedError::Network)
.check_status_code(FetchFeedError::ClientError, FetchFeedError::ServerError)
.map(parse_rss_response)
.await
.wrap_err_with(|| eyre!("Failed to load feed {}", feed))?
.text()
.await
.wrap_err_with(|| eyre!("Failed to load feed {}", feed))?;
let channel = Feed::from_str(&content)
.map_err(|_| eyre!("Failed to parse feed {}", feed))?;
.flatten()
}
}
async fn parse_rss_response(response: Response) -> Result<u64, FetchFeedError> {
let content = response.text().await?;
let channel = Feed::from_str(&content).map_err(ParseFeedError::Parse)?;
let mut hasher = DefaultHasher::new();
match channel {
Feed::RSS(channel) => {
let item = channel.items.first().ok_or(eyre!("Empty feed"))?;
let item = channel.items.first().ok_or(ParseFeedError::Empty)?;
if let Some(guid) = item.guid() {
guid.value.hash(&mut hasher);
@ -156,15 +222,14 @@ impl FeedFetcher {
} else if let Some(link) = item.link() {
link.hash(&mut hasher);
} else {
return Err(eyre!("No guid, pubDate or link set on feed item"));
return Err(ParseFeedError::MissingKey.into());
}
}
Feed::Atom(channel) => {
let item = channel.entries().first().ok_or(eyre!("Empty feed"))?;
let item = channel.entries().first().ok_or(ParseFeedError::Empty)?;
item.id().hash(&mut hasher);
}
}
Ok(hasher.finish())
}
}