main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
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>
3 files changed,
+86 insertions,
-26 deletions
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
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
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()