fix(dl): only log in when qBittorrent asks
ci / web (push) Successful in 32s
e2e / e2e (push) Failing after 1m56s
ci / rust (push) Successful in 2m0s
ci / image (push) Successful in 2m27s

Logging in before the first call turned a stale or wrong password into a
hard failure on calls that would have succeeded: an instance that bypasses
authentication for arr's address answers everything, and never needed the
credentials at all. Authentication is now established on the first 403.

The health probe was reading a 403 from `app/version` as healthy, on the
reasoning that a live daemon is all the lamp claims. That is exactly the
state arr cannot use, and it left the lamp green while every grab and every
reaper tick failed on rejected credentials. It now lists torrents through
the configured client, so the lamp fails when arr's own calls do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-29 18:42:43 +01:00
co-authored by Claude Opus 5
parent ab4f95b166
commit 9ecbfde656
3 changed files with 70 additions and 27 deletions
+14 -14
View File
@@ -181,21 +181,21 @@ async fn probe_prowlarr(state: &AppState) -> Check {
}
}
/// A live `WebUI` answers `/api/v2/app/version`, and answers `403` when the
/// probe carries no session. Both are a running daemon, which is all this
/// lamp claims; whether arr's credentials work shows up on the first grab.
/// The probe is a real torrent list through the configured client, not a bare
/// reachability check.
///
/// A `WebUI` that wants credentials answers `403` to anything unauthenticated,
/// so "the port is open" is true of an instance arr cannot use at all. Reading
/// that as healthy once left the lamp green while every grab and every reaper
/// tick failed on a rejected password, which is the one thing this lamp exists
/// to catch.
async fn probe_qbit(state: &AppState) -> Check {
let base = state.upstreams().qbittorrent_url.trim_end_matches('/');
let request = state.http().get(format!("{base}/api/v2/app/version"));
match request.send().await {
Ok(response)
if response.status().is_success()
|| response.status() == reqwest::StatusCode::FORBIDDEN =>
{
Check::ok()
}
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
Err(err) => Check::unreachable(describe(err)),
let Some(qbit) = state.qbit() else {
return Check::unconfigured("no qBittorrent client is configured");
};
match qbit.list_torrents().await {
Ok(_) => Check::ok(),
Err(error) => Check::unreachable(error.to_string()),
}
}
+11 -6
View File
@@ -191,8 +191,8 @@ mod tests {
let qbit = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v2/app/version"))
.respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&qbit)
.await;
@@ -237,6 +237,7 @@ mod tests {
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"))
// The subtitle binaries default to `PATH`; pin them to something
// every machine has so this test stays about the classic upstreams.
.with_syncer(arr_subs::Syncer::new().with_binary("sh"))
@@ -253,7 +254,9 @@ mod tests {
#[tokio::test]
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
let (prowlarr, qbit) = upstreams_up().await;
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri()))
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"));
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unconfigured");
@@ -266,8 +269,9 @@ mod tests {
// Port 1 is privileged and nothing binds it, so the probe gets a
// refused connection immediately instead of waiting out the timeout.
let state =
AppState::new(Upstreams::new("http://127.0.0.1:1".into(), qbit.uri())).expect("state");
let state = AppState::new(Upstreams::new("http://127.0.0.1:1".into(), qbit.uri()))
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"));
let body = report(state).await;
assert_eq!(body["status"], "degraded");
@@ -307,7 +311,8 @@ mod tests {
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
)
.expect("state");
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"));
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unreachable");