Skip to content

fix(qbittorrent): tell a refused release apart from a failed submission - #906

Open
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/qbittorrent-409-rejected-release
Open

fix(qbittorrent): tell a refused release apart from a failed submission#906
m4bard wants to merge 1 commit into
Listenarrs:canaryfrom
m4bard:fix/qbittorrent-409-rejected-release

Conversation

@m4bard

@m4bard m4bard commented Aug 28, 2026

Copy link
Copy Markdown

This does not close #881. That issue asks three design questions I cannot answer for you, and this is the part of it that does not depend on any of them.

What happens today

qBittorrent 5.2 answers HTTP 409 when the info-hash being submitted is already in its download list. That happens whenever one release satisfies more than one wanted book: the release is grabbed for the first book, and the search for the second scores the same release top and submits it again.

QbittorrentAddWorkflow raised DownloadClientSubmissionException for every non-success status, so that was indistinguishable from a broken client. The two entry points then diverge, which I had wrong in the issue until I ran it:

  • on the automatic search path the exception is caught and the cycle carries on, logging an error
  • on POST /api/v1/download/search-and-download it reaches the controller's catch-all and comes back as a 500, so pressing search on such a book gives "Failed to search and download" and no way to tell that the release is already downloading

I reproduced it on stock ghcr.io/listenarrs/listenarr:canary reporting 1.3.4, with a stub indexer serving one series-wide release and a stub client answering 409 on a repeat submission. Three cycles, thirteen seconds apart, same info-hash each time. Logs are on #881, and the two stubs are public if you want to run it yourself.

What this changes

DownloadClientRejectedReleaseException narrows the existing submission exception rather than sitting beside it, so every current handler keeps its behaviour and only callers that want to skip need to know the type exists. Sonarr's DownloadClientRejectedReleaseException derives from ReleaseDownloadException the same way.

The workflow raises it only on Conflict. Every other status is still a plain submission failure, and there is a test on 500 asserting exactly that, so the branch cannot be widened without something going red.

The controller answers a rejection the way the service already answers "no suitable download client found": Success = false with the reason, rather than a server error.

Sonarr, for whatever it is worth

Checked against v5-develop rather than from memory. DownloadClientRejectedReleaseException is thrown from Clients/QBittorrent/QBittorrent.cs:95 and :171 on HttpStatusCode.Conflict, from Clients/Sabnzbd/Sabnzbd.cs:49 when the response carries no ids, and from Clients/Nzbget/Nzbget.cs:48 on a null response. ProcessDownloadDecisions.ProcessDecisionInternal catches it at :213-224 and returns Skipped, and the switch arm at :112-115 is a bare break, so nothing is recorded.

I should be precise about how deliberate that is: the exception is not named in ProcessDownloadDecisions, it lands in the catch-all alongside anything that is not a client-unavailable or authentication error. The outcome is what I am describing rather than a case written for it.

Three different 409s, and this is one of them

Worth saying plainly, because I conflated two of them myself when I filed #881.

What this deliberately does not do

The search path learns nothing from the rejection, so the next cycle selects the same release and is refused again. Stopping the repetition needs a record of which wanted items a release satisfies, which is question 1 on #881 and is yours to decide. This makes the refusal honest rather than silent.

It also interacts with #882, which asks whether a rejected submission should write a DownloadFailed history row. If you take both, a 409 probably should not write one, and #882's body says so.

Tests

The 409 case, plus a control on 500 asserting it is still a plain submission failure and not a rejection. Confirmed the new test fails with the production change reverted. Full unfiltered suite: 3120 passed, 0 failed, 130 skipped.

One structural note: the catch went in the controller rather than DownloadService.SearchAndDownloadAsync. Putting it in the service pushed that file to 522 lines against the 500-line budget, and extracting from it would have collided with #882, which already restructures the same file.

qBittorrent 5.2 answers HTTP 409 when the info-hash being submitted is
already in its download list. That happens whenever one release satisfies
more than one wanted book: the release is grabbed for the first book, and
the search for the second scores the same release top and submits it again.

QbittorrentAddWorkflow raised DownloadClientSubmissionException for every
non-success status, so this was indistinguishable from a broken client. On
the automatic search path the cycle logged an error and carried on. On
POST /api/v1/download/search-and-download the exception reached the
controller's catch-all and came back as a 500, so a user who pressed search
on such a book got "Failed to search and download" and no way to tell that
the release was already downloading.

Reproduced on stock canary 1.3.4 with a stub indexer serving one
series-wide release and a stub client answering 409 on a repeat submission.
Three cycles, thirteen seconds apart, same info-hash each time. Written up
with logs on Listenarrs#881.

DownloadClientRejectedReleaseException narrows the existing submission
exception rather than sitting beside it, so every current handler keeps its
behaviour and only callers that want to skip need to know the type exists.
Sonarr's DownloadClientRejectedReleaseException derives from
ReleaseDownloadException the same way, and is thrown from QBittorrent.cs on
HttpStatusCode.Conflict, from Sabnzbd.cs when the response carries no ids
and from Nzbget.cs on a null response.

The workflow raises it only on Conflict; every other status is still a plain
submission failure. The controller answers a rejection the way the service
already answers "no suitable download client found", with Success = false
and the reason, rather than a server error.

Deliberately not included: the search path does not learn anything from the
rejection, so the next cycle will select the same release and be refused
again. Stopping the repetition needs a record of which wanted items a
release satisfies, which is the data model question on Listenarrs#881 and is not
mine to answer. This makes the refusal honest, not silent.

Tests: the 409 case, plus a control on 500 asserting it is still a plain
submission failure and not a rejection, so the branch cannot be widened
without a test going red. Confirmed the new test fails with the production
change reverted. Full suite: 3120 passed, 0 failed, 130 skipped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qBittorrent 5.2 returns 409 when it already holds a release, and Listenarr treats it as a hard submission failure

1 participant