From 0b350eb13e596b6facee044b6467e04da7877e33 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:10:51 +0100 Subject: [PATCH 01/11] fastly: Move wildcard check into `purge()` fn --- src/fastly.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fastly.rs b/src/fastly.rs index 03b45839357..e2ebdd310ab 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -36,12 +36,6 @@ impl Fastly { /// #[instrument(skip(self))] pub async fn invalidate(&self, path: &str) -> anyhow::Result<()> { - if path.contains('*') { - return Err(anyhow!( - "wildcard invalidations are not supported for Fastly" - )); - } - let domains = [ &self.static_domain_name, &format!("fastly-{}", self.static_domain_name), @@ -56,6 +50,12 @@ impl Fastly { #[instrument(skip(self))] pub async fn purge(&self, domain: &str, path: &str) -> anyhow::Result<()> { + if path.contains('*') { + return Err(anyhow!( + "wildcard invalidations are not supported for Fastly" + )); + } + let path = path.trim_start_matches('/'); let url = format!("https://api.fastly.com/purge/{domain}/{path}"); From fae5e3c6de66d80e60f0c6e9c43f61e71f80f6c0 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:12:33 +0100 Subject: [PATCH 02/11] fastly: Add doc comment to `purge()` fn --- src/fastly.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fastly.rs b/src/fastly.rs index e2ebdd310ab..323c81b4ed7 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -48,6 +48,14 @@ impl Fastly { Ok(()) } + /// Invalidate a path on Fastly + /// + /// This method takes a domain and path and invalidates the cached content + /// on Fastly. The path must not contain a wildcard, since the Fastly API + /// does not support wildcard invalidations. + /// + /// More information on Fastly's APIs for cache invalidations can be found here: + /// #[instrument(skip(self))] pub async fn purge(&self, domain: &str, path: &str) -> anyhow::Result<()> { if path.contains('*') { From 48fe8e3da31791f14a539c513d58d44539a01814 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:16:21 +0100 Subject: [PATCH 03/11] fastly: Simplified `invalidate()` fn --- src/fastly.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/fastly.rs b/src/fastly.rs index 323c81b4ed7..c1f652d67a0 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -36,14 +36,10 @@ impl Fastly { /// #[instrument(skip(self))] pub async fn invalidate(&self, path: &str) -> anyhow::Result<()> { - let domains = [ - &self.static_domain_name, - &format!("fastly-{}", self.static_domain_name), - ]; + self.purge(&self.static_domain_name, path).await?; - for domain in domains { - self.purge(domain, path).await?; - } + let prefixed_domain = format!("fastly-{}", self.static_domain_name); + self.purge(&prefixed_domain, path).await?; Ok(()) } From 2d527e5b2119a33687b81ea3645d7fd1bff04742 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:25:29 +0100 Subject: [PATCH 04/11] fastly: Remove `static_domain_name` field This makes the client more flexible, allowing us to reuse it for `index.crates.io` in the future. --- src/fastly.rs | 15 ++++----------- src/worker/environment.rs | 9 +++++++-- src/worker/jobs/generate_og_image.rs | 3 ++- src/worker/jobs/invalidate_cdns.rs | 6 ++++-- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/fastly.rs b/src/fastly.rs index c1f652d67a0..d81623c05ed 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -8,19 +8,12 @@ use tracing::{debug, instrument, trace}; pub struct Fastly { client: Client, api_token: SecretString, - static_domain_name: String, } impl Fastly { pub fn from_environment(client: Client) -> Option { let api_token = dotenvy::var("FASTLY_API_TOKEN").ok()?.into(); - let static_domain_name = dotenvy::var("S3_CDN").expect("missing S3_CDN"); - - Some(Self { - client, - api_token, - static_domain_name, - }) + Some(Self { client, api_token }) } /// Invalidate a path on Fastly @@ -35,10 +28,10 @@ impl Fastly { /// More information on Fastly's APIs for cache invalidations can be found here: /// #[instrument(skip(self))] - pub async fn invalidate(&self, path: &str) -> anyhow::Result<()> { - self.purge(&self.static_domain_name, path).await?; + pub async fn invalidate(&self, base_domain: &str, path: &str) -> anyhow::Result<()> { + self.purge(base_domain, path).await?; - let prefixed_domain = format!("fastly-{}", self.static_domain_name); + let prefixed_domain = format!("fastly-{base_domain}"); self.purge(&prefixed_domain, path).await?; Ok(()) diff --git a/src/worker/environment.rs b/src/worker/environment.rs index 0de9d1500a5..0f3c976b71e 100644 --- a/src/worker/environment.rs +++ b/src/worker/environment.rs @@ -91,8 +91,13 @@ impl Environment { result.context("Failed to enqueue CloudFront invalidation processing job")?; } - if let Some(fastly) = self.fastly() { - fastly.invalidate(path).await.context("Fastly")?; + if let Some(fastly) = self.fastly() + && let Some(cdn_domain) = &self.config.storage.cdn_prefix + { + fastly + .invalidate(cdn_domain, path) + .await + .context("Fastly")?; } Ok(()) diff --git a/src/worker/jobs/generate_og_image.rs b/src/worker/jobs/generate_og_image.rs index 3fdfce69818..6737a859495 100644 --- a/src/worker/jobs/generate_og_image.rs +++ b/src/worker/jobs/generate_og_image.rs @@ -122,7 +122,8 @@ impl BackgroundJob for GenerateOgImage { // Invalidate Fastly CDN if let Some(fastly) = ctx.fastly() - && let Err(error) = fastly.invalidate(&og_image_path).await + && let Some(cdn_domain) = &ctx.config.storage.cdn_prefix + && let Err(error) = fastly.invalidate(cdn_domain, &og_image_path).await { warn!("Failed to invalidate Fastly CDN for {crate_name}: {error}"); } diff --git a/src/worker/jobs/invalidate_cdns.rs b/src/worker/jobs/invalidate_cdns.rs index 7b402f4e387..aebee1a3fc4 100644 --- a/src/worker/jobs/invalidate_cdns.rs +++ b/src/worker/jobs/invalidate_cdns.rs @@ -39,10 +39,12 @@ impl BackgroundJob for InvalidateCdns { // For now, we won't parallelise: most crate deletions are for new crates with one (or very // few) versions, so the actual number of paths being invalidated is likely to be small, and // this is all happening from either a background job or admin command anyway. - if let Some(fastly) = ctx.fastly() { + if let Some(fastly) = ctx.fastly() + && let Some(cdn_domain) = &ctx.config.storage.cdn_prefix + { for path in self.paths.iter() { fastly - .invalidate(path) + .invalidate(cdn_domain, path) .await .with_context(|| format!("Failed to invalidate path on Fastly CDN: {path}"))?; } From fd4caf93d6deadcadee110deff159bcf1051ecda Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:29:20 +0100 Subject: [PATCH 05/11] fastly: Rename `invalidate()` fn to `purge_both_domains()` --- src/fastly.rs | 2 +- src/worker/environment.rs | 2 +- src/worker/jobs/generate_og_image.rs | 2 +- src/worker/jobs/invalidate_cdns.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastly.rs b/src/fastly.rs index d81623c05ed..f2613d9d92b 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -28,7 +28,7 @@ impl Fastly { /// More information on Fastly's APIs for cache invalidations can be found here: /// #[instrument(skip(self))] - pub async fn invalidate(&self, base_domain: &str, path: &str) -> anyhow::Result<()> { + pub async fn purge_both_domains(&self, base_domain: &str, path: &str) -> anyhow::Result<()> { self.purge(base_domain, path).await?; let prefixed_domain = format!("fastly-{base_domain}"); diff --git a/src/worker/environment.rs b/src/worker/environment.rs index 0f3c976b71e..70ff29c2ba6 100644 --- a/src/worker/environment.rs +++ b/src/worker/environment.rs @@ -95,7 +95,7 @@ impl Environment { && let Some(cdn_domain) = &self.config.storage.cdn_prefix { fastly - .invalidate(cdn_domain, path) + .purge_both_domains(cdn_domain, path) .await .context("Fastly")?; } diff --git a/src/worker/jobs/generate_og_image.rs b/src/worker/jobs/generate_og_image.rs index 6737a859495..4839764b661 100644 --- a/src/worker/jobs/generate_og_image.rs +++ b/src/worker/jobs/generate_og_image.rs @@ -123,7 +123,7 @@ impl BackgroundJob for GenerateOgImage { // Invalidate Fastly CDN if let Some(fastly) = ctx.fastly() && let Some(cdn_domain) = &ctx.config.storage.cdn_prefix - && let Err(error) = fastly.invalidate(cdn_domain, &og_image_path).await + && let Err(error) = fastly.purge_both_domains(cdn_domain, &og_image_path).await { warn!("Failed to invalidate Fastly CDN for {crate_name}: {error}"); } diff --git a/src/worker/jobs/invalidate_cdns.rs b/src/worker/jobs/invalidate_cdns.rs index aebee1a3fc4..1fc97fb92eb 100644 --- a/src/worker/jobs/invalidate_cdns.rs +++ b/src/worker/jobs/invalidate_cdns.rs @@ -44,7 +44,7 @@ impl BackgroundJob for InvalidateCdns { { for path in self.paths.iter() { fastly - .invalidate(cdn_domain, path) + .purge_both_domains(cdn_domain, path) .await .with_context(|| format!("Failed to invalidate path on Fastly CDN: {path}"))?; } From 5c60be08e5273400763df74cc3eff1a309680f9f Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:31:37 +0100 Subject: [PATCH 06/11] fastly: Extract `new()` fn --- src/fastly.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fastly.rs b/src/fastly.rs index f2613d9d92b..7df664580de 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -11,9 +11,13 @@ pub struct Fastly { } impl Fastly { + pub fn new(client: Client, api_token: SecretString) -> Self { + Self { client, api_token } + } + pub fn from_environment(client: Client) -> Option { let api_token = dotenvy::var("FASTLY_API_TOKEN").ok()?.into(); - Some(Self { client, api_token }) + Some(Self::new(client, api_token)) } /// Invalidate a path on Fastly From 89289db4fdc9f097973b01be0f691b64e51e009e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:34:02 +0100 Subject: [PATCH 07/11] fastly: Remove `from_environment()` fn --- src/bin/background-worker.rs | 6 +++++- src/fastly.rs | 5 ----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs index 93e850e0f4f..843b43df63a 100644 --- a/src/bin/background-worker.rs +++ b/src/bin/background-worker.rs @@ -27,6 +27,7 @@ use crates_io_index::RepositoryConfig; use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepoImpl; use crates_io_worker::Runner; +use generic_array::functional::FunctionalSequence; use object_store::prefix::PrefixStore; use reqwest::Client; use std::sync::Arc; @@ -85,7 +86,10 @@ fn main() -> anyhow::Result<()> { .expect("Couldn't build client"); let emails = Emails::from_environment(&config); - let fastly = Fastly::from_environment(client.clone()); + + let fastly_api_token = var("FASTLY_API_TOKEN")?.map(Into::into); + let fastly = fastly_api_token.map(|token| Fastly::new(client, token)); + let team_repo = TeamRepoImpl::default(); let docs_rs = RealDocsRsClient::from_environment().map(|cl| Box::new(cl) as _); diff --git a/src/fastly.rs b/src/fastly.rs index 7df664580de..c6f00a6aa11 100644 --- a/src/fastly.rs +++ b/src/fastly.rs @@ -15,11 +15,6 @@ impl Fastly { Self { client, api_token } } - pub fn from_environment(client: Client) -> Option { - let api_token = dotenvy::var("FASTLY_API_TOKEN").ok()?.into(); - Some(Self::new(client, api_token)) - } - /// Invalidate a path on Fastly /// /// This method takes a path and invalidates the cached content on Fastly. The path must not From fb4deec6c5a234368f0a6a889b97bd850f34c330 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:41:15 +0100 Subject: [PATCH 08/11] Extract `crates_io_fastly` workspace crate This should make it easier to iterate on the implementation independently. --- Cargo.lock | 11 +++++++++++ Cargo.toml | 1 + crates/crates_io_fastly/Cargo.toml | 14 ++++++++++++++ crates/crates_io_fastly/README.md | 13 +++++++++++++ .../crates_io_fastly/src/lib.rs | 2 ++ src/bin/background-worker.rs | 3 +-- src/lib.rs | 1 - src/worker/environment.rs | 2 +- 8 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 crates/crates_io_fastly/Cargo.toml create mode 100644 crates/crates_io_fastly/README.md rename src/fastly.rs => crates/crates_io_fastly/src/lib.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index db97554c195..873fd685199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1420,6 +1420,7 @@ dependencies = [ "crates_io_diesel_helpers", "crates_io_docs_rs", "crates_io_env_vars", + "crates_io_fastly", "crates_io_github", "crates_io_index", "crates_io_linecount", @@ -1627,6 +1628,16 @@ dependencies = [ "dotenvy", ] +[[package]] +name = "crates_io_fastly" +version = "0.0.0" +dependencies = [ + "anyhow", + "reqwest", + "secrecy", + "tracing", +] + [[package]] name = "crates_io_github" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index e5b0b550e1e..b7b069b598c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ crates_io_database_dump = { path = "crates/crates_io_database_dump" } crates_io_diesel_helpers = { path = "crates/crates_io_diesel_helpers" } crates_io_docs_rs = { path = "crates/crates_io_docs_rs" } crates_io_env_vars = { path = "crates/crates_io_env_vars" } +crates_io_fastly = { path = "crates/crates_io_fastly" } crates_io_github = { path = "crates/crates_io_github" } crates_io_index = { path = "crates/crates_io_index" } crates_io_linecount = { path = "crates/crates_io_linecount" } diff --git a/crates/crates_io_fastly/Cargo.toml b/crates/crates_io_fastly/Cargo.toml new file mode 100644 index 00000000000..f0cd08e8eca --- /dev/null +++ b/crates/crates_io_fastly/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "crates_io_fastly" +version = "0.0.0" +license = "MIT OR Apache-2.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +anyhow = "=1.0.100" +reqwest = { version = "=0.12.24", features = ["json"] } +secrecy = "=0.10.3" +tracing = "=0.1.41" diff --git a/crates/crates_io_fastly/README.md b/crates/crates_io_fastly/README.md new file mode 100644 index 00000000000..07cf6a4fbef --- /dev/null +++ b/crates/crates_io_fastly/README.md @@ -0,0 +1,13 @@ +# crates_io_fastly + +This package implements functionality for interacting with the Fastly API. + +The `Fastly` struct provides methods for purging cached content on Fastly's CDN. +It uses the `reqwest` crate to perform HTTP requests to the Fastly API and +authenticates using an API token. + +The main operations supported are: +- `purge()` - Purge a specific path on a single domain +- `purge_both_domains()` - Purge a path on both the primary and prefixed domains + +Note that wildcard invalidations are not supported by the Fastly API. diff --git a/src/fastly.rs b/crates/crates_io_fastly/src/lib.rs similarity index 98% rename from src/fastly.rs rename to crates/crates_io_fastly/src/lib.rs index c6f00a6aa11..408d6052f91 100644 --- a/src/fastly.rs +++ b/crates/crates_io_fastly/src/lib.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("../README.md")] + use anyhow::{Context, anyhow}; use reqwest::Client; use reqwest::header::{HeaderMap, HeaderValue}; diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs index 843b43df63a..d7b78bd8425 100644 --- a/src/bin/background-worker.rs +++ b/src/bin/background-worker.rs @@ -16,18 +16,17 @@ extern crate tracing; use anyhow::Context; use crates_io::app::create_database_pool; use crates_io::cloudfront::CloudFront; -use crates_io::fastly::Fastly; use crates_io::ssh; use crates_io::storage::Storage; use crates_io::worker::{Environment, RunnerExt}; use crates_io::{Emails, config}; use crates_io_docs_rs::RealDocsRsClient; use crates_io_env_vars::var; +use crates_io_fastly::Fastly; use crates_io_index::RepositoryConfig; use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepoImpl; use crates_io_worker::Runner; -use generic_array::functional::FunctionalSequence; use object_store::prefix::PrefixStore; use reqwest::Client; use std::sync::Arc; diff --git a/src/lib.rs b/src/lib.rs index 8b6422c83ba..77c424ac508 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,6 @@ pub mod config; pub mod controllers; pub mod db; pub mod email; -pub mod fastly; pub mod headers; pub mod index; mod licenses; diff --git a/src/worker/environment.rs b/src/worker/environment.rs index 70ff29c2ba6..b962f43e684 100644 --- a/src/worker/environment.rs +++ b/src/worker/environment.rs @@ -1,6 +1,5 @@ use crate::Emails; use crate::cloudfront::CloudFront; -use crate::fastly::Fastly; use crate::storage::Storage; use crate::typosquat; use crate::worker::jobs::ProcessCloudfrontInvalidationQueue; @@ -8,6 +7,7 @@ use anyhow::Context; use bon::Builder; use crates_io_database::models::CloudFrontInvalidationQueueItem; use crates_io_docs_rs::DocsRsClient; +use crates_io_fastly::Fastly; use crates_io_index::{Repository, RepositoryConfig}; use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepo; From e7c5e5e190825fbcd5266b0d78d02d3fdc89f910 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:46:21 +0100 Subject: [PATCH 09/11] fastly: Simplify `new()` fn There is not much reason for us to pass in a custom `Client`. We can just create one and simplify the public API surface a bit. --- crates/crates_io_fastly/src/lib.rs | 3 ++- src/bin/background-worker.rs | 8 +------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/crates_io_fastly/src/lib.rs b/crates/crates_io_fastly/src/lib.rs index 408d6052f91..e69c9e55f08 100644 --- a/crates/crates_io_fastly/src/lib.rs +++ b/crates/crates_io_fastly/src/lib.rs @@ -13,7 +13,8 @@ pub struct Fastly { } impl Fastly { - pub fn new(client: Client, api_token: SecretString) -> Self { + pub fn new(api_token: SecretString) -> Self { + let client = Client::new(); Self { client, api_token } } diff --git a/src/bin/background-worker.rs b/src/bin/background-worker.rs index d7b78bd8425..fb20bc9e7ae 100644 --- a/src/bin/background-worker.rs +++ b/src/bin/background-worker.rs @@ -28,7 +28,6 @@ use crates_io_og_image::OgImageGenerator; use crates_io_team_repo::TeamRepoImpl; use crates_io_worker::Runner; use object_store::prefix::PrefixStore; -use reqwest::Client; use std::sync::Arc; use std::thread::sleep; use std::time::Duration; @@ -79,15 +78,10 @@ fn main() -> anyhow::Result<()> { let downloads_archive_store = PrefixStore::new(storage.as_inner(), "archive/version-downloads"); let downloads_archive_store = Box::new(downloads_archive_store); - let client = Client::builder() - .timeout(Duration::from_secs(45)) - .build() - .expect("Couldn't build client"); - let emails = Emails::from_environment(&config); let fastly_api_token = var("FASTLY_API_TOKEN")?.map(Into::into); - let fastly = fastly_api_token.map(|token| Fastly::new(client, token)); + let fastly = fastly_api_token.map(Fastly::new); let team_repo = TeamRepoImpl::default(); From 90fb283d8526de9591a597b2346f582537aae47e Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 16:48:55 +0100 Subject: [PATCH 10/11] fastly: Simplify header setting code --- crates/crates_io_fastly/src/lib.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/crates_io_fastly/src/lib.rs b/crates/crates_io_fastly/src/lib.rs index e69c9e55f08..df246dfbd7a 100644 --- a/crates/crates_io_fastly/src/lib.rs +++ b/crates/crates_io_fastly/src/lib.rs @@ -2,7 +2,7 @@ use anyhow::{Context, anyhow}; use reqwest::Client; -use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue}; use secrecy::{ExposeSecret, SecretString}; use tracing::{debug, instrument, trace}; @@ -60,18 +60,11 @@ impl Fastly { trace!(?url); - let api_token = self.api_token.expose_secret(); - let mut api_token = HeaderValue::try_from(api_token)?; - api_token.set_sensitive(true); - - let mut headers = HeaderMap::new(); - headers.append("Fastly-Key", api_token); - debug!("sending invalidation request to Fastly"); let response = self .client .post(&url) - .headers(headers) + .header("Fastly-Key", self.token_header_value()?) .send() .await .context("failed to send invalidation request to Fastly")?; @@ -97,4 +90,12 @@ impl Fastly { } } } + + fn token_header_value(&self) -> Result { + let api_token = self.api_token.expose_secret(); + + let mut header_value = HeaderValue::try_from(api_token)?; + header_value.set_sensitive(true); + Ok(header_value) + } } From ca2d083294f6fbf0817b721dbf97891ca364a02a Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 13 Nov 2025 17:13:13 +0100 Subject: [PATCH 11/11] fastly: Replace `anyhow` usage with `thiserror` --- Cargo.lock | 2 +- crates/crates_io_fastly/Cargo.toml | 2 +- crates/crates_io_fastly/src/lib.rs | 41 +++++++++++++++++++++++------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 873fd685199..da29bb333e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1632,9 +1632,9 @@ dependencies = [ name = "crates_io_fastly" version = "0.0.0" dependencies = [ - "anyhow", "reqwest", "secrecy", + "thiserror 2.0.17", "tracing", ] diff --git a/crates/crates_io_fastly/Cargo.toml b/crates/crates_io_fastly/Cargo.toml index f0cd08e8eca..909b8f5244a 100644 --- a/crates/crates_io_fastly/Cargo.toml +++ b/crates/crates_io_fastly/Cargo.toml @@ -8,7 +8,7 @@ edition = "2024" workspace = true [dependencies] -anyhow = "=1.0.100" reqwest = { version = "=0.12.24", features = ["json"] } secrecy = "=0.10.3" +thiserror = "=2.0.17" tracing = "=0.1.41" diff --git a/crates/crates_io_fastly/src/lib.rs b/crates/crates_io_fastly/src/lib.rs index df246dfbd7a..409e71d750b 100644 --- a/crates/crates_io_fastly/src/lib.rs +++ b/crates/crates_io_fastly/src/lib.rs @@ -1,11 +1,28 @@ #![doc = include_str!("../README.md")] -use anyhow::{Context, anyhow}; use reqwest::Client; -use reqwest::header::{HeaderMap, HeaderValue, InvalidHeaderValue}; +use reqwest::header::{HeaderValue, InvalidHeaderValue}; use secrecy::{ExposeSecret, SecretString}; +use thiserror::Error; use tracing::{debug, instrument, trace}; +#[derive(Debug, Error)] +pub enum Error { + #[error("Wildcard invalidations are not supported for Fastly")] + WildcardNotSupported, + + #[error("Invalid API token format")] + InvalidApiToken(#[from] InvalidHeaderValue), + + #[error("Failed to `POST {url}`{}: {source}", status.map(|s| format!(" (status: {})", s)).unwrap_or_default())] + PurgeFailed { + url: String, + status: Option, + #[source] + source: reqwest::Error, + }, +} + #[derive(Debug)] pub struct Fastly { client: Client, @@ -30,7 +47,7 @@ impl Fastly { /// More information on Fastly's APIs for cache invalidations can be found here: /// #[instrument(skip(self))] - pub async fn purge_both_domains(&self, base_domain: &str, path: &str) -> anyhow::Result<()> { + pub async fn purge_both_domains(&self, base_domain: &str, path: &str) -> Result<(), Error> { self.purge(base_domain, path).await?; let prefixed_domain = format!("fastly-{base_domain}"); @@ -48,11 +65,9 @@ impl Fastly { /// More information on Fastly's APIs for cache invalidations can be found here: /// #[instrument(skip(self))] - pub async fn purge(&self, domain: &str, path: &str) -> anyhow::Result<()> { + pub async fn purge(&self, domain: &str, path: &str) -> Result<(), Error> { if path.contains('*') { - return Err(anyhow!( - "wildcard invalidations are not supported for Fastly" - )); + return Err(Error::WildcardNotSupported); } let path = path.trim_start_matches('/'); @@ -67,7 +82,11 @@ impl Fastly { .header("Fastly-Key", self.token_header_value()?) .send() .await - .context("failed to send invalidation request to Fastly")?; + .map_err(|source| Error::PurgeFailed { + url: url.clone(), + status: None, + source, + })?; let status = response.status(); @@ -86,7 +105,11 @@ impl Fastly { "invalidation request to Fastly failed" ); - Err(error).with_context(|| format!("failed to purge {url}")) + Err(Error::PurgeFailed { + url, + status: Some(status), + source: error, + }) } } }