Skip to content

feat(dht): add bootstrap and iterative peer discovery#69

Merged
joamag merged 18 commits into
masterfrom
feat/better-dht-client
Jun 21, 2026
Merged

feat(dht): add bootstrap and iterative peer discovery#69
joamag merged 18 commits into
masterfrom
feat/better-dht-client

Conversation

@joamag

@joamag joamag commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements DHT bootstrap and proper peer discovery/mapping for the DHT client, addressing the lack of a routing table and iterative lookups on top of the existing KRPC query primitives.

Closes #9.

Changes

  • DHTNode — a routing-table entry (id + contact) with compact-info unpack and XOR distance.
  • DHTRoutingTable — a k-bucket table keyed by XOR distance (add with dedupe/bucket-full guard, closest distance-ordered).
  • DHTClient.routing / bootstrap / lookup — lazy routing table, bootstrap from well-known nodes, and iterative find_node/get_peers lookups built on the existing query. The original ping/find_node/get_peers/query/on_data* primitives are preserved.
  • DHTResponse.get_nodes — parses the compact nodes field of a response.
  • Bug fixes surfaced while testing live: on_data_dht no longer crashes on a response with no matching pending request (guards None, mirroring the DNS client); lookup/bootstrap now actually invoke the user-supplied callback (it was previously accepted but ignored).
  • __main__ — a command line example that runs a get_peers lookup around an info hash (configurable via DHT_INFO_HASH) and prints discovered nodes/peers.
  • Unit tests covering the new structures and client methods, plus a CHANGELOG entry.

Verification

  • Unit tests: 14 passing; black clean.
  • Live: bootstraps and runs iterative lookups against the public DHT, fanning out to hundreds of unique nodes.
  • The lookup returns no peers (values) from the test environment; this was independently confirmed with aria2c (a mature BitTorrent client), which also gets zero peers for the same hash here — only one DHT router is reachable from this network, so it is an environment limitation, not a code defect.

🤖 Generated with Claude Code

- add DHTNode and DHTRoutingTable (k-bucket, XOR distance) structures
- add bootstrap and iterative find_node/get_peers lookups on DHTClient
- wire user callback through lookup and guard unmatched responses
- add __main__ example and unit tests for the new DHT support

Closes #9
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@joamag, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 14 minutes and 48 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1ad2eeaf-c251-48ef-a0a2-8846b820b6a0

📥 Commits

Reviewing files that changed from the base of the PR and between 06e0c93 and f3f1597.

📒 Files selected for processing (1)
  • src/netius/servers/torrent.py
📝 Walkthrough

Walkthrough

Adds a routing-table-backed iterative DHT client with DHTNode, DHTRoutingTable, bootstrap(), and lookup() methods. Implements BEP 9/10 metadata exchange on TorrentConnection to fetch torrent info by info hash. Updates TorrentTask with metadata-gated piece loading, reworked DHT peer discovery, ETA/speed reporting, and a configurable max-peers cap. Adds unit tests across all new behaviors.

Changes

DHT Bootstrapping and BEP9 Metadata Exchange

Layer / File(s) Summary
DHT constants, data structures, and request/response fixes
src/netius/clients/dht.py, src/netius/clients/__init__.py
Expands DHT module constants (bucket sizes, bootstrap node list), fixes DHTRequest._get_peer_id to return only the 20-byte id, repairs DHTResponse transaction-id parsing and is_error/is_response logic, and introduces DHTNode (unpack, XOR distance, validity) and DHTRoutingTable (k-bucket add and closest selection). Exports DHTNode and DHTRoutingTable from the clients package.
DHT iterative lookup, bootstrap, and datagram hardening
src/netius/clients/dht.py
Adds DHTClient.routing() for routing-table caching, bootstrap() to seed via find_node, and lookup() for iterative get_peers/find_node with routing-table population, peer aggregation, and query deduplication. Hardens on_data() to drop malformed datagrams; rewrites on_data_dht() to match responses to pending requests. Adds _bootstrap_nodes() for hostname resolution and a __main__ demo loop.
BEP9/10 extension protocol and TorrentConnection metadata exchange
src/netius/clients/torrent.py, src/netius/common/torrent.py
Registers message type 20 as "extended" in TORRENT_TYPES. Introduces EXTENDED_RESERVED, METADATA_BLOCK_SIZE, EXTENDED_TYPES, and metadata message type constants. Extends TorrentConnection state and handshake to advertise extension support, adds extended_t(), on_extended_handshake(), on_metadata(), set_metadata(), request_metadata(), extended_handshake(), and extended() methods, and implements _metadata_pieces().
TorrentTask metadata-gated loading, DHT peer discovery, status, and server config
src/netius/servers/torrent.py
load() defers piece/file loading until has_metadata() is true. Rewrites on_dht() to unpack compact IPv4+port peer values with validation. Replaces per-peer get_peers with a single dht_client.lookup(..., type="get_peers"). Adds has_metadata() and set_metadata() with SHA1 validation and "metadata" event. Guards set_data() before stored is initialized. Adds speed() zero-delta guard, eta()/eta_s() helpers, expanded info_string(), add_peer() max cap, TorrentServer.__init__ max_peers parameter, and TORRENT_MAX_PEERS env override.
Unit tests for DHT client, TorrentConnection, and TorrentTask
src/netius/test/clients/dht.py, src/netius/test/clients/torrent.py, src/netius/test/servers/torrent.py, src/netius/test/base/mixin.py, src/netius/test/extra/proxy_f.py, src/netius/test/servers/proxy.py, CHANGELOG.md
New test modules cover DHTRequest/DHTResponse/DHTNode/DHTRoutingTable/DHTClient behaviors; TorrentConnectionTest validates extended handshake, metadata piece assembly, and reject handling; TorrentTaskTest covers DHT response handling, metadata set/validate, set_data guard, ETA computation, and peer cap. Includes mock helpers. Existing test files normalized for consistent line endings. Changelog entries document new features and fixes.

Poem

🐇 Hoppity-hop through the DHT sea,
Routing tables fill up bucket by bucket with glee!
Extended handshakes whisper "I know BEP ten,"
Metadata pieces arrive, assembled, and then—
ETA ticking, max peers in a line,
The rabbit downloads torrents, everything's fine! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding DHT bootstrap and iterative peer discovery functionality, which aligns with the primary objective of this PR.
Description check ✅ Passed The PR description comprehensively details the implementation of DHT bootstrap and peer discovery, explaining the additions, bug fixes, and verification steps related to the changeset.
Linked Issues check ✅ Passed The code changes fully implement all objectives from issue #9: routing table with k-buckets (DHTNode, DHTRoutingTable), bootstrap functionality (DHTClient.bootstrap), iterative lookups (DHTClient.lookup for find_node/get_peers), response parsing (DHTResponse.get_nodes), and proper callback invocation.
Out of Scope Changes check ✅ Passed Beyond the core DHT enhancements, the PR extends torrent client metadata exchange (BEP9/10), torrent server improvements (status/ETA reporting, max_peers), and adds comprehensive unit tests—all supporting and enabling the DHT functionality for practical peer discovery, thus remaining scoped to the PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

joamag added 14 commits June 21, 2026 10:00
- query closest unqueried nodes from the routing table on each response
- dedup queried nodes by (host, port) instead of host only
- avoids a single unresponsive node dead-ending the iterative lookup
- repeat get_peers lookup rounds on a configurable interval (DHT_INTERVAL)
- stop the loop once at least one peer has been discovered
- reuse the persistent routing table to resume from the closest nodes
- send a valid 20 byte node id (drop the appended contact bytes)
- query every node returned by a response, not only the closest 8
- ignore malformed datagrams and non numeric transaction ids
- skip unroutable nodes (zero port, unspecified host)
- include the responding node contact in the lookup debug line
- add metadata extension support (BEP 9/10) to fetch the info dict from peers
- defer piece and file loading until the metadata is available
- drive DHT peer discovery from the get_peers lookup and parse peer values
- guard block requesting until the metadata has been received
- add a DHT based download example to the torrent client
- lookup now seeds from the well known bootstrap nodes when the routing
  table is empty and no nodes are provided (fixes DHT peer requests that
  were issued with an empty contact set, eg: torrent get_peers lookups)
- fix invalid percent format string in the torrent task status output
- include the file name, info hash, total size and piece size in the
  info string, with safe defaults while the metadata is not yet available
- skip set_data when the pieces structure is not loaded (avoids a crash
  on a data block received before the metadata exchange or after unload)
- list the actual file names in the task status output (the name field
  is the directory name for multiple file torrents)
- print the task status at most once every print interval (3 seconds)
- include the (resolved) download path in the completion message
- read the download target path from the TORRENT_TARGET_PATH config
  value (defaulting to the downloads directory)
- catch index and struct errors when parsing malformed DHT datagrams
- ignore DHT peer responses received after the task has been unloaded
  (eg: once the download has completed) to avoid a None owner access
- close the (threaded) DHT client during server cleanup so its loop
  thread ends, otherwise the process hangs after the download completes
- add the DHT client as a regular (non threaded) container base instead
  of a separate thread, the shared event loop handles the datagram
  traffic together with the peer connections without issues
- drop the explicit DHT client close on cleanup and the thread safe peer
  connection delay as they are no longer required without a separate thread
- add a configurable maximum number of peers per task (TORRENT_MAX_PEERS)
- show the estimated time of arrival in the task status output
- skip malformed (non compact) DHT peer values instead of raising
- guard the speed computation against a zero elapsed time
@joamag joamag marked this pull request as ready for review June 21, 2026 19:28
Copilot AI review requested due to automatic review settings June 21, 2026 19:28
@joamag joamag self-assigned this Jun 21, 2026
@joamag joamag added enhancement New feature or request p-medium Medium priority issue labels Jun 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds higher-level BitTorrent/DHT functionality to Netius by implementing DHT bootstrapping + iterative lookups and enabling torrent downloads from an info-hash via metadata exchange (BEP 9/10), with accompanying tests and changelog updates.

Changes:

  • Implement DHT routing-table primitives (DHTNode, DHTRoutingTable) and iterative bootstrap()/lookup() on DHTClient, plus response node parsing.
  • Add torrent extension-protocol support to negotiate and fetch metadata, defer piece/file loading until metadata exists, and improve task status output (ETA, throttled printing, peer cap).
  • Add/extend unit tests for DHT, torrent client, and torrent server/task behaviors; update CHANGELOG.md.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/netius/clients/dht.py Adds routing table + iterative lookup/bootstrap; hardens parsing and request matching; exports node parsing helpers.
src/netius/clients/init.py Re-exports new DHT structures (DHTNode, DHTRoutingTable).
src/netius/clients/torrent.py Implements BEP 10 extended messaging + BEP 9 metadata exchange and adds a CLI example.
src/netius/common/torrent.py Recognizes message type 20 as extended.
src/netius/servers/torrent.py Defers loading until metadata exists, parses DHT peer values, adds ETA/status throttling and max-peers support.
src/netius/test/clients/dht.py Unit tests for DHT request/response parsing, node/routing-table behavior, and client lookup/bootstrap.
src/netius/test/clients/torrent.py Unit tests for extended handshake/metadata request + assembly logic.
src/netius/test/servers/torrent.py Unit tests for torrent task robustness (late DHT responses, malformed peers, metadata, ETA, peer cap, pre-metadata data).
CHANGELOG.md Documents the new DHT + torrent features and the related fixes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/netius/clients/dht.py Outdated
Comment on lines 150 to 154
def is_error(self):
return self.info("y", True)

def is_response(self):
return self.info("r", True)
Comment thread src/netius/clients/torrent.py
Comment thread src/netius/clients/torrent.py Outdated
Comment thread src/netius/clients/torrent.py
Comment thread src/netius/servers/torrent.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cc6e775f4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/netius/servers/torrent.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/netius/servers/torrent.py (1)

774-812: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Missing guard for self.stored access in status string.

Lines 803-808 access self.stored.marked_pieces, self.stored.total_pieces, etc. without checking if stored is initialized. While current usage only calls info_string() from piece callbacks (where stored is guaranteed to exist), calling it before metadata arrives would raise AttributeError.

Consider adding a guard or providing fallback values for when stored is None:

🛡️ Optional defensive handling
+            + "pieces      := %d/%d\n"
+            % (self.stored.marked_pieces if self.stored else 0, self.stored.total_pieces if self.stored else 0)
-            + "pieces      := %d/%d\n"
-            % (self.stored.marked_pieces, self.stored.total_pieces)

Or guard the entire section with an early check on self.stored.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/netius/servers/torrent.py` around lines 774 - 812, The info_string()
method accesses self.stored attributes (marked_pieces, total_pieces,
marked_blocks, total_blocks, missing_pieces, missing_blocks) without checking if
self.stored is initialized, which could raise an AttributeError if called before
metadata arrives. Add a guard check to verify that self.stored is not None
before accessing these attributes on lines 803-808, and provide appropriate
fallback values (such as 0 or "N/A") when self.stored is None to ensure the
method handles the case gracefully.
src/netius/clients/torrent.py (1)

227-252: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Fragile header/payload split relies on "ee" substring.

The parsing at line 231 uses data.index(b"ee") to find the end of the bencoded header. While this works for well-formed BEP 9 messages (the header dict contains only integer values for msg_type and piece), it could fail if a peer sends malformed data containing "ee" within string values.

Consider wrapping in a try/except to handle ValueError from index() if the pattern is missing:

🛡️ Optional defensive handling
     def on_metadata(self, data):
+        try:
+            index = data.index(b"ee") + 2
+        except ValueError:
+            return
-        index = data.index(b"ee") + 2
         message = netius.common.bdecode(data[:index])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/netius/clients/torrent.py` around lines 227 - 252, In the on_metadata
method, the line using data.index(b"ee") to locate the end of the bencoded
header can raise a ValueError if the "ee" delimiter is not found in malformed
data. Wrap the header parsing logic (the data.index(b"ee") call and the
subsequent bdecode operation) in a try/except block to catch ValueError
exceptions. When a ValueError occurs, the method should return early without
processing, since this indicates malformed metadata that cannot be safely
parsed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 17-20: The `### Changed` section in the CHANGELOG contains an
empty bullet point `*` with no description, which violates the changelog
guidelines requiring concrete entries. Either remove the entire empty `###
Changed` section if there are no actual changes to document, or replace the
empty bullet point with a meaningful description of what was changed in this
release.

In `@src/netius/clients/dht.py`:
- Around line 150-154: The `is_error()` and `is_response()` methods are
attempting to call `self.info(...)` as if it were a function, but `self.info` is
a dictionary and cannot be called directly, causing a TypeError. Replace the
calls to `self.info(...)` with proper dictionary access using `.get()` method.
Additionally, correct the logic: `is_error()` should check if the value of key
"y" equals "e", and `is_response()` should check if the key "r" exists in the
dictionary by verifying if `.get("r")` returns a truthy value.

---

Nitpick comments:
In `@src/netius/clients/torrent.py`:
- Around line 227-252: In the on_metadata method, the line using
data.index(b"ee") to locate the end of the bencoded header can raise a
ValueError if the "ee" delimiter is not found in malformed data. Wrap the header
parsing logic (the data.index(b"ee") call and the subsequent bdecode operation)
in a try/except block to catch ValueError exceptions. When a ValueError occurs,
the method should return early without processing, since this indicates
malformed metadata that cannot be safely parsed.

In `@src/netius/servers/torrent.py`:
- Around line 774-812: The info_string() method accesses self.stored attributes
(marked_pieces, total_pieces, marked_blocks, total_blocks, missing_pieces,
missing_blocks) without checking if self.stored is initialized, which could
raise an AttributeError if called before metadata arrives. Add a guard check to
verify that self.stored is not None before accessing these attributes on lines
803-808, and provide appropriate fallback values (such as 0 or "N/A") when
self.stored is None to ensure the method handles the case gracefully.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3e6081fd-d9da-473e-ad8b-53915fce6561

📥 Commits

Reviewing files that changed from the base of the PR and between a93d4ad and cc6e775.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • src/netius/clients/__init__.py
  • src/netius/clients/dht.py
  • src/netius/clients/torrent.py
  • src/netius/common/torrent.py
  • src/netius/servers/torrent.py
  • src/netius/test/clients/dht.py
  • src/netius/test/clients/torrent.py
  • src/netius/test/servers/torrent.py

Comment thread CHANGELOG.md
Comment thread src/netius/clients/dht.py Outdated
joamag added 2 commits June 21, 2026 21:22
- fix DHT is_error/is_response to read the message type instead of
  calling the info dict (which raised a type error)
- guard the extended and metadata message handlers against malformed
  data so a bad message no longer tears down the connection
- validate the info hash in the download example with a clear error
- load the pieces and file before the peer discovery so the tracker
  announce no longer fails for torrent file based downloads
- move the download example imports to the top of the module

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/netius/clients/torrent.py (1)

296-300: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use block not in instead of not block in per coding guidelines.

The membership test should use the preferred syntax.

Proposed fix
 def remove_request(self, block):
-    if not block in self.requests:
+    if block not in self.requests:
         return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/netius/clients/torrent.py` around lines 296 - 300, In the remove_request
method, change the membership test syntax from `not block in self.requests` to
`block not in self.requests` to follow the preferred coding guidelines for
membership testing in Python.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/netius/clients/torrent.py`:
- Around line 296-300: In the remove_request method, change the membership test
syntax from `not block in self.requests` to `block not in self.requests` to
follow the preferred coding guidelines for membership testing in Python.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1a5a6885-ff4a-4fb0-b842-bdace7de7b7a

📥 Commits

Reviewing files that changed from the base of the PR and between 88b3d9f and 06e0c93.

📒 Files selected for processing (5)
  • src/netius/clients/dht.py
  • src/netius/clients/torrent.py
  • src/netius/servers/torrent.py
  • src/netius/test/clients/dht.py
  • src/netius/test/clients/torrent.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/netius/clients/dht.py
  • src/netius/servers/torrent.py

@joamag joamag left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@joamag joamag merged commit 2f85540 into master Jun 21, 2026
19 checks passed
@joamag joamag deleted the feat/better-dht-client branch June 21, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request p-medium Medium priority issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Better DHT client

2 participants