Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
da9df5e
docs(issues): record the #2435 option C decision and propagation plan
josecelano Oct 5, 2026
739d919
docs(adrs): keep Result with non-exhaustive errors for possibly falli…
josecelano Oct 5, 2026
48fdc2e
refactor(swarm-coordination-registry): replace the Infallible error a…
josecelano Oct 5, 2026
f7a781f
feat(rest-api): return a 500 when the tracker stats cannot be collected
josecelano Oct 5, 2026
ecb15d4
refactor(tracker-core): propagate swarm registry errors instead of ex…
josecelano Oct 5, 2026
9df52e4
docs(adrs): sharpen the non-exhaustive error ADR around abstraction s…
josecelano Oct 5, 2026
74fb95c
docs(issues): switch #2435 to option B after re-assessing speculative…
josecelano Oct 5, 2026
c6d8cd5
docs(adrs): return Result only for concretely fallible public APIs
josecelano Oct 5, 2026
92e6e0c
revert(tracker-core): stop propagating the uninhabited swarm registry…
josecelano Oct 5, 2026
4af11ef
revert(rest-api): drop the stats 500 path for an error that cannot occur
josecelano Oct 5, 2026
162cd58
refactor(swarm-coordination-registry): return plain values from infal…
josecelano Oct 5, 2026
ecff5d0
docs(issues): add a pre-publish error API checklist to EPIC #1669
josecelano Oct 5, 2026
20573e5
fix(swarm-coordination-registry): mark registry query methods must_use
josecelano Oct 6, 2026
5514a87
docs(issues): address the #2435 task review findings
josecelano Oct 6, 2026
1b49213
docs(issues): add the #2435 implementation retrospective
josecelano Oct 6, 2026
3b6c630
docs(issues): cite #2435 branch commits by subject instead of id
josecelano Oct 6, 2026
03f6515
docs(adrs): scope the no-expect rule to workspace APIs that cannot fail
josecelano Oct 6, 2026
915cdd2
docs(adrs): define ports and require a real or planned failing backend
josecelano Oct 6, 2026
3c07f57
docs(tracker-core): link the registry and in-memory repository to the…
josecelano Oct 6, 2026
27d84f8
docs(tracker-core): remove false lock-panic docs from torrent services
josecelano Oct 6, 2026
549cf0c
docs(issues): move the EPIC #1669 pre-publish checklist to Delivery S…
josecelano Oct 6, 2026
571b816
docs(issues): record the #2435 task review and fix spec review findings
josecelano Oct 6, 2026
1ea8054
docs(pr-reviews): audit the review of #2445
josecelano Oct 6, 2026
0be2875
docs(issues): restore the EPIC #1669 log lines lost in the #2445 rebase
josecelano Oct 6, 2026
0317ff4
docs(issues): list all five files in the #2435 net code diff
josecelano Oct 6, 2026
ee35bbf
docs(pr-reviews): scope the F10 verification grep in the #2445 audit
josecelano Oct 6, 2026
657e7b5
docs(pr-reviews): audit round 2 of the #2445 review
josecelano Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ fn it_should_parse_valid_config() {
}
```

## When Public APIs Return `Result`

Return `Result` only when an operation can fail today, crosses an I/O boundary, or is a trait or port
with an existing or planned backend that can fail. Otherwise return a plain value; a real failure later is a
semver-signalled breaking change. Never use `Infallible` or an empty error enum as a placeholder, and mark
existing public error enums `#[non_exhaustive]` before first publish. See [ADR 20261005145329](../../../../../docs/adrs/20261005145329_return_result_only_for_concretely_fallible_public_apis.md).

## Quick Checklist

- [ ] Error type uses `thiserror::Error` derive
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
semantic-links:
skill-links:
- create-adr
related-artifacts:
- .github/skills/dev/planning/create-adr/SKILL.md
- .github/skills/dev/rust-code-quality/handle-errors-in-code/SKILL.md
- "issue #2435"
- docs/issues/open/2435-remove-misleading-panics-in-in-memory-torrent-repository/ISSUE.md
- packages/swarm-coordination-registry/src/swarm/registry.rs
- packages/tracker-core/src/torrent/repository/in_memory.rs
---

<!-- skill-link: create-adr -->

# Return `Result` Only for Concretely Fallible Public APIs

## Scope

This is a repository-level decision. It sets an API convention for every workspace package,
because every package will be published on crates.io and consumed independently of the tracker
(EPIC #1669, [independent package versioning](20260629000000_adopt_independent_package_versioning.md)).
The first application is the boundary between `swarm-coordination-registry` and `tracker-core`.

## Description

The swarm coordination `Registry` returned `Result<_, Error>` from ten methods with
`pub type Error = Infallible;`. No method could fail. Its main consumer,
`InMemoryTorrentRepository`, called `.expect(...)` on every result and documented a `# Panics`
section for a panic that could not happen. Reviewers read that as a reliability problem, and every
new method copied the pattern.

The `Result` had been kept on purpose, to absorb future failures without a breaking change. The
first implementation of issue #2435 followed that intent through: it replaced `Infallible` with a
crate-owned, empty `#[non_exhaustive]` error enum and propagated it to every delivery layer. That
work showed the cost of the approach:

- `SwarmRegistry` variants in `AnnounceError` and `ScrapeError`, a REST `StatsError` with a `500`
path, error handling in the torrent cleanup job, and 37 new `.unwrap()` calls in tests;
- all of it for an error that cannot occur, so none of those paths could be tested;
- and readers once again inferred a failure mode that does not exist, which was the original
problem.

The deciding question was simple: can counting the swarms (`Registry::len`) ever fail? Nobody could
name a case. The counting methods that returned `Result` had an equally weak case: a different
backend would affect `len()` as much as `count_peers()`, and a backend that different would most
likely be a new abstraction with its own error type anyway.

## Agreement

A public API returns `Result` when at least one of these holds:

1. **A failure can happen today**: I/O, parsing, validation, resource limits.
2. **The operation crosses an I/O boundary**: database, network, filesystem.
3. **It is a trait or port with an existing or planned backend that can fail.** The database
driver traits are an example. A "port" is a trait that defines a boundary other
implementations can back, such as the REST application port traits in the
[contract-first REST API architecture](20260623200526_adopt_contract-first_architecture_for_rest_api.md).
Being swappable is not enough: some backend that is real or planned must be able to fail. For
example, the auth-key and whitelist ports return `Result` because their implementation does
database I/O (conditions 1 and 2). The stats and torrent query ports return plain values,
because no backend that can fail exists or is planned; they change to `Result` when one is.

Otherwise, it returns a plain value. If a real failure appears later, the signature changes to
`Result` as a semver-signalled breaking change: a `0.x` minor bump or a major bump after 1.0. The
compiler then lists every caller that must handle the error.

Related rules:

- **Never use `Result<T, Infallible>` or an empty error enum to reserve room for future failures.**
`Infallible` means "the error type for errors that can never happen". If an operation cannot
fail, it returns `T`.
- **Do not `expect` a workspace API's `Result` whose error cannot occur.** Change that API to
return a plain value instead, so the signature states the truth and no false `# Panics` section
is needed. A call-site `expect` on an API that can fail in general but not for this input, often
one we do not own (for example serializing plain data with `serde_json`), stays under the
`handle-errors-in-code` skill's Unwrap and Expect Policy, with the `# Panics` section that
`clippy::missing_panics_doc` requires.
- **Mark existing public error enums `#[non_exhaustive]` before their first publish.** Enums with
real variants gain variants as features grow (the reverted first attempt at #2435 had to add one
to two enums). With
`#[non_exhaustive]`, adding a variant is not a breaking change. This is the cheap, standard tool;
RFC 2008 names error types as its most common use. Derive only traits every future variant can
keep, because removing a derive is a breaking change.

### Why Breaking Changes Are Acceptable

The two options spread their costs differently:

- A speculative `Result` costs every consumer, from the first release and forever: propagation code
and untestable error paths for an error that does not exist.
- A breaking change costs a one-time migration, only if the failure ever appears, and only for
callers of the changed method. Cargo treats `0.1` to `0.2` (or `1.x` to `2.0`) as incompatible,
so consumers adopt the change deliberately rather than being broken silently.

Before a crate reaches 1.0, its public API should be reviewed against this rule method by method.

### Alternatives Considered

- **Keep `Result<T, Infallible>`.** Rejected: it states that failure is impossible while keeping a
`Result`, so consumers `expect` it, and replacing `Infallible` later changes the public API
anyway.
- **An empty `#[non_exhaustive]` error enum with full propagation.** Implemented first in #2435,
then reverted. Consumers cannot treat the type as uninhabited, so later variants are
non-breaking, but every consumer pays for the plumbing up front for a failure that may never
exist. See the Description.
- **A generic error type** such as `Box<dyn std::error::Error + Send + Sync>`. Rejected: it carries
the same speculative cost and also hides which failures are possible.

### Consequences

Positive:

- Signatures state the truth: callers see `Result` only where something can fail.
- No error plumbing, `expect` calls, or `# Panics` sections exist for impossible failures.
- Error paths that do exist correspond to real failures and can be tested.

Negative:

- Introducing the first failure into an operation is a breaking change for its callers.
- The rule needs judgement. "Can this fail today, does it do I/O, or does a real or planned backend
fail?" is
usually clear, but borderline cases should be decided in review.

## Affected Code

- [`packages/swarm-coordination-registry/src/swarm/registry.rs`](../../packages/swarm-coordination-registry/src/swarm/registry.rs)
Comment thread
da2ce7 marked this conversation as resolved.
- [`packages/tracker-core/src/torrent/repository/in_memory.rs`](../../packages/tracker-core/src/torrent/repository/in_memory.rs)

## Date

2026-10-05

## References

- Issue #2435 and its [specification](../issues/open/2435-remove-misleading-panics-in-in-memory-torrent-repository/ISSUE.md)
- Found during PR #2423 (issue #2406) review
- EPIC #1669 (package overhaul and publishing)
- [ADR: adopt independent package versioning](20260629000000_adopt_independent_package_versioning.md)
- [RFC 2008: `#[non_exhaustive]`](https://rust-lang.github.io/rfcs/2008-non-exhaustive.html)
- [`std::convert::Infallible`](https://doc.rust-lang.org/std/convert/enum.Infallible.html)
- [Cargo Book: SemVer compatibility](https://doc.rust-lang.org/cargo/reference/semver.html)
1 change: 1 addition & 0 deletions docs/adrs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ supersession rules.
| [20260926142648](20260926142648_adopt_self_hosted_hetzner_runner_for_container_tests.md) | 2026-09-26 | Adopt a self-hosted Hetzner runner for the container test job | Run the container test and Docker E2E jobs on one persistent Hetzner runner, accepting the fork-PR exposure under approval, 2FA, Dependabot and `main` routing, and cache-free publish builds. |
| [20260929183441](20260929183441_build_container_from_positive_lists_with_external_only_dependency_cache.md) | 2026-09-29 | Build the container from positive lists with an external-only dependency cache | Use Cargo Chef's canonical `COPY . .` recipe stage (measured to cache identically to hand-maintained manifest/stub lists), keep the `--external-only` three-layer cook, make `.dockerignore` a default-deny allow-list, and select container test scope with `[workspace] default-members` instead of `--exclude` lists. |
| [20261002173716](20261002173716_load_persisted_scrape_downloads_with_a_batched_uncached_lookup.md) | 2026-10-02 | Load persisted scrape downloads with a batched, uncached lookup | Announce and scrape share one persisted-downloads lookup; scrape loads all torrents absent from memory with one deduplicated `IN (...)` query, bounded at 100 info-hashes independently of protocol request limits, with no cache until metrics show a need. |
| [20261005145329](20261005145329_return_result_only_for_concretely_fallible_public_apis.md) | 2026-10-05 | Return `Result` only for concretely fallible public APIs | A public API returns `Result` only when it can fail today, crosses an I/O boundary, or is a trait or port with an existing or planned backend that can fail; otherwise it returns a plain value and a real failure later is a semver-signalled breaking change. No `Infallible` or empty error enums as placeholders; existing public error enums get `#[non_exhaustive]` before first publish. |

## ADR Lifecycle

Expand Down
18 changes: 17 additions & 1 deletion docs/issues/open/1669-overhaul-packages/EPIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ epic: null
github-issue: 1669
spec-path: docs/issues/open/1669-overhaul-packages/EPIC.md
epic-owner: josecelano
last-updated-utc: "2026-10-06 09:12"
last-updated-utc: "2026-10-06 10:40"
semantic-links:
skill-links:
- create-issue
Expand All @@ -18,6 +18,7 @@ semantic-links:
- docs/issues/closed/1926-1669-si-32-define-package-versioning-strategy/ISSUE.md
- docs/adrs/20260527175600_keep_protocol_and_domain_types_decoupled.md
- docs/adrs/20260629000000_adopt_independent_package_versioning.md
- docs/adrs/20261005145329_return_result_only_for_concretely_fallible_public_apis.md
- docs/adrs/index.md
- docs/issues/open/1669-overhaul-packages/DECISIONS.md
- AGENTS.md
Expand Down Expand Up @@ -780,6 +781,17 @@ Each subsequent cycle produces one or more of:

There is no predetermined end date or total subissue count.

### Pre-publish API checklist

Before the first crates.io publish of each package, audit its public error API
([ADR 20261005145329](../../../adrs/20261005145329_return_result_only_for_concretely_fallible_public_apis.md)):

1. Mark every public error enum that has real variants `#[non_exhaustive]`, so that adding a
variant later is not a breaking change.
2. Derive only the traits every future variant can keep; removing a derive is a breaking change.
3. Confirm that no public API returns `Result<_, Infallible>` or an empty error enum as a
placeholder for future failures.

## Open Questions

These questions do not block starting work, but need answers before specific subissues can
Expand Down Expand Up @@ -932,12 +944,16 @@ Previously referenced tools (screenshots from CodeScene already in the issue com
#1860, #1861, #1864, SI-23 to SI-28, SI-34 and SI-35 as done; replaced `rest-api-core` with
the #1938 REST API packages; regenerated the dependency lists from `cargo metadata`; recorded
that the baseline analysis is largely done but not yet tracked in a GitHub issue.
- 2026-10-05 21:07 UTC - Copilot - Added the Pre-publish API checklist (`#[non_exhaustive]`
Comment thread
josecelano marked this conversation as resolved.
audit of public error enums) from issue #2435 and its ADR, with maintainer approval.
- 2026-10-06 08:41 UTC - GitHub Copilot - Applied PR #2441 review feedback: added Details rows
for the newly listed subissues and SI-29; replaced the publication Yes/No column with the
latest crates.io version and added the published root `torrust-tracker` crate; marked
`bittorrent-primitives` as superseded and archived.
- 2026-10-06 09:12 UTC - GitHub Copilot - Stated that the published count excludes
`bittorrent-primitives`, and marked the repository as archived in the desired-state notes.
- 2026-10-06 10:40 UTC - Copilot - Moved the Pre-publish API checklist from Open Questions to
Delivery Strategy, because it is a decided procedure (PR #2445 review finding F7).

## Acceptance Criteria

Expand Down
Loading
Loading