Repository: algesten/str0m
Pull request #650: refactor(ice): return `Candidate` from `add_local_candidate`
Author: login: thomaseizinger
Created: 2025-05-02T23:43:56Z | Merged: 2025-05-04T08:39:54Z

At present, `IceAgent::add_local_candidate` consumes the `Candidate` and returns a `bool` whether the candidate was accepted by the `IceAgent`. In most applications, one needs to test for this boolean to know, whether the candidate should be signaled to the remote party. For example, a redundant candidate doesn't need to be signaled to the remote.

The current API is sub-optimal for this usecase because one always needs to `clone` the candidate in order to have it available to sending it via the signalling layer. We therefore refactor this API to return an Option`` instead with a reference to the candidate if it got accepted.

In addition, as of #640, str0m has a feature where it computes the local preference of a `Candidate` such that relay candidates allocated via the same IP version are preferred over others: An IPv4 relay candidate allocated over an IPv4 network is preferred over the IPv6 relay candidate allocated on the same network. By returning a reference to the accepted candidate from `add_local_candidate`, this local preference is preserved and reflected accordingly in the priority communicated to the remote.

As a result, the controlling agent (even if that isn't str0m), will prefer such a candidate and nominate a network path that doesn't need any IP stack translation on the relay. Prior to this change, this would only work for the relay candidates of the controlling agent as local preferences are only computed once a candidate is added to an agent.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4c7b7cf1..39c29b33f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
   * RTT in PeerStats #648
   * Fix off-by-one SDES size calculation #647
   * Increase ufrag size to 16 #646
+  * Change `Rtc::add_local_candidate` and `IceAgent::add_local_candidate` to return the candidate on success #650
 
 # 0.8.0
 
diff --git a/examples/chat.rs b/examples/chat.rs
index e83abe363..fdd516137 100644
--- a/examples/chat.rs
+++ b/examples/chat.rs
@@ -86,7 +86,7 @@ fn web_request(request: &Request, addr: SocketAddr, tx: SyncSender<Rtc>) -> Resp
 
     // Add the shared UDP socket as a host candidate
     let candidate = Candidate::host(addr, "udp").expect("a host candidate");
-    rtc.add_local_candidate(candidate);
+    rtc.add_local_candidate(candidate).unwrap();
 
     // Create an SDP Answer.
     let answer = rtc
diff --git a/examples/http-post.rs b/examples/http-post.rs
index 86192b6b0..5d9a60d48 100644
--- a/examples/http-post.rs
+++ b/examples/http-post.rs
@@ -65,7 +65,7 @@ fn web_request(request: &Request) -> Response {
     let socket = UdpSocket::bind(format!("{addr}:0")).expect("binding a random UDP port");
     let addr = socket.local_addr().expect("a local socket address");
     let candidate = Candidate::host(addr, "udp").expect("a host candidate");
-    rtc.add_local_candidate(candidate);
+    rtc.add_local_candidate(candidate).unwrap();
 
     // Create an SDP Answer.
     let answer = rtc
diff --git a/src/ice/agent.rs b/src/ice/agent.rs
index 9f75bdf64..4ee55b6b2 100644
--- a/src/ice/agent.rs
+++ b/src/ice/agent.rs
@@ -482,17 +482,19 @@ impl IceAgent {
 
     /// Adds a local candidate.
     ///
-    /// Returns `false` if the candidate was not added because it is redundant.
+    /// Returns `Some` if the candidate was added and `None` in all other cases.
+    /// If the candidate was added, it should be signalled to the remote party.
+    ///
     /// Adding loopback addresses or multicast/broadcast addresses causes
     /// an error.
-    pub fn add_local_candidate(&mut self, mut c: Candidate) -> bool {
+    pub fn add_local_candidate(&mut self, mut c: Candidate) -> Option<&Candidate> {
         let ip = c.addr().ip();
 
         if self.ice_lite {
             // Reject all non-host candidates.
             if c.kind() != CandidateKind::Host {
                 debug!("Reject non-host candidate due to ice-lite mode: {:?}", c);
-                return false;
+                return None;
             }
         }
 
@@ -599,7 +601,7 @@ impl IceAgent {
                         "Reject redundant candidate, current: {:?} rejected: {:?}",
                         other, c
                     );
-                    return false;
+                    return None;
                 }
 
                 // Stop using the current candidate in favor of the new one.
@@ -640,7 +642,9 @@ impl IceAgent {
         // by the remote party, whether we form local pairs directly or later seems irrelevant.
         self.form_pairs(&[local_idx], &remote_idxs);
 
-        true
+        // We specifically use indexing here instead of `.get`.
+        // If the index is wrong, this is a bug!
+        Some(&self.local_candidates[local_idx])
     }
 
     /// Adds a remote candidate.
@@ -1820,10 +1824,18 @@ mod test {
     fn local_preference_host() {
         let mut agent = IceAgent::new();
 
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
-        agent.add_local_candidate(Candidate::host(ipv6_1(), "udp").unwrap());
-        agent.add_local_candidate(Candidate::host(ipv6_2(), "udp").unwrap());
-        agent.add_local_candidate(Candidate::host(ipv4_2(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
+        agent
+            .add_local_candidate(Candidate::host(ipv6_1(), "udp").unwrap())
+            .unwrap();
+        agent
+            .add_local_candidate(Candidate::host(ipv6_2(), "udp").unwrap())
+            .unwrap();
+        agent
+            .add_local_candidate(Candidate::host(ipv4_2(), "udp").unwrap())
+            .unwrap();
 
         let v: Vec<_> = agent
             .local_candidates
@@ -1842,11 +1854,11 @@ mod test {
         // redundant when the agent is not behind a NAT.
 
         let x2 = agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
-        assert!(x2);
+        assert!(x2.is_some());
 
         // this is redundant given we have the direct host candidate above.
         let x1 = agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_1(), ipv4_1(), "udp"));
-        assert!(!x1);
+        assert!(x1.is_none());
     }
 
     #[test]
@@ -1855,7 +1867,7 @@ mod test {
         let host = Candidate::host(ipv4_1(), "udp").unwrap();
         let srflx = Candidate::server_reflexive(ipv4_1(), ipv4_1(), "udp").unwrap();
 
-        agent.add_local_candidate(host.clone());
+        agent.add_local_candidate(host.clone()).unwrap();
         let invalidated = agent.invalidate_candidate(&srflx);
         assert!(!invalidated);
 
@@ -1883,17 +1895,31 @@ mod test {
         let mut agent = IceAgent::new();
 
         // Candidates with the same SocketAddr but different protocols are considered distinct.
-        assert!(agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap()));
-        assert!(agent.add_local_candidate(Candidate::host(ipv4_1(), "tcp").unwrap()));
-        assert!(agent.add_local_candidate(Candidate::host(ipv4_1(), "ssltcp").unwrap()));
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .is_some());
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "tcp").unwrap())
+            .is_some());
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "ssltcp").unwrap())
+            .is_some());
 
         // Verify these are rejected, since these tuples of address and protocol have been added.
-        assert!(!agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap()));
-        assert!(!agent.add_local_candidate(Candidate::host(ipv4_1(), "ssltcp").unwrap()));
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .is_none());
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "ssltcp").unwrap())
+            .is_none());
 
         // Verify these are allowed, since these have different addresses.
-        assert!(agent.add_local_candidate(Candidate::host(ipv4_2(), "udp").unwrap()));
-        assert!(agent.add_local_candidate(Candidate::host(ipv4_2(), "ssltcp").unwrap()));
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_2(), "udp").unwrap())
+            .is_some());
+        assert!(agent
+            .add_local_candidate(Candidate::host(ipv4_2(), "ssltcp").unwrap())
+            .is_some());
     }
 
     #[test]
@@ -1905,10 +1931,10 @@ mod test {
 
         // this is contrived, but it is redundant when we add the host candidate below.
         let x1 = agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_1(), ipv4_1(), "udp"));
-        assert!(x1);
+        assert!(x1.is_some());
 
         let x2 = agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
-        assert!(x2);
+        assert!(x2.is_some());
 
         let v: Vec<_> = agent
             .local_candidates
@@ -1924,11 +1950,17 @@ mod test {
         let mut agent = IceAgent::new();
 
         // local 0
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
         // local 1 "udp"
-        agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_4(), ipv4_2(), "udp"));
+        agent
+            .add_local_candidate(Candidate::test_peer_rflx(ipv4_4(), ipv4_2(), "udp"))
+            .unwrap();
         // local 2 "tcp"
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "tcp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "tcp").unwrap())
+            .unwrap();
 
         // remote 0
         agent.add_remote_candidate(Candidate::test_peer_rflx(ipv4_4(), ipv4_3(), "udp"));
@@ -1956,18 +1988,24 @@ mod test {
 
         agent.add_remote_candidate(Candidate::host(ipv4_3(), "udp").unwrap());
         agent.add_remote_candidate(Candidate::host(ipv4_3(), "tcp").unwrap());
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
 
         // the UDP candidates should be pair up.
         assert_eq!(agent.pair_indexes(), [(0, 0)]);
 
         // this local UDP candidate is redundant an won't form a new pair.
-        agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp"));
+        agent
+            .add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp"))
+            .unwrap();
 
         assert_eq!(agent.pair_indexes(), [(0, 0)]);
 
         // this local TCP candidate will be paired up (This is the 3rd local candidate)
-        agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "tcp"));
+        agent
+            .add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "tcp"))
+            .unwrap();
 
         assert_eq!(agent.pair_indexes(), [(0, 0), (2, 1)]);
     }
@@ -1977,13 +2015,17 @@ mod test {
         let mut agent = IceAgent::new();
 
         agent.add_remote_candidate(Candidate::host(ipv4_3(), "udp").unwrap());
-        agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp"));
+        agent
+            .add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp"))
+            .unwrap();
 
         assert_eq!(agent.pair_indexes(), [(0, 0)]);
 
         // this local candidate is redundant, but has higher priority than then existing pair.
         // it replaces the existing pair.
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
 
         assert_eq!(agent.pair_indexes(), [(1, 0)]);
     }
@@ -2004,7 +2046,9 @@ mod test {
         );
 
         agent.add_remote_candidate(c);
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
 
         assert_eq!(agent.pair_indexes(), [(0, 0)]);
 
@@ -2032,7 +2076,7 @@ mod test {
 
         let local = Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp");
 
-        agent.add_local_candidate(local.clone());
+        agent.add_local_candidate(local.clone()).unwrap();
         agent.invalidate_candidate(&local);
 
         agent.add_remote_candidate(Candidate::host(ipv4_3(), "udp").unwrap());
@@ -2050,7 +2094,9 @@ mod test {
         agent.add_remote_candidate(remote.clone());
         agent.invalidate_candidate(&remote);
 
-        agent.add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp"));
+        agent
+            .add_local_candidate(Candidate::test_peer_rflx(ipv4_2(), ipv4_1(), "udp"))
+            .unwrap();
 
         // There should be no pairs since we invalidated the local candidate.
         assert_eq!(agent.pair_indexes(), []);
@@ -2059,7 +2105,9 @@ mod test {
     #[test]
     fn poll_time_must_timing_advance() {
         let mut agent = IceAgent::new();
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
         agent.add_remote_candidate(Candidate::host(ipv4_3(), "udp").unwrap());
 
         let now1 = Instant::now();
@@ -2098,7 +2146,9 @@ mod test {
         remote_candidate.set_ufrag(&remote_creds.ufrag);
 
         agent.set_remote_credentials(remote_creds.clone());
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
         agent.add_remote_candidate(remote_candidate);
         agent.handle_timeout(Instant::now());
 
@@ -2117,7 +2167,9 @@ mod test {
     #[test]
     fn queues_stun_binding_before_remote_creds() {
         let mut agent = IceAgent::new();
-        agent.add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap());
+        agent
+            .add_local_candidate(Candidate::host(ipv4_1(), "udp").unwrap())
+            .unwrap();
 
         let remote_creds = IceCreds::new();
         let mut remote_candidate = Candidate::host(ipv4_3(), "udp").unwrap();
diff --git a/src/ice/mod.rs b/src/ice/mod.rs
index 3aff38537..a2950cf41 100644
--- a/src/ice/mod.rs
+++ b/src/ice/mod.rs
@@ -29,12 +29,13 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:9999", "udp"); // 9999 is just dropped by propagate
-        a1.add_local_candidate(c1.clone());
+        // 9999 is just dropped by propagate
+        let c1 = a1.add_host_candidate("1.1.1.1:9999");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -155,11 +156,10 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
 
         a1.set_controlling(true);
@@ -226,12 +226,12 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -276,12 +276,12 @@ mod test {
         let mut a2 = TestAgent::new(info_span!("R"));
         a2.time = early_now;
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -298,12 +298,12 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1.clone());
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -346,12 +346,11 @@ mod test {
         let mut a2 = TestAgent::new(info_span!("R"));
 
         let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_local_candidate(c1).unwrap().clone();
         a2.add_remote_candidate(c1.clone());
 
-        let c2 = host("2.2.2.2:1000", "udp");
-        a1.add_remote_candidate(c2.clone());
-        a2.add_local_candidate(c2.clone());
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
+        a1.add_remote_candidate(c2);
 
         a1.set_controlling(true);
         a2.set_controlling(false);
@@ -368,7 +367,7 @@ mod test {
         let new_sock = sock("8.8.8.8:1000");
 
         let c3 = Candidate::host(new_sock, Protocol::Udp).unwrap();
-        a1.add_local_candidate(c3.clone());
+        let c3 = a1.add_local_candidate(c3).unwrap().clone();
         a2.add_remote_candidate(c3);
 
         a1.agent.invalidate_candidate(&c1);
@@ -398,12 +397,12 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1.clone());
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -425,7 +424,7 @@ mod test {
         }
 
         // Add back the invalidated candidate
-        a1.add_local_candidate(c1);
+        a1.add_local_candidate(c1).unwrap();
 
         // progress() fails after 100 number of polls.
         a1.progress_count = 0;
@@ -443,12 +442,12 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2.clone());
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -491,12 +490,13 @@ mod test {
         // a1 acts as "server"
         a1.agent.set_ice_lite(true);
 
-        let c1 = host("1.1.1.1:9999", "udp"); // 9999 is just dropped by propagate
-        a1.add_local_candidate(c1.clone());
+        // 9999 is just dropped by propagate
+        let c1 = a1.add_host_candidate("1.1.1.1:9999");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
+
         a1.set_controlling(true);
         a2.set_controlling(false);
 
@@ -539,11 +539,11 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("3.3.3.3:1000", "udp"); // will be rewritten to 4.4.4.4
-        a1.add_local_candidate(c1.clone());
+        // will be rewritten to 4.4.4.4
+        let c1 = a1.add_host_candidate("3.3.3.3:1000");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
 
         a1.set_controlling(true);
@@ -598,11 +598,10 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1.clone());
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2.clone());
 
         a1.set_controlling(true);
@@ -649,11 +648,11 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("3.3.3.3:9999", "udp"); // no traffic possible
-        a1.add_local_candidate(c1.clone());
+        // no traffic possible
+        let c1 = a1.add_host_candidate("3.3.3.3:9999");
         a2.add_remote_candidate(c1);
-        let c2 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c2);
 
         a1.set_controlling(true);
@@ -665,9 +664,8 @@ mod test {
         }
 
         // "trickle" a possible candidate
-        a1.add_local_candidate(host("1.1.1.1:1000", "udp")); // possible
+        a1.add_host_candidate("1.1.1.1:1000");
 
-        // loop until we're connected.
         loop {
             if a1.state().is_connected() && a2.state().is_connected() {
                 break;
@@ -681,14 +679,18 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = relay("1.1.1.1:1000", "5.6.7.8:4321", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1
+            .add_local_candidate(relay("1.1.1.1:1000", "5.6.7.8:4321", "udp"))
+            .unwrap()
+            .clone();
         a2.add_remote_candidate(c1);
-        let c2 = srflx("4.4.4.4:1000", "3.3.3.3:1000", "udp");
-        let c3 = host("3.3.3.3:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+
+        let c2 = a2
+            .add_local_candidate(srflx("4.4.4.4:1000", "3.3.3.3:1000", "udp"))
+            .unwrap()
+            .clone();
         a1.add_remote_candidate(c2);
-        a2.add_local_candidate(c3.clone());
+        let c3 = a2.add_host_candidate("3.3.3.3:1000");
         a1.add_remote_candidate(c3);
 
         a1.set_controlling(true);
@@ -702,7 +704,8 @@ mod test {
             progress(&mut a1, &mut a2);
         }
 
-        a1.add_local_candidate(relay("1.1.1.1:1001", "5.6.7.8:4321", "udp"));
+        a1.add_local_candidate(relay("1.1.1.1:1001", "5.6.7.8:4321", "udp"))
+            .unwrap();
         a2.add_remote_candidate(relay("1.1.1.1:1001", "5.6.7.8:4321", "udp"));
 
         loop {
@@ -728,22 +731,24 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        let c2 = host("1.1.1.1:1001", "udp");
-
         // We need a 2nd pair of candidates to make sure the agent doesn't go straight into `Completed`.
-        let c3 = relay("2.2.2.2:1000", "5.6.7.8:4321", "udp");
-        let c4 = relay("2.2.2.2:1001", "5.6.7.8:4321", "udp");
 
-        // Agent 1 knows all candidates ahead of time.
-        a1.add_local_candidate(c1.clone());
-        a1.add_remote_candidate(c2.clone());
-        a1.add_local_candidate(c3.clone());
-        a1.add_remote_candidate(c4.clone());
+        // Both agents know their local candidates
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
+        let c3 = a1
+            .add_local_candidate(relay("2.2.2.2:1000", "5.6.7.8:4321", "udp"))
+            .unwrap()
+            .clone();
 
-        // Agent 2 only knows his own candidates (imagine signalling layer being a bit slow)
-        a2.add_local_candidate(c2.clone());
-        a2.add_local_candidate(c4.clone());
+        let c2 = a2.add_host_candidate("1.1.1.1:1001");
+        let c4 = a2
+            .add_local_candidate(relay("2.2.2.2:1001", "5.6.7.8:4321", "udp"))
+            .unwrap()
+            .clone();
+
+        // Agent 1 also learns about the remote candidates but agent 2 doesn't (imagine signalling layer being a bit slow)
+        a1.add_remote_candidate(c2);
+        a1.add_remote_candidate(c4);
 
         a1.set_controlling(true);
         a2.set_controlling(false);
@@ -778,14 +783,14 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        let c1 = host("1.1.1.1:1000", "udp");
-        a1.add_local_candidate(c1.clone());
+        let c1 = a1.add_host_candidate("1.1.1.1:1000");
         a2.add_remote_candidate(c1);
-        let c2 = srflx("2.2.2.2:1000", "2.2.2.2:1000", "udp");
-        let c3 = host("2.2.2.2:1000", "udp");
-        a2.add_local_candidate(c2.clone());
+        let c2 = a2
+            .add_local_candidate(srflx("2.2.2.2:1000", "2.2.2.2:1000", "udp"))
+            .unwrap()
+            .clone();
         a1.add_remote_candidate(c2);
-        a2.add_local_candidate(c3.clone());
+        let c3 = a2.add_host_candidate("2.2.2.2:1000");
         a1.add_remote_candidate(c3);
 
         a1.set_controlling(true);
@@ -808,7 +813,7 @@ mod test {
     // However, in case our only IPv6 connectivity is via a relay that we are talking to over IPv4,
     // we want to prefer the IPv4 code path.
     #[test]
-    fn prefers_ipv4_ipv4_relay_candidate_over_ipv4_ipv6() {
+    fn prefers_ipv4_ipv4_relay_candidate_over_ipv4_ipv6_controlling() {
         let _guard = tracing_subscriber::fmt()
             .with_env_filter("debug")
             .with_test_writer()
@@ -817,41 +822,64 @@ mod test {
         let mut a1 = TestAgent::new(info_span!("L"));
         let mut a2 = TestAgent::new(info_span!("R"));
 
-        // Agent 1 only has IPv4 connectivity to a relay but allocates both IPv4 and IPv6 addresses.
-        let relay_ipv4_ipv4 = relay("7.7.7.7:5000", "1.1.1.1:5000", "udp");
-        let relay_ipv6_ipv4 = relay("[::7]:5000", "1.1.1.1:5000", "udp");
+        a1.set_controlling(true);
+        a2.set_controlling(false);
 
-        // Agent 2 has no relay but has full IPv4 and IPv6 connectivity.
-        let host_ipv4 = host("5.5.5.5:3000", "udp");
-        let host_ipv6 = host("[::2]:3000", "udp");
+        prefer_ipv4_candidate_over_ipv6_candidate(&mut a1, &mut a2);
+    }
+
+    // In general, ICE prefers IPv6 over IPv4.
+    // However, in case our only IPv6 connectivity is via a relay that we are talking to over IPv4,
+    // we want to prefer the IPv4 code path.
+    #[test]
+    fn prefers_ipv4_ipv4_relay_candidate_over_ipv4_ipv6_controlled() {
+        let _guard = tracing_subscriber::fmt()
+            .with_env_filter("debug")
+            .with_test_writer()
+            .set_default();
 
-        a1.add_local_candidate(relay_ipv4_ipv4.clone());
-        a1.add_local_candidate(relay_ipv6_ipv4.clone());
-        a2.add_remote_candidate(relay_ipv4_ipv4.clone());
-        a2.add_remote_candidate(relay_ipv6_ipv4.clone());
+        let mut a1 = TestAgent::new(info_span!("L"));
+        let mut a2 = TestAgent::new(info_span!("R"));
 
-        a2.add_local_candidate(host_ipv4.clone());
-        a2.add_local_candidate(host_ipv6.clone());
-        a1.add_remote_candidate(host_ipv4.clone());
-        a1.add_remote_candidate(host_ipv6.clone());
+        a1.set_controlling(false);
+        a2.set_controlling(true);
 
-        a1.set_controlling(true);
-        a2.set_controlling(false);
+        prefer_ipv4_candidate_over_ipv6_candidate(&mut a1, &mut a2);
+    }
+
+    fn prefer_ipv4_candidate_over_ipv6_candidate(a1: &mut TestAgent, a2: &mut TestAgent) {
+        // Agent 1 only has IPv4 connectivity to a relay but allocates both IPv4 and IPv6 addresses.
+        // Agent 2 has no relay but has full IPv4 and IPv6 connectivity.
+        let relay_ipv4_ipv4 = a1
+            .add_local_candidate(relay("7.7.7.7:5000", "1.1.1.1:5000", "udp"))
+            .unwrap()
+            .clone();
+        let relay_ipv6_ipv4 = a1
+            .add_local_candidate(relay("[::7]:5000", "1.1.1.1:5000", "udp"))
+            .unwrap()
+            .clone();
+        a2.add_remote_candidate(relay_ipv4_ipv4);
+        a2.add_remote_candidate(relay_ipv6_ipv4);
+
+        let host_ipv4 = a2.add_host_candidate("5.5.5.5:3000");
+        let host_ipv6 = a2.add_host_candidate("[::2]:3000");
+        a1.add_remote_candidate(host_ipv4);
+        a1.add_remote_candidate(host_ipv6);
 
         // loop until we're connected.
         loop {
             if a1.state().is_connected() && a2.state().is_connected() {
                 break;
             }
-            progress(&mut a1, &mut a2);
+            progress(a1, a2);
         }
 
         assert!(a1.has_event(|e| {
-            matches!(e, IceAgentEvent::NominatedSend { source, destination, .. } if source == &sock("7.7.7.7:5000") && destination == &sock("5.5.5.5:3000"))
-        }));
+                    matches!(e, IceAgentEvent::NominatedSend { source, destination, .. } if source == &sock("7.7.7.7:5000") && destination == &sock("5.5.5.5:3000"))
+                }));
         assert!(a2.has_event(|e| {
-            matches!(e, IceAgentEvent::NominatedSend { source, destination, .. } if source == &sock("5.5.5.5:3000") && destination == &sock("7.7.7.7:5000"))
-        }));
+                    matches!(e, IceAgentEvent::NominatedSend { source, destination, .. } if source == &sock("5.5.5.5:3000") && destination == &sock("7.7.7.7:5000"))
+                }));
     }
 
     #[test]
@@ -934,6 +962,13 @@ mod test {
             }
         }
 
+        fn add_host_candidate(&mut self, addr: &str) -> Candidate {
+            self.agent
+                .add_local_candidate(host(addr, "udp"))
+                .unwrap()
+                .clone()
+        }
+
         fn has_event(&self, predicate: impl Fn(&IceAgentEvent) -> bool) -> bool {
             self.events.iter().any(|(_, e)| predicate(e))
         }
diff --git a/src/lib.rs b/src/lib.rs
index e6b04b10a..c324f4446 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1220,6 +1220,9 @@ impl Rtc {
     /// Add a local ICE candidate. Local candidates are socket addresses the `Rtc` instance
     /// use for communicating with the peer.
     ///
+    /// If the candidate is accepted by the `Rtc` instance, it will return `Some` with a reference
+    /// to it. You should then signal this candidate to the remote peer.
+    ///
     /// This library has no built-in discovery of local network addresses on the host
     /// or NATed addresses via a STUN server or TURN server. The user of the library
     /// is expected to add new local candidates as they are discovered.
@@ -1240,8 +1243,8 @@ impl Rtc {
     /// ```
     ///
     /// [1]: https://www.rfc-editor.org/rfc/rfc8838.txt
-    pub fn add_local_candidate(&mut self, c: Candidate) {
-        self.ice.add_local_candidate(c);
+    pub fn add_local_candidate(&mut self, c: Candidate) -> Option<&Candidate> {
+        self.ice.add_local_candidate(c)
     }
 
     /// Add a remote ICE candidate. Remote candidates are addresses of the peer.
diff --git a/tests/bidirectional.rs b/tests/bidirectional.rs
index f7d9ab520..b3c1c29f7 100644
--- a/tests/bidirectional.rs
+++ b/tests/bidirectional.rs
@@ -3,7 +3,7 @@ use std::time::Duration;
 
 use str0m::format::Codec;
 use str0m::media::{Direction, MediaKind};
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -17,10 +17,8 @@ pub fn bidirectional_same_m_line() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let mut change = l.sdp_api();
     let mid = change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
diff --git a/tests/common.rs b/tests/common.rs
index 20a240bc1..98d1b626d 100644
--- a/tests/common.rs
+++ b/tests/common.rs
@@ -1,6 +1,6 @@
 #![allow(unused)]
 use std::io::Cursor;
-use std::net::Ipv4Addr;
+use std::net::{Ipv4Addr, SocketAddr};
 use std::ops::{Deref, DerefMut};
 use std::sync::Once;
 use std::time::{Duration, Instant};
@@ -42,6 +42,13 @@ impl TestRtc {
         }
     }
 
+    pub fn add_host_candidate(&mut self, socket: SocketAddr) -> Candidate {
+        self.rtc
+            .add_local_candidate(Candidate::host(socket, "udp").unwrap())
+            .unwrap()
+            .clone()
+    }
+
     pub fn duration(&self) -> Duration {
         self.last - self.start
     }
diff --git a/tests/contiguous.rs b/tests/contiguous.rs
index ff113084f..c5c2717b3 100644
--- a/tests/contiguous.rs
+++ b/tests/contiguous.rs
@@ -151,10 +151,14 @@ impl Server {
 
         let mut r = TestRtc::new_with_rtc(info_span!("R"), rtc_r);
 
-        let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-        let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-        l.add_local_candidate(host1);
-        r.add_local_candidate(host2);
+        l.add_local_candidate(Candidate::host(
+            (Ipv4Addr::new(1, 1, 1, 1), 1000).into(),
+            "udp",
+        )?);
+        r.add_local_candidate(Candidate::host(
+            (Ipv4Addr::new(2, 2, 2, 2), 2000).into(),
+            "udp",
+        )?);
 
         // The change is on the L (sending side) with Direction::SendRecv.
         let mut change = l.sdp_api();
diff --git a/tests/data-channel-direct.rs b/tests/data-channel-direct.rs
index 2270f689b..630967dc8 100644
--- a/tests/data-channel-direct.rs
+++ b/tests/data-channel-direct.rs
@@ -20,9 +20,9 @@ pub fn data_channel_direct() -> Result<(), RtcError> {
 
     let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
     let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1.clone());
+    l.add_local_candidate(host1.clone()).unwrap();
     l.add_remote_candidate(host2.clone());
-    r.add_local_candidate(host2);
+    r.add_local_candidate(host2).unwrap();
     r.add_remote_candidate(host1);
 
     let finger_l = l.direct_api().local_dtls_fingerprint();
diff --git a/tests/data-channel.rs b/tests/data-channel.rs
index d5ad4391b..106eaec45 100644
--- a/tests/data-channel.rs
+++ b/tests/data-channel.rs
@@ -1,7 +1,7 @@
 use std::net::Ipv4Addr;
 use std::time::Duration;
 
-use str0m::{Candidate, RtcError};
+use str0m::RtcError;
 use tracing::info_span;
 
 mod common;
@@ -15,10 +15,8 @@ pub fn data_channel() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let mut change = l.sdp_api();
     let cid = change.add_channel("My little channel".into());
diff --git a/tests/flappy-ice-state.rs b/tests/flappy-ice-state.rs
index 304240d8b..57fe2bb33 100644
--- a/tests/flappy-ice-state.rs
+++ b/tests/flappy-ice-state.rs
@@ -2,7 +2,7 @@ use std::net::Ipv4Addr;
 use std::time::Duration;
 
 use str0m::RtcConfig;
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -18,10 +18,8 @@ pub fn flappy_ice_lite_state() -> Result<(), RtcError> {
     let rtc = RtcConfig::new().set_ice_lite(true).build();
     let mut r = TestRtc::new_with_rtc(info_span!("R"), rtc);
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let mut change = l.sdp_api();
     let _ = change.add_channel("My little channel".into());
diff --git a/tests/ice-restart.rs b/tests/ice-restart.rs
index eb8a1130d..c0879c5af 100644
--- a/tests/ice-restart.rs
+++ b/tests/ice-restart.rs
@@ -2,7 +2,7 @@ use std::net::Ipv4Addr;
 use std::time::Duration;
 
 use str0m::RtcConfig;
-use str0m::{Candidate, RtcError};
+use str0m::RtcError;
 use tracing::info_span;
 
 mod common;
@@ -18,10 +18,8 @@ pub fn ice_restart() -> Result<(), RtcError> {
     let rtc = RtcConfig::new().set_ice_lite(true).build();
     let mut r = TestRtc::new_with_rtc(info_span!("R"), rtc);
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let (offer, pending) = l.span.in_scope(|| {
         let mut change = l.rtc.sdp_api();
diff --git a/tests/keyframes.rs b/tests/keyframes.rs
index 3c521911e..29916e1d4 100644
--- a/tests/keyframes.rs
+++ b/tests/keyframes.rs
@@ -3,7 +3,7 @@ use std::time::Duration;
 
 use str0m::format::{Codec, CodecExtra};
 use str0m::media::{Direction, MediaKind};
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -18,10 +18,8 @@ pub fn test_vp8_keyframes_detection() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();
@@ -110,10 +108,8 @@ pub fn test_vp9_keyframes_detection() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();
@@ -203,10 +199,8 @@ pub fn test_h264_keyframes_detection() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();
diff --git a/tests/mediatime-backwards.rs b/tests/mediatime-backwards.rs
index e2b5934de..5d82596c5 100644
--- a/tests/mediatime-backwards.rs
+++ b/tests/mediatime-backwards.rs
@@ -4,7 +4,7 @@ use std::time::Duration;
 use str0m::format::Codec;
 use str0m::media::{Direction, MediaKind, MediaTime};
 use str0m::rtp::RawPacket;
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info;
 use tracing::info_span;
 
@@ -22,10 +22,8 @@ pub fn mediatime_backwards() -> Result<(), RtcError> {
     // Enable raw packets to trace the RTP headers
     r.rtc = str0m::Rtc::builder().enable_raw_packets(true).build();
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();
diff --git a/tests/remb.rs b/tests/remb.rs
index ed78688a7..f46bcca66 100644
--- a/tests/remb.rs
+++ b/tests/remb.rs
@@ -3,7 +3,7 @@ use std::time::Duration;
 
 use str0m::bwe::{Bitrate, BweKind};
 use str0m::media::{Direction, MediaKind};
-use str0m::{Candidate, Event, Rtc, RtcError};
+use str0m::{Event, Rtc, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -20,10 +20,8 @@ pub fn remb() -> Result<(), RtcError> {
     let mut l = TestRtc::new_with_rtc(info_span!("L"), l_rtc);
     let mut r = TestRtc::new_with_rtc(info_span!("R"), r_rtc);
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let mid = negotiate(&mut l, &mut r, |change| {
         change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None)
diff --git a/tests/stats.rs b/tests/stats.rs
index 90c355615..c91c1a89f 100644
--- a/tests/stats.rs
+++ b/tests/stats.rs
@@ -4,7 +4,7 @@ use std::time::Duration;
 use str0m::format::Codec;
 use str0m::media::{Direction, MediaKind};
 use str0m::stats::MediaEgressStats;
-use str0m::{Candidate, Event, RtcConfig, RtcError};
+use str0m::{Event, RtcConfig, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -21,10 +21,8 @@ pub fn stats() -> Result<(), RtcError> {
     let mut l = TestRtc::new_with_rtc(info_span!("L"), l_config.build());
     let mut r = TestRtc::new_with_rtc(info_span!("R"), r_config.build());
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let mut change = l.sdp_api();
     let mid = change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
diff --git a/tests/twcc.rs b/tests/twcc.rs
index 5e4c07f94..c83800df8 100644
--- a/tests/twcc.rs
+++ b/tests/twcc.rs
@@ -4,7 +4,7 @@ use std::time::Duration;
 use str0m::format::Codec;
 use str0m::media::{Direction, MediaKind};
 use str0m::rtp::rtcp::Twcc;
-use str0m::{Candidate, Rtc, RtcError};
+use str0m::{Rtc, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -21,10 +21,8 @@ pub fn twcc() -> Result<(), RtcError> {
     let mut l = TestRtc::new_with_rtc(info_span!("L"), l_rtc);
     let mut r = TestRtc::new_with_rtc(info_span!("R"), r_rtc);
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     let mid = negotiate(&mut l, &mut r, |change| {
         change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None)
diff --git a/tests/unidirectional-r-create-media.rs b/tests/unidirectional-r-create-media.rs
index 1b60a3e89..f141b0b63 100644
--- a/tests/unidirectional-r-create-media.rs
+++ b/tests/unidirectional-r-create-media.rs
@@ -4,7 +4,7 @@ use std::time::Duration;
 use str0m::change::SdpOffer;
 use str0m::format::Codec;
 use str0m::media::{Direction, MediaKind};
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -18,10 +18,8 @@ pub fn unidirectional_r_create_media() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the R (not sending side) with Direction::RecvOnly.
     let mut change = r.sdp_api();
diff --git a/tests/unidirectional.rs b/tests/unidirectional.rs
index 83ee0999f..a8cf5014a 100644
--- a/tests/unidirectional.rs
+++ b/tests/unidirectional.rs
@@ -3,7 +3,7 @@ use std::time::Duration;
 
 use str0m::format::Codec;
 use str0m::media::{Direction, MediaKind};
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -17,10 +17,8 @@ pub fn unidirectional() -> Result<(), RtcError> {
     let mut l = TestRtc::new(info_span!("L"));
     let mut r = TestRtc::new(info_span!("R"));
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();
diff --git a/tests/user-rtp-header-extension.rs b/tests/user-rtp-header-extension.rs
index e9af72a60..7eee5a78d 100644
--- a/tests/user-rtp-header-extension.rs
+++ b/tests/user-rtp-header-extension.rs
@@ -8,7 +8,7 @@ use str0m::rtp::Extension;
 use str0m::rtp::ExtensionSerializer;
 use str0m::rtp::ExtensionValues;
 use str0m::Rtc;
-use str0m::{Candidate, Event, RtcError};
+use str0m::{Event, RtcError};
 use tracing::info_span;
 
 mod common;
@@ -82,10 +82,8 @@ pub fn user_rtp_header_extension() -> Result<(), RtcError> {
     let mut l = TestRtc::new_with_rtc(info_span!("L"), rtc_l);
     let mut r = TestRtc::new_with_rtc(info_span!("R"), rtc_r);
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();
@@ -225,10 +223,8 @@ pub fn user_rtp_header_extension_two_byte_form() -> Result<(), RtcError> {
     let mut l = TestRtc::new_with_rtc(info_span!("L"), rtc_l);
     let mut r = TestRtc::new_with_rtc(info_span!("R"), rtc_r);
 
-    let host1 = Candidate::host((Ipv4Addr::new(1, 1, 1, 1), 1000).into(), "udp")?;
-    let host2 = Candidate::host((Ipv4Addr::new(2, 2, 2, 2), 2000).into(), "udp")?;
-    l.add_local_candidate(host1);
-    r.add_local_candidate(host2);
+    l.add_host_candidate((Ipv4Addr::new(1, 1, 1, 1), 1000).into());
+    r.add_host_candidate((Ipv4Addr::new(2, 2, 2, 2), 2000).into());
 
     // The change is on the L (sending side) with Direction::SendRecv.
     let mut change = l.sdp_api();


Reviews:
user: login: thomaseizinger
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-03T02:19:11Z
user: login: algesten
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-03T08:38:20Z
user: login: algesten
body: Looks good! Though I don't like `Result` here. 

Adding redundant candidates is not an error, it's expected. The idea is that you should be able to continuously add candidates that you discover without needing to remember which are added already.

It silently discards any that are not relevant.
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-03T08:40:34Z
user: login: algesten
state: APPROVED
author_association: OWNER
submitted_at: 2025-05-04T08:39:48Z

Review comments:
diff_hunk: @@ -466,17 +467,35 @@ impl IceAgent {
 
     /// Adds a local candidate.
     ///
-    /// Returns `false` if the candidate was not added because it is redundant.
+    /// Returns `Result` if the candidate was added and `Err` in all other cases.
+    /// If the candidate was added, it should be signalled to the remote party.
+    ///
     /// Adding loopback addresses or multicast/broadcast addresses causes
     /// an error.
-    pub fn add_local_candidate(&mut self, mut c: Candidate) -> bool {
+    ///
+    /// The resulting error is boxed. To pattern match on it, dereference it first.
+    ///
+    /// ```
+    /// # use str0m::{Candidate, ice::IceAgent, error::IceError};
+    ///
+    /// let mut agent = IceAgent::new();
+    ///
+    /// match agent
+    ///     .add_local_candidate(Candidate::host("1.1.1.1:1000".parse().unwrap(), "udp").unwrap())
+    ///     .map_err(|e| *e) // Dereference boxed error to allow pattern matching.
+    /// {
+    ///     Ok(_) => {}
+    ///     Err(IceError::RedundantCandidate { .. }) => {}
+    ///     Err(IceError::NonHostCandidateInIceLite(..)) => {}
+    /// }
+    /// ```
+    pub fn add_local_candidate(&mut self, mut c: Candidate) -> Result<&Candidate, Box<IceError>> {
path: src/ice/agent.rs
user: login: thomaseizinger
body: Having to `Box` the error here is not ideal. Clippy was complaining that it is a very large error variant (168 bytes). Boxing it was the easiest to fix that as otherwise, I'd have to put an `#[allow]` on all call-sites that return `IceError` and `RtcError` (which embeds an `IceError`).

I added a doc-test to explain how to still pattern match on it. Open to other solutions!
created_at: 2025-05-03T02:19:07Z
author_association: COLLABORATOR
diff_hunk: @@ -466,17 +467,35 @@ impl IceAgent {
 
     /// Adds a local candidate.
     ///
-    /// Returns `false` if the candidate was not added because it is redundant.
+    /// Returns `Result` if the candidate was added and `Err` in all other cases.
+    /// If the candidate was added, it should be signalled to the remote party.
+    ///
     /// Adding loopback addresses or multicast/broadcast addresses causes
     /// an error.
-    pub fn add_local_candidate(&mut self, mut c: Candidate) -> bool {
+    ///
+    /// The resulting error is boxed. To pattern match on it, dereference it first.
+    ///
+    /// ```
+    /// # use str0m::{Candidate, ice::IceAgent, error::IceError};
+    ///
+    /// let mut agent = IceAgent::new();
+    ///
+    /// match agent
+    ///     .add_local_candidate(Candidate::host("1.1.1.1:1000".parse().unwrap(), "udp").unwrap())
+    ///     .map_err(|e| *e) // Dereference boxed error to allow pattern matching.
+    /// {
+    ///     Ok(_) => {}
+    ///     Err(IceError::RedundantCandidate { .. }) => {}
+    ///     Err(IceError::NonHostCandidateInIceLite(..)) => {}
+    /// }
+    /// ```
+    pub fn add_local_candidate(&mut self, mut c: Candidate) -> Result<&Candidate, Box<IceError>> {
path: src/ice/agent.rs
user: login: algesten
body: I'd hugely prefer `Option<&Candidate>`
created_at: 2025-05-03T08:38:20Z
author_association: OWNER

PR comments:
user: login: thomaseizinger
created_at: 2025-05-03T10:05:51Z
body: > Looks good! Though I don't like `Result` here.
> 
> Adding redundant candidates is not an error, it's expected. The idea is that you should be able to continuously add candidates that you discover without needing to remember which are added already.
> 
> It silently discards any that are not relevant.

Fair enough. For the record though, I don't think that just because something return an error means that something fatal happened. I see `Result` as a way of expressing "did this operation succeed" and when a candidate is not added, then it didn't succeed. What `Result` opens up though is the flexibility of allowing the host application to decide e.g. which level they want to log this at. With `Option`, this information is lost. So in a way, `Result` is just a richer API.

Perhaps we should turn `Result` into `Option` on the `Rtc` level?

I don't mind either way though, happy to go with `Option`!
author_association: COLLABORATOR
user: login: algesten
created_at: 2025-05-03T14:08:50Z
body: > I don't mind either way though, happy to go with `Option`!

With `Result` you have to handle it. And for this call, you really don't. If we need to convey meaning I'd prefer to return an enum, but we don't have any other meaning right now than "was/nt added", 
author_association: OWNER
user: login: thomaseizinger
created_at: 2025-05-04T01:43:17Z
body: @algesten I figured that the `Result` might be controversial so I had it split out into a separate commit already. I dropped that one now. Everything else is the same (and now rebased on top of `main`), including unwrapping the `Option`. Let me know what you think about that.
author_association: COLLABORATOR
user: login: algesten
created_at: 2025-05-04T08:40:10Z
body: Looks good! Thanks!
author_association: OWNER