Skip to main content

max / makenotwork

Fail the forum tabs soft on a transport failure dashboard_tab_forums and library_tab_communities mapped a send() failure to AppError::Internal, and a timeout is a send() failure, so the five-second ceiling that bounds a slow Multithreaded was itself what turned both tabs into 500s. The 2026-08-14 load sweep measured 300 of 300 requests failing at exactly that latency. Route every upstream failure mode, transport and malformed body included, to the empty state the tabs were designed to show.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 21:27 UTC
Signed with PGP, not checked
Commit: 75b3f4f54b46424da31d00aee557598729ad076a
Parent: 2c368f9
3 files changed, +86 insertions, -26 deletions
@@ -767,3 +767,36 @@
767 767 resp.text
768 768 );
769 769 }
770 +
771 + /// The Askama Forums and Communities tabs fail soft on a transport failure.
772 + ///
773 + /// Both handlers mapped a `send()` error to a 500, and a timeout is a `send()`
774 + /// error, so the five-second ceiling that exists to bound a slow Multithreaded
775 + /// was itself what broke the tab: the 2026-08-14 load sweep saw 300 of 300
776 + /// requests answer 500 at exactly that latency. An unreachable address takes
777 + /// the same branch.
778 + #[tokio::test]
779 + async fn forum_tabs_fail_soft_when_multithreaded_is_unreachable() {
780 + let mut h = crate::harness::TestHarness::build(crate::harness::BuildOptions {
781 + // Nothing listens here, so the request fails at the transport.
782 + mt_base_url: Some("http://127.0.0.1:9".to_owned()),
783 + ..Default::default()
784 + })
785 + .await;
786 + h.signup("mtdown", "mtdown@example.com", "password123")
787 + .await;
788 +
789 + let resp = h.client.htmx_get("/dashboard/tabs/forums").await;
790 + assert_eq!(
791 + resp.status, 200,
792 + "forums tab should render the empty state, got: {}",
793 + resp.text
794 + );
795 +
796 + let resp = h.client.htmx_get("/library/tabs/communities").await;
797 + assert_eq!(
798 + resp.status, 200,
799 + "communities tab should render the empty state, got: {}",
800 + resp.text
801 + );
802 + }
@@ -447,28 +447,40 @@
447 447
448 448 let url = format!("{}/api/user/{}/summary", mt_base_url, user.id);
449 449
450 - let resp = crate::helpers::HTTP_CLIENT
450 + // Same fail-soft rule as the Forums tab: a transport failure is an upstream
451 + // failure like any other and renders the empty list, not a 500.
452 + let empty = || {
453 + LibraryCommunitiesTabTemplate {
454 + memberships: vec![],
455 + mt_base_url: mt_base_url.clone(),
456 + }
457 + .into_response()
458 + };
459 +
460 + let resp = match crate::helpers::HTTP_CLIENT
451 461 .get(&url)
452 462 .timeout(std::time::Duration::from_secs(5))
453 463 .send()
454 464 .await
455 - .map_err(|e| {
465 + {
466 + Ok(resp) => resp,
467 + Err(e) => {
456 468 tracing::warn!(error = ?e, "failed to fetch MT user summary");
457 - AppError::Internal(anyhow::anyhow!("MT API unavailable"))
458 - })?;
469 + return Ok(empty());
470 + }
471 + };
459 472
460 473 if !resp.status().is_success() {
461 - return Ok(LibraryCommunitiesTabTemplate {
462 - memberships: vec![],
463 - mt_base_url: mt_base_url.clone(),
464 - }
465 - .into_response());
474 + return Ok(empty());
466 475 }
467 476
468 - let json: serde_json::Value = resp.json().await.map_err(|e| {
469 - tracing::warn!(error = ?e, "failed to parse MT summary response");
470 - AppError::Internal(anyhow::anyhow!("MT API response invalid"))
471 - })?;
477 + let json: serde_json::Value = match resp.json().await {
478 + Ok(json) => json,
479 + Err(e) => {
480 + tracing::warn!(error = ?e, "failed to parse MT summary response");
481 + return Ok(empty());
482 + }
483 + };
472 484
473 485 let memberships = json["memberships"]
474 486 .as_array()
@@ -31,28 +31,43 @@
31 31
32 32 let url = format!("{}/api/user/{}/summary", mt_base_url, session_user.id);
33 33
34 - let resp = crate::helpers::HTTP_CLIENT
34 + // Every upstream failure mode ends at the same empty state, including the
35 + // transport ones. A timeout is a `send()` error, so mapping it to a 500 made
36 + // the five-second ceiling the thing that broke the tab: at exactly that
37 + // latency the load sweep saw 300 of 300 requests answer 500 instead of
38 + // rendering the empty list this tab was designed to show.
39 + let empty = || {
40 + UserForumsTabTemplate {
41 + memberships: vec![],
42 + mt_base_url: mt_base_url.clone(),
43 + }
44 + .into_response()
45 + };
46 +
47 + let resp = match crate::helpers::HTTP_CLIENT
35 48 .get(&url)
36 49 .timeout(std::time::Duration::from_secs(5))
37 50 .send()
38 51 .await
39 - .map_err(|e| {
52 + {
53 + Ok(resp) => resp,
54 + Err(e) => {
40 55 tracing::warn!(error = ?e, "failed to fetch MT user summary");
41 - AppError::Internal(anyhow::anyhow!("MT API unavailable"))
42 - })?;
56 + return Ok(empty());
57 + }
58 + };
43 59
44 60 if !resp.status().is_success() {
45 - return Ok(UserForumsTabTemplate {
46 - memberships: vec![],
47 - mt_base_url: mt_base_url.clone(),
48 - }
49 - .into_response());
61 + return Ok(empty());
50 62 }
51 63
52 - let json: serde_json::Value = resp.json().await.map_err(|e| {
53 - tracing::warn!(error = ?e, "failed to parse MT summary response");
54 - AppError::Internal(anyhow::anyhow!("MT API response invalid"))
55 - })?;
64 + let json: serde_json::Value = match resp.json().await {
65 + Ok(json) => json,
66 + Err(e) => {
67 + tracing::warn!(error = ?e, "failed to parse MT summary response");
68 + return Ok(empty());
69 + }
70 + };
56 71
57 72 let memberships = json["memberships"]
58 73 .as_array()