Show artwork up to 20 MB, and remember and log what failed (#169)

Five Glass Cannon Network feeds name Patreon covers of about 7 MB, over the
5 MB limit on /api/art. Each page load fetched them all again, refused them,
and logged a 502 per cover, about 84 a day, with nothing in the log to say
which image or why: the reason went only to the browser. The limit is now
20 MB, a failure is answered from memory for an hour, and the fetch failure
is logged with its address and reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:10:18 +00:00
parent c37bcf816b
commit 1e7a858e9c
3 changed files with 62 additions and 3 deletions

View File

@@ -2,6 +2,9 @@
//! of each publisher's server for every visit, and still there when a publisher is not.
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
pub fn dir() -> PathBuf {
crate::config::data_dir().join("art")
@@ -38,6 +41,36 @@ pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
std::fs::rename(&tmp, file)
}
/// How long an address that could not be fetched is answered from memory before it is tried again.
const RETRY: Duration = Duration::from_secs(60 * 60);
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
/// Why `url` could not be fetched, if it failed less than an hour ago.
pub fn failed(url: &str) -> Option<String> {
failed_as_of(url, Instant::now())
}
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
match map.get(url) {
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
Some(_) => {
map.remove(url);
None
}
None => None,
}
}
pub fn fail(url: &str, why: &str) {
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
}
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
/// Returns how many went.
pub fn trim(dir: &Path, limit: u64) -> usize {
@@ -89,4 +122,15 @@ mod tests {
assert_eq!(trim(&d, 0), 2);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_failure_is_remembered_for_an_hour() {
let u = "https://example.com/a-failure-is-remembered.png";
assert_eq!(failed(u), None);
fail(u, "larger than 5 MB");
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
// Asked for after the hour, it is forgotten, not just ignored.
assert_eq!(failed(u), None);
}
}