feat(arr): outbound Sonarr/Radarr client with classified failures + Test Connection (#4) - #61
Merged
Merged
Conversation
PR 2 of the configuration work. Implements IArrClient against the *arr v3 API
(GET /api/v3/system/status, X-Api-Key header) and the Action that drives it from the UI.
The point of ArrHttpClient is that failures are *classified*, not merely reported.
"It doesn't work" is the most common self-hosting complaint and only a specific cause
is actionable — a wrong port looks nothing like a wrong API key, and a reverse-proxy
subpath omitted from the base URL looks nothing like either:
- Unauthorized 401/403 — reached it, key rejected
- ApiNotFound 404 — says so, and names the subpath as the likely cause
- NotAnArrInstance 200 but no appName/version, or not JSON at all — wrong port
- TlsFailure AuthenticationException anywhere in the exception chain
- Unreachable SocketException, or HttpClient's timeout (which surfaces as a
cancellation, not a TimeoutException)
- Unexpected anything else, never silently treated as success
Two details worth knowing:
Base URLs keep their subpath. Instances behind a reverse proxy are commonly served from
something like https://host/sonarr, and Uri's relative resolution drops the last segment
unless the base ends in a slash — so a missing trailing slash would silently turn
/sonarr/api/v3/... into /api/v3/..., producing a 404 that looks like a wrong path.
The API key goes in the header rather than as a query parameter, so it never lands in a
proxy access log. Asserted in a test.
Timeout is 10s, not HttpClient's 100s default: this sits behind an operator clicking
"Test", so a hung connection has to fail fast rather than leave the button spinning.
TestArrConnectionHandler caches the outcome on the instance, so the settings page can
render state without re-probing everything on each page load, and has an overload for
*unsaved* details — so a wrong key never has to be persisted just to discover it is
wrong. Caller cancellation propagates rather than being recorded as an instance failure;
a cancelled page load is not a broken Sonarr.
This is an Action running in a UI host, which is the deliberate narrow deviation from
DR-009 recorded in the plan: the operator clicks a button and expects an answer, and
round-tripping "test this instance" through the durable bus to an agent is far more
machinery than a button warrants.
Tested against a stubbed HttpMessageHandler rather than as a live test — Live.Tests hits
real broadcasters and there is no real Sonarr to point at. Covers every failure mode
above plus subpath preservation, trailing-slash handling, header placement, malformed
base URLs failing without leaving the process, and cancellation propagation.
Also points the plan's plaintext-key note at #60, filed to replace plaintext storage with
encryption at rest.
Tests: App 71 / Arch 11 / Infra 102 green, 0 warnings.
Next: PR 3 — the /settings page, which also adopts the GetSettings/SaveSettings handlers
that are registered in DI but called by nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChrisonSimtian
added a commit
that referenced
this pull request
Jul 28, 2026
…masking (#63) Two bugs, and the second was hiding the first. ## 1. No framework JavaScript, so the UI was inert GET /_framework/blazor.web.js -> HTTP 500, 0 bytes GET /app.css (compressed) -> HTTP 200, 0 bytes System.IO.FileNotFoundException: Could not find file '.../src/Presentation/Web/wwwroot/_framework/blazor.web.js' at StaticAssetDevelopmentRuntimeHandler.AttachRuntimePatching No Blazor circuit could start, so every interactive page — Search, Activity, and the new Settings page — rendered its initial HTML and then did nothing. Compressed CSS came back empty too, so the UI was unstyled. Nothing logged an obvious error; it just looked broken. Root cause: the framework calls UseStaticWebAssets() only in Development, and none of our hosts run as Development — there are no launch profiles, and Aspire does not set one either. Without it the static-web-assets manifest is never loaded, so relative asset paths resolve against wwwroot, where framework assets and the content-hashed compressed files do not exist. The manifest itself was correct all along: blazor.web.js pointed at the microsoft.aspnetcore.app.internal.assets package and app.css.gz at obj/<config>/net10.0/compressed/<hash>.gz. Fixed by calling builder.WebHost.UseStaticWebAssets() explicitly. It is a no-op when the manifest is absent, i.e. in a published app where assets are copied into wwwroot, so production behaviour is unchanged. Rejected alternative: adding launchSettings.json with ASPNETCORE_ENVIRONMENT=Development to each host. It fixes the standalone case but is the wrong lever — it would also flip Development on for anything that runs the built binary, and an applicationUrl in those profiles interfered with Aspire's own endpoint allocation. Fixing the asset loading directly is narrower and works regardless of environment. ## 2. IArrClient was never registered anywhere Trying to run in Development to test the theory surfaced this: Unable to resolve service for type 'Krautwatch.Domain.Interfaces.IArrClient' while attempting to activate 'TestArrConnectionHandler' AddApplication() registered TestArrConnectionHandler unconditionally, but no host called AddArrClient(), so IArrClient had no registration at all. Every host would fail to start under DI validate-on-build, and in Production — where validation is off — the Test button would simply have thrown at the moment a user clicked it. Mine, from #61. Fixed by following the pattern this codebase already uses for RunDownloadHandler ("registered by the Downloader host only — it needs IDownloadProvider"): the handler is registered by the Web host, alongside AddArrClient(), because the two cannot be separated. Worth noting why this went unnoticed: running as Production disables DI validate-on-build, so the very bug that made the UI inert also suppressed the startup error that would have pointed at the missing registration. ## Verified on both run paths Standalone (dotnet run --project src/Presentation/Web): blazor.web.js plain 200/200645b gz 200/55644b app.css plain 200/4798b gz 200/4831b Aspire fleet (dotnet run --project src/Presentation/AppHost), on a clean environment: Web host on :51578 blazor.web.js plain 200/200645b gz 200/55644b app.css plain 200/4798b gz 200/4831b Both were 500/0b and 200/0b before. The Web host also now starts under DI validation. Tests: App 88 / Arch 11 / Infra 102 green, 0 warnings. Closes #63. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 2 of 3 for #4. Implements
IArrClientagainst the*arrv3 API (GET /api/v3/system/status,X-Api-Keyheader) plus the Action that drives it from the UI.Failures are classified, not just reported
That's the whole point of this client. "It doesn't work" is the most common self-hosting complaint, and only a specific cause is actionable — a wrong port looks nothing like a wrong API key, and a reverse-proxy subpath omitted from the base URL looks nothing like either.
UnauthorizedApiNotFoundNotAnArrInstanceappName/version, or not JSONTlsFailureAuthenticationExceptionanywhere in the chainUnreachableSocketException, or timeoutUnexpectedThree details worth a look
Base URLs keep their subpath. Instances behind a reverse proxy are commonly served from
https://host/sonarr, andUri's relative resolution drops the last segment unless the base ends in a slash. Without handling that,/sonarr/api/v3/statussilently becomes/api/v3/status— producing a 404 that looks like a wrong path when the config was actually fine. Asserted both ways.The API key goes in the header, not the query string, so it never lands in a proxy access log. There's a test asserting the key does not appear in the URI.
Timeout is 10s, not
HttpClient's 100s default. This sits behind an operator clicking Test, so a hung connection must fail fast rather than leave the button spinning for a minute and a half.The Action
TestArrConnectionHandlercaches the outcome on the instance, so the settings page renders state without re-probing everything on load. It has an overload for unsaved details — so a wrong key never has to be persisted just to discover it's wrong.Caller cancellation propagates rather than being recorded as an instance failure: a cancelled page load is not a broken Sonarr, and caching it as one would be actively misleading.
This is an Action running in a UI host — the deliberate, narrow DR-009 deviation recorded in the plan. The operator clicks a button and expects an answer; round-tripping "test this instance" through the durable bus to an agent and polling for a result is far more machinery than a button warrants.
Testing
Against a stubbed
HttpMessageHandler, not a live test —Live.Testshits real broadcasters and there's no real Sonarr to point at. Covers every failure mode above, plus subpath preservation, trailing-slash handling, header placement, malformed base URLs failing without leaving the process, and cancellation propagation.Tests: App 71 / Arch 11 / Infra 102 green, 0 warnings.
Also
Points the plan's plaintext-key note at #60, filed to replace plaintext
*arrkey storage with encryption at rest. Worth noting the real question there is key management, not the cipher — and that encryption at rest protects database dumps and stolen volumes, not a compromised app host, since the app must be able to decrypt.Next
PR 3 — the
/settingspage: instance CRUD with a per-row Test button, plus the download settings whoseGetSettings/SaveSettingshandlers are registered in DI but called by nothing.Plan:
docs/plans/2026-07-27 - arr-instance-config-ui.md🤖 Generated with Claude Code