Skip to content

Backport upstream's fix for Decypharr treating a provider 400 as permanent - #72

Merged
leonardoazeredo merged 5 commits into
mainfrom
fix/decypharr-400-is-retryable
Sep 13, 2026
Merged

leonardoazeredo merged 5 commits into
mainfrom
fix/decypharr-400-is-retryable

Conversation

@leonardoazeredo

@leonardoazeredo leonardoazeredo commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Decypharr v2.5 maps an unrecognised provider error code to a permanent error
(pkg/manager/link/errors.go, the ErrorCodeToLinkError default branch). TorBox
answers a link-validation request with a bare HTTP 400, so a transient upstream
hiccup became permanent in two ways:

  • resolveLinkWithRetry returns at the first attempt on a permanent error, so its
    four tries and exponential backoff were never used. The log shows no
    link fetch failed, retrying line at all, which is the tell.
  • fetchAndValidate memoises the failure against the link URL, and that URL is
    derived from torrent_id/file_id, so the same request rebuilt the same URL.

Measured on this NAS: 70 of those errors in 72 hours, 41 of them inside the
01:00–03:00 window of the TorBox rate-limit incident. The titles they touched sat
at 0% until queue-cleanup removed them, and the arr could not replace them
either, because an item at the cutoff makes it reject every alternative.

Upstream fixed this in PR #402,
which is open and unmerged. decypharr/ builds the pinned upstream commit
plus that change.

What is in here

File Role
decypharr/apply-patch.py rewrites errors.go by exact string replacement; refuses on anchor drift, a double-apply, or an unreadable file
decypharr/patch_test.go the Go assertion the image build runs
decypharr/verify-patch.sh runs that assertion and refuses to build if it cannot
decypharr/Dockerfile upstream's own recipe, so the runtime image is v2.5 plus one behaviour change
.github/workflows/decypharr-image.yml builds and publishes it
docker-compose.arr-stack.yml points decypharr at the published tag
tests/decypharr-patch.bats 13 tests over the above

The source is fetched by commit (0dd1cbb), not by tag, so the patch anchors
cannot move under it.

The gate is a real one

go test -run <pattern> exits 0 reporting "no tests to run" when nothing
matches, so a gate built only on that would go silent — and still pass — the
moment the test file stopped being copied in. verify-patch.sh asks for the test
names with -list first and refuses to continue without them.

All three of its paths verified, not asserted:

  • patch applied, tests present → go test passes
  • patch missing → the 400 test fails, exit 1
  • test file absent → "not in this tree; refusing to build", exit 1

A build or a gate that cannot fail is the failure mode this repo has a mutation
framework for, so both halves are demonstrated rather than described.

Verified on the NAS before merging

  • image builds for aarch64 (124 MB, same size as upstream)
  • the patch string is present in the binary: 1 occurrence, against 0 in
    ghcr.io/sirrobot01/decypharr:v2.5
  • pulled from GHCR and recreated: healthy, version banner v2.5-patch1-patched,
    existing config volume intact
  • corpus on the NAS: compose-architecture.sh 3/3 KILLED; no-sweep-list.sh
    2 KILLED, 3 SKIPPED for want of a host git (the runner refuses to judge rather
    than scoring them)

Three defects found in this change's own tooling, and fixed here

  1. $$ is not unescaped by this Docker builder. The first published attempt
    died with /bin/sh: syntax error: unexpected "(". Reproduced in a two-line
    Dockerfile, then the check moved into a real script.
  2. The registry check raced the publish workflow that this same push starts,
    reporting the tag missing while it was still uploading — a red required check
    against a green build. Images this repo publishes are now skipped there, and
    tests/decypharr-patch.bats asserts the tag compose consumes is the tag
    something publishes, where it cannot race.
  3. The no-sweep guard failed, rather than skipped, without a host git. The NAS
    has no git binary. Found by running the corpus there: the entry came back
    ERRORED with "the test is already failing unmutated" instead of KILLED.

Not in scope

The other two findings from the same diagnosis are untouched and still open:
the usenet path (decisive test: one failing Usenet-Crawler NZB submitted straight
to TorBox's usenet zone) and the download-client priority mismatch (SABnzbd
recentTvPriority/olderTvPriority = −100 against Decypharr's 0 on both arrs).

The 400 itself is still a real upstream answer and will still appear in the logs.
What changes is that it now costs a retry rather than the file.

Decypharr v2.5 maps an unrecognised provider error code to a PERMANENT error
(pkg/manager/link/errors.go, the ErrorCodeToLinkError default branch). TorBox
answers a link-validation request with a bare HTTP 400, so a transient
upstream hiccup became permanent in two ways:

  - resolveLinkWithRetry returns at the first attempt on a permanent error, so
    its four tries and exponential backoff were never used. The logs show no
    "link fetch failed, retrying" line at all, which is the tell.
  - fetchAndValidate memoises the failure against the link URL, and that URL is
    derived from torrent_id/file_id, so the same request rebuilt the same URL.

Measured on this NAS: 70 of those errors in 72 hours, 41 of them inside the
01:00-03:00 window of the TorBox rate-limit incident. The titles they touched
sat at 0% until queue-cleanup removed them, and the arr could not replace them
either, because an item at the cutoff makes it reject every alternative.

Upstream fixed this in PR #402, which is open and unmerged. decypharr/ builds
the pinned upstream commit plus that change:

  - apply-patch.py rewrites the file by exact string replacement and fails
    loudly when an anchor has drifted or the file is already patched, so the
    build cannot silently ship an unpatched binary. It is applied by commit
    (0dd1cbb), not by tag, so the anchors cannot move under it.
  - patch_test.go runs inside the image build and asserts that a 400 is
    retryable and that sibling codes are unchanged. That test fails against
    unpatched v2.5, so it is a real gate rather than a passing formality. It
    also asks for the test names first: "go test -run <pattern>" exits 0 with
    "no tests to run" when nothing matches, which would have made the gate go
    quiet and still pass.
  - The runtime image is upstream's own recipe, so it is v2.5 plus one
    behaviour change -- same size, same entrypoint, same healthcheck.

The image is built and published by .github/workflows/decypharr-image.yml, so
the NAS pulls a real tag instead of building anything.

Verified on the NAS before this commit: image built for aarch64, the patch
string is present in the binary (0 occurrences in the upstream image), the
container recreated healthy on the existing config volume, and the version
banner reports v2.5-patch1-patched.

Also documents the distinction this turned on: the 403 rate limit and this 400
are separate events an hour apart, and it is the 400 that wedges the queue.
The first published attempt failed at the build gate with
`/bin/sh: syntax error: unexpected "("`. Docker is documented as unescaping
`$$` to a literal `$` in RUN, and it does not do so in this build: the shell
received `$$(...)` verbatim, which is not command substitution.

Reproduced in isolation with a two-line Dockerfile, so this is the builder's
behaviour rather than something about the surrounding stage.

The check now lives in decypharr/verify-patch.sh, which is plain shell that
shellcheck and sh can both read, instead of an inline RUN fighting the quoting.
Its three paths are each verified:

  - patch applied, tests present   -> go test passes
  - patch missing                  -> the 400 test fails, exit 1
  - test file absent               -> "not in this tree; refusing to build", exit 1

The image builds with it on the NAS for aarch64.
`all image tags exist on their registry` and the Decypharr image workflow run
on the same push. The check asked ghcr whether the tag exists while the build
was still uploading it, so it reported the tag missing and turned a green
publish into a red required check -- observed on the first two runs of this
branch, with the build succeeding minutes later.

Images this repository publishes are now skipped there. That check was never
the right question for them: the tag's existence is decided by the workflow
that builds it, and what actually matters -- that the tag compose consumes is
the tag something publishes -- is asserted in tests/decypharr-patch.bats,
where it cannot race.

Coincidental evidence the skip is narrow: the same job still probes the other
five ghcr.io images in the compose files and passes.
tests/shellcheck.bats derives that list from TARGETS and fails in both
directions, so a new production shell file is a red suite until it is either
swept or recorded as unswept. verify-patch.sh gets the second treatment: it is
a build-time guard whose oracle is a Go compiler, so a generated sweep would
report survivors that say nothing. Its behaviour is asserted in
tests/decypharr-patch.bats instead, and the README says which of those is which
rather than leaving the next reader to wonder why the one entry with an oracle
is sitting in the list of files without one.
`shell_files` enumerates with `git ls-files`, and the NAS has no git binary --
it drives this repo through a containerised alpine/git. The test that derives
the no-sweep list had no skip for that, so it failed with "derived no-sweep
list is empty; the discovery is broken", which reads as a broken repository
rather than a missing tool.

Found by running the corpus on the NAS, which is the only host that can run it
with GNU sed: `no-sweep-list-blind-to-a-new-target` came back ERRORED with
"the test is already failing unmutated - a later failure would prove nothing"
instead of KILLED. The corpus was right to refuse it. My earlier claim that the
remaining errors there were "environmental" was only half true: the
environmental part was that git is absent, and the defect was that the guard
turned that into a red suite. The other test that enumerates with git
ls-files already had exactly this skip and says so in its own comment.

No new corpus entry: this change adds no new guard, and the entries that guard
tests/shellcheck.bats already exist.
@leonardoazeredo leonardoazeredo changed the title fix/decypharr 400 is retryable Backport upstream's fix for Decypharr treating a provider 400 as permanent Sep 13, 2026
@leonardoazeredo
leonardoazeredo merged commit b0070aa into main Sep 13, 2026
11 checks passed
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.

1 participant