async_snmp/client/
v3.rs

1//! SNMPv3-specific client functionality.
2//!
3//! This module contains V3 security configuration, key derivation, engine discovery,
4//! and V3 message building/handling.
5
6use crate::ber::Decoder;
7use crate::error::internal::{AuthErrorKind, CryptoErrorKind, DecodeErrorKind, EncodeErrorKind};
8use crate::error::{Error, ErrorStatus, Result};
9use crate::format::hex;
10use crate::message::{MsgFlags, MsgGlobalData, ScopedPdu, SecurityLevel, V3Message};
11use crate::pdu::{Pdu, PduType};
12use crate::transport::Transport;
13use crate::v3::{AuthProtocol, PrivProtocol};
14use crate::v3::{
15    LocalizedKey, PrivKey, UsmSecurityParams,
16    auth::{authenticate_message, verify_message},
17    is_not_in_time_window_report, is_unknown_engine_id_report,
18};
19use bytes::Bytes;
20use std::time::Instant;
21use tracing::{Span, instrument};
22
23use super::Client;
24
25/// SNMPv3 security configuration.
26///
27/// Stores the credentials needed for authenticated and/or encrypted communication.
28/// Keys are derived when the engine ID is discovered.
29///
30/// # Master Key Caching
31///
32/// When polling many engines with shared credentials, use
33/// [`MasterKeys`](crate::MasterKeys) to cache the expensive password-to-key
34/// derivation. When `master_keys` is set, passwords are ignored and keys are
35/// derived from the cached master keys.
36#[derive(Clone)]
37pub struct V3SecurityConfig {
38    /// Username for USM authentication
39    pub username: Bytes,
40    /// Authentication protocol and password
41    pub auth: Option<(AuthProtocol, Vec<u8>)>,
42    /// Privacy protocol and password
43    pub privacy: Option<(PrivProtocol, Vec<u8>)>,
44    /// Pre-computed master keys for efficient key derivation
45    pub master_keys: Option<crate::v3::MasterKeys>,
46}
47
48impl V3SecurityConfig {
49    /// Create a new V3 security config with just a username (noAuthNoPriv).
50    pub fn new(username: impl Into<Bytes>) -> Self {
51        Self {
52            username: username.into(),
53            auth: None,
54            privacy: None,
55            master_keys: None,
56        }
57    }
58
59    /// Add authentication (authNoPriv or authPriv).
60    pub fn auth(mut self, protocol: AuthProtocol, password: impl Into<Vec<u8>>) -> Self {
61        self.auth = Some((protocol, password.into()));
62        self
63    }
64
65    /// Add privacy/encryption (authPriv).
66    pub fn privacy(mut self, protocol: PrivProtocol, password: impl Into<Vec<u8>>) -> Self {
67        self.privacy = Some((protocol, password.into()));
68        self
69    }
70
71    /// Use pre-computed master keys for efficient key derivation.
72    ///
73    /// When set, passwords are ignored and keys are derived from the cached
74    /// master keys. This avoids the expensive ~850μs password expansion for
75    /// each engine.
76    pub fn with_master_keys(mut self, master_keys: crate::v3::MasterKeys) -> Self {
77        self.master_keys = Some(master_keys);
78        self
79    }
80
81    /// Get the security level based on configured auth/privacy.
82    pub fn security_level(&self) -> SecurityLevel {
83        // Check master_keys first, then fall back to auth/privacy
84        if let Some(ref master_keys) = self.master_keys {
85            if master_keys.priv_protocol().is_some() {
86                return SecurityLevel::AuthPriv;
87            }
88            return SecurityLevel::AuthNoPriv;
89        }
90
91        match (&self.auth, &self.privacy) {
92            (None, _) => SecurityLevel::NoAuthNoPriv,
93            (Some(_), None) => SecurityLevel::AuthNoPriv,
94            (Some(_), Some(_)) => SecurityLevel::AuthPriv,
95        }
96    }
97
98    /// Derive localized keys for a specific engine ID.
99    ///
100    /// If master keys are configured, uses the cached master keys for efficient
101    /// localization (~1μs). Otherwise, performs full password-to-key derivation
102    /// (~850μs for SHA-256).
103    pub fn derive_keys(&self, engine_id: &[u8]) -> V3DerivedKeys {
104        // Use master keys if available (efficient path)
105        if let Some(ref master_keys) = self.master_keys {
106            tracing::trace!(target: "async_snmp::client", { engine_id_len = engine_id.len(), auth_protocol = ?master_keys.auth_protocol(), priv_protocol = ?master_keys.priv_protocol() }, "localizing from cached master keys");
107            let (auth_key, priv_key) = master_keys.localize(engine_id);
108            tracing::trace!(target: "async_snmp::client", "key localization complete");
109            return V3DerivedKeys {
110                auth_key: Some(auth_key),
111                priv_key,
112            };
113        }
114
115        // Fall back to password-based derivation
116        tracing::trace!(target: "async_snmp::client", { engine_id_len = engine_id.len(), has_auth = self.auth.is_some(), has_priv = self.privacy.is_some() }, "deriving localized keys from passwords");
117
118        let auth_key = self.auth.as_ref().map(|(protocol, password)| {
119            tracing::trace!(target: "async_snmp::client", { auth_protocol = ?protocol }, "deriving auth key");
120            LocalizedKey::from_password(*protocol, password, engine_id)
121        });
122
123        let priv_key = match (&self.auth, &self.privacy) {
124            (Some((auth_protocol, _)), Some((priv_protocol, priv_password))) => {
125                tracing::trace!(target: "async_snmp::client", { priv_protocol = ?priv_protocol }, "deriving privacy key");
126                Some(PrivKey::from_password(
127                    *auth_protocol,
128                    *priv_protocol,
129                    priv_password,
130                    engine_id,
131                ))
132            }
133            _ => None,
134        };
135
136        tracing::trace!(target: "async_snmp::client", "key derivation complete");
137        V3DerivedKeys { auth_key, priv_key }
138    }
139}
140
141impl std::fmt::Debug for V3SecurityConfig {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("V3SecurityConfig")
144            .field("username", &String::from_utf8_lossy(&self.username))
145            .field("auth", &self.auth.as_ref().map(|(p, _)| p))
146            .field("privacy", &self.privacy.as_ref().map(|(p, _)| p))
147            .finish()
148    }
149}
150
151/// Derived keys for a specific engine ID.
152pub struct V3DerivedKeys {
153    pub auth_key: Option<LocalizedKey>,
154    pub priv_key: Option<PrivKey>,
155}
156
157// V3-specific Client implementation
158impl<T: Transport> Client<T> {
159    /// Ensure engine ID is discovered for V3 operations.
160    #[instrument(level = "debug", skip(self), fields(snmp.target = %self.peer_addr()))]
161    pub(super) async fn ensure_engine_discovered(&self) -> Result<()> {
162        // Check if already discovered
163        {
164            let state = self.inner.engine_state.read().unwrap();
165            if state.is_some() {
166                return Ok(());
167            }
168        }
169
170        // Check shared cache first
171        if let Some(cache) = &self.inner.engine_cache
172            && let Some(cached_state) = cache.get(&self.peer_addr())
173        {
174            tracing::debug!(target: "async_snmp::client", "using cached engine state");
175            let mut state = self.inner.engine_state.write().unwrap();
176            *state = Some(cached_state.clone());
177            // Derive keys for this engine
178            if let Some(security) = &self.inner.config.v3_security {
179                let keys = security.derive_keys(&cached_state.engine_id);
180                let mut derived = self.inner.derived_keys.write().unwrap();
181                *derived = Some(keys);
182            }
183            return Ok(());
184        }
185
186        // Perform discovery
187        tracing::debug!(target: "async_snmp::client", "performing engine discovery");
188        let msg_id = self.next_request_id();
189        let discovery_msg = V3Message::discovery_request(msg_id);
190        let discovery_data = discovery_msg.encode();
191
192        // Register request and send discovery
193        self.inner
194            .transport
195            .register_request(msg_id, self.inner.config.timeout);
196        self.inner.transport.send(&discovery_data).await?;
197        let (response_data, _source) = self.inner.transport.recv(msg_id).await?;
198
199        // Parse response
200        let response = V3Message::decode(response_data)?;
201
202        let reported_msg_max_size = response.global_data.msg_max_size as u32;
203        let session_max = self.inner.transport.max_message_size();
204        let engine_state = crate::v3::parse_discovery_response_with_limits(
205            &response.security_params,
206            reported_msg_max_size,
207            session_max,
208        )?;
209        tracing::debug!(target: "async_snmp::client", { snmp.engine_id = %hex::Bytes(&engine_state.engine_id), snmp.engine_boots = engine_state.engine_boots, snmp.engine_time = engine_state.engine_time, snmp.msg_max_size = engine_state.msg_max_size }, "discovered engine");
210
211        // Derive keys for this engine
212        if let Some(security) = &self.inner.config.v3_security {
213            let keys = security.derive_keys(&engine_state.engine_id);
214            let mut derived = self.inner.derived_keys.write().unwrap();
215            *derived = Some(keys);
216        }
217
218        // Store in local cache
219        {
220            let mut state = self.inner.engine_state.write().unwrap();
221            *state = Some(engine_state.clone());
222        }
223
224        // Store in shared cache if present
225        if let Some(cache) = &self.inner.engine_cache {
226            cache.insert(self.peer_addr(), engine_state);
227        }
228
229        Ok(())
230    }
231
232    /// Build and encode a V3 message with authentication and/or encryption.
233    ///
234    /// The `msg_id` parameter is separate from `pdu.request_id` per RFC 3412
235    /// Section 6.2: retransmissions SHOULD use a new msgID for each attempt.
236    pub(super) fn build_v3_message(&self, pdu: &Pdu, msg_id: i32) -> Result<Vec<u8>> {
237        let security = self.inner.config.v3_security.as_ref().ok_or_else(|| {
238            tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::NoSecurityConfig }, "V3 security not configured");
239            Error::Config("V3 security not configured".into()).boxed()
240        })?;
241
242        let engine_state = self.inner.engine_state.read().unwrap();
243        let engine_state = engine_state.as_ref().ok_or_else(|| {
244            tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::EngineNotDiscovered }, "engine not discovered");
245            Error::Config("engine not discovered".into()).boxed()
246        })?;
247
248        let derived = self.inner.derived_keys.read().unwrap();
249
250        let security_level = security.security_level();
251
252        // Build scoped PDU
253        let scoped_pdu = ScopedPdu::new(
254            engine_state.engine_id.clone(),
255            Bytes::new(), // empty context name
256            pdu.clone(),
257        );
258
259        // Get current engine time estimate
260        let engine_boots = engine_state.engine_boots;
261        let engine_time = engine_state.estimated_time();
262
263        // Handle encryption if needed
264        let (msg_data, priv_params) = if security_level.requires_priv() {
265            tracing::trace!(target: "async_snmp::client", "encrypting scoped PDU");
266
267            // Get mutable priv_key - we need interior mutability for salt counter
268            // Since PrivKey uses internal counter, we need to clone and use
269            let derived_ref = derived.as_ref().ok_or_else(|| {
270                tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::KeysNotDerived }, "keys not derived");
271                Error::Config("keys not derived".into()).boxed()
272            })?;
273            let mut priv_key = derived_ref
274                .priv_key
275                .as_ref()
276                .ok_or_else(|| {
277                    tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::NoPrivKey }, "privacy key not available");
278                    Error::Config("privacy key not available".into()).boxed()
279                })?
280                .clone();
281
282            // Encode scoped PDU
283            let scoped_pdu_bytes = scoped_pdu.encode_to_bytes();
284
285            // Encrypt
286            let (ciphertext, salt) = priv_key
287                .encrypt(
288                    &scoped_pdu_bytes,
289                    engine_boots,
290                    engine_time,
291                    Some(&self.inner.salt_counter),
292                )
293                .map_err(|e| {
294                    tracing::warn!(target: "async_snmp::crypto", { peer = %self.peer_addr(), error = %e }, "encryption failed");
295                    Error::Auth {
296                        target: self.peer_addr(),
297                    }
298                    .boxed()
299                })?;
300
301            tracing::trace!(target: "async_snmp::client", { plaintext_len = scoped_pdu_bytes.len(), ciphertext_len = ciphertext.len() }, "encrypted scoped PDU");
302
303            (crate::message::V3MessageData::Encrypted(ciphertext), salt)
304        } else {
305            (
306                crate::message::V3MessageData::Plaintext(scoped_pdu),
307                Bytes::new(),
308            )
309        };
310
311        // Build USM security parameters
312        let mac_len = if security_level.requires_auth() {
313            derived
314                .as_ref()
315                .and_then(|d| d.auth_key.as_ref())
316                .map(|k| k.mac_len())
317                .unwrap_or(12)
318        } else {
319            0
320        };
321
322        let mut usm_params = UsmSecurityParams::new(
323            engine_state.engine_id.clone(),
324            engine_boots,
325            engine_time,
326            security.username.clone(),
327        );
328
329        if security_level.requires_auth() {
330            usm_params = usm_params.with_auth_placeholder(mac_len);
331        }
332
333        if security_level.requires_priv() {
334            usm_params = usm_params.with_priv_params(priv_params);
335        }
336
337        let usm_encoded = usm_params.encode();
338
339        // Build global data
340        let msg_flags = MsgFlags::new(security_level, true); // reportable=true for requests
341        let global_data = MsgGlobalData::new(msg_id, 65507, msg_flags);
342
343        // Build complete message
344        let msg = match msg_data {
345            crate::message::V3MessageData::Plaintext(scoped_pdu) => {
346                V3Message::new(global_data, usm_encoded, scoped_pdu)
347            }
348            crate::message::V3MessageData::Encrypted(ciphertext) => {
349                V3Message::new_encrypted(global_data, usm_encoded, ciphertext)
350            }
351        };
352
353        let mut encoded = msg.encode().to_vec();
354
355        // Apply authentication if needed
356        if security_level.requires_auth() {
357            tracing::trace!(target: "async_snmp::client", "applying HMAC authentication");
358
359            let auth_key = derived
360                .as_ref()
361                .and_then(|d| d.auth_key.as_ref())
362                .ok_or_else(|| {
363                    tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::MissingAuthKey }, "auth key not available for encoding");
364                    Error::Config("auth key not available".into()).boxed()
365                })?;
366
367            // Find auth params position and apply HMAC
368            if let Some((offset, len)) = UsmSecurityParams::find_auth_params_offset(&encoded) {
369                authenticate_message(auth_key, &mut encoded, offset, len);
370                tracing::trace!(target: "async_snmp::client", { auth_params_offset = offset, auth_params_len = len }, "applied HMAC authentication");
371            } else {
372                tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::MissingAuthParams }, "could not find auth params position");
373                return Err(Error::Config("could not find auth params position".into()).boxed());
374            }
375        }
376
377        Ok(encoded)
378    }
379
380    /// Send a V3 request and handle the response.
381    #[instrument(
382        level = "debug",
383        skip(self, pdu),
384        fields(
385            snmp.target = %self.peer_addr(),
386            snmp.request_id = pdu.request_id,
387            snmp.security_level = ?self.inner.config.v3_security.as_ref().map(|s| s.security_level()),
388            snmp.attempt = tracing::field::Empty,
389            snmp.elapsed_ms = tracing::field::Empty,
390        )
391    )]
392    pub(super) async fn send_v3_and_recv(&self, pdu: Pdu) -> Result<Pdu> {
393        let start = Instant::now();
394
395        // Ensure engine is discovered first
396        self.ensure_engine_discovered().await?;
397
398        let security = self.inner.config.v3_security.as_ref().ok_or_else(|| {
399            tracing::debug!(target: "async_snmp::client", { kind = %EncodeErrorKind::NoSecurityConfig }, "V3 security not configured");
400            Error::Config("V3 security not configured".into()).boxed()
401        })?;
402        let security_level = security.security_level();
403
404        let mut last_error: Option<Box<Error>> = None;
405        let max_attempts = if self.inner.transport.is_reliable() {
406            0
407        } else {
408            self.inner.config.retry.max_attempts
409        };
410
411        for attempt in 0..=max_attempts {
412            Span::current().record("snmp.attempt", attempt);
413            if attempt > 0 {
414                tracing::debug!(target: "async_snmp::client", "retrying V3 request");
415            }
416
417            // RFC 3412 Section 6.2: use fresh msgID for each transmission attempt
418            let msg_id = self.next_request_id();
419            let data = self.build_v3_message(&pdu, msg_id)?;
420
421            tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?pdu.pdu_type, snmp.varbind_count = pdu.varbinds.len(), snmp.msg_id = msg_id }, "sending V3 {} request", pdu.pdu_type);
422            tracing::trace!(target: "async_snmp::client", { snmp.bytes = data.len() }, "sending V3 request");
423
424            // Register (or re-register) with fresh deadline before sending
425            self.inner
426                .transport
427                .register_request(msg_id, self.inner.config.timeout);
428
429            // Send request
430            self.inner.transport.send(&data).await?;
431
432            // Wait for response (deadline was set by register_request)
433            match self.inner.transport.recv(msg_id).await {
434                Ok((response_data, _source)) => {
435                    tracing::trace!(target: "async_snmp::client", { snmp.bytes = response_data.len() }, "received V3 response");
436
437                    // Verify authentication if required
438                    if security_level.requires_auth() {
439                        tracing::trace!(target: "async_snmp::client", "verifying HMAC authentication on response");
440
441                        let derived = self.inner.derived_keys.read().unwrap();
442                        let auth_key = derived
443                            .as_ref()
444                            .and_then(|d| d.auth_key.as_ref())
445                            .ok_or_else(|| {
446                                tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %AuthErrorKind::NoAuthKey }, "authentication failed");
447                                Error::Auth {
448                                    target: self.peer_addr(),
449                                }
450                                .boxed()
451                            })?;
452
453                        if let Some((offset, len)) =
454                            UsmSecurityParams::find_auth_params_offset(&response_data)
455                        {
456                            if !verify_message(auth_key, &response_data, offset, len) {
457                                tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %AuthErrorKind::HmacMismatch }, "authentication failed");
458                                return Err(Error::Auth {
459                                    target: self.peer_addr(),
460                                }
461                                .boxed());
462                            }
463                            tracing::trace!(target: "async_snmp::client", { auth_params_offset = offset, auth_params_len = len }, "HMAC verification successful");
464                        } else {
465                            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %AuthErrorKind::AuthParamsNotFound }, "authentication failed");
466                            return Err(Error::Auth {
467                                target: self.peer_addr(),
468                            }
469                            .boxed());
470                        }
471                    }
472
473                    // Decode response
474                    let response = V3Message::decode(response_data.clone())?;
475
476                    // Check for Report PDU (error response)
477                    if let Some(scoped_pdu) = response.scoped_pdu()
478                        && scoped_pdu.pdu.pdu_type == PduType::Report
479                    {
480                        // Check for time window error - resync and retry
481                        if is_not_in_time_window_report(&scoped_pdu.pdu) {
482                            tracing::debug!(target: "async_snmp::client", "not in time window, resyncing");
483                            // Update engine time from response
484                            let usm_params =
485                                UsmSecurityParams::decode(response.security_params.clone())?;
486                            {
487                                let mut state = self.inner.engine_state.write().unwrap();
488                                if let Some(ref mut s) = *state {
489                                    s.update_time(usm_params.engine_boots, usm_params.engine_time);
490                                }
491                            }
492                            last_error = Some(
493                                Error::Auth {
494                                    target: self.peer_addr(),
495                                }
496                                .boxed(),
497                            );
498                            // Apply backoff delay before retry (if not last attempt)
499                            if attempt < max_attempts {
500                                let delay = self.inner.config.retry.compute_delay(attempt);
501                                if !delay.is_zero() {
502                                    tracing::debug!(target: "async_snmp::client", { delay_ms = delay.as_millis() as u64 }, "backing off");
503                                    tokio::time::sleep(delay).await;
504                                }
505                            }
506                            continue;
507                        }
508
509                        // Check for unknown engine ID
510                        if is_unknown_engine_id_report(&scoped_pdu.pdu) {
511                            tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr() }, "unknown engine ID");
512                            return Err(Error::Auth {
513                                target: self.peer_addr(),
514                            }
515                            .boxed());
516                        }
517
518                        // Other Report errors
519                        return Err(Error::Snmp {
520                            target: self.peer_addr(),
521                            status: ErrorStatus::GenErr,
522                            index: 0,
523                            oid: scoped_pdu.pdu.varbinds.first().map(|vb| vb.oid.clone()),
524                        }
525                        .boxed());
526                    }
527
528                    // Extract security params before consuming response
529                    let response_security_params = response.security_params.clone();
530
531                    // Handle encrypted response
532                    let response_pdu = if security_level.requires_priv() {
533                        match response.data {
534                            crate::message::V3MessageData::Encrypted(ciphertext) => {
535                                tracing::trace!(target: "async_snmp::client", { ciphertext_len = ciphertext.len() }, "decrypting response");
536
537                                // Decrypt
538                                let derived = self.inner.derived_keys.read().unwrap();
539                                let priv_key = derived
540                                    .as_ref()
541                                    .and_then(|d| d.priv_key.as_ref())
542                                    .ok_or_else(|| {
543                                    tracing::warn!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %CryptoErrorKind::NoPrivKey }, "decryption failed");
544                                    Error::Auth {
545                                        target: self.peer_addr(),
546                                    }
547                                    .boxed()
548                                })?;
549
550                                let usm_params =
551                                    UsmSecurityParams::decode(response_security_params.clone())?;
552                                let plaintext = priv_key
553                                    .decrypt(
554                                        &ciphertext,
555                                        usm_params.engine_boots,
556                                        usm_params.engine_time,
557                                        &usm_params.priv_params,
558                                    )
559                                    .map_err(|e| {
560                                        tracing::warn!(target: "async_snmp::crypto", { peer = %self.peer_addr(), error = %e }, "decryption failed");
561                                        Error::Auth {
562                                            target: self.peer_addr(),
563                                        }
564                                        .boxed()
565                                    })?;
566
567                                tracing::trace!(target: "async_snmp::client", { plaintext_len = plaintext.len() }, "decrypted response");
568
569                                // Decode scoped PDU
570                                let mut decoder = Decoder::with_target(plaintext, self.peer_addr());
571                                let scoped_pdu = ScopedPdu::decode(&mut decoder)?;
572                                scoped_pdu.pdu
573                            }
574                            crate::message::V3MessageData::Plaintext(scoped_pdu) => scoped_pdu.pdu,
575                        }
576                    } else {
577                        response.into_pdu().ok_or_else(|| {
578                            tracing::debug!(target: "async_snmp::client", { peer = %self.peer_addr(), kind = %DecodeErrorKind::MissingPdu }, "missing PDU in response");
579                            Error::MalformedResponse {
580                                target: self.peer_addr(),
581                            }
582                            .boxed()
583                        })?
584                    };
585
586                    // Validate request ID
587                    if response_pdu.request_id != pdu.request_id {
588                        tracing::warn!(target: "async_snmp::client", { expected_request_id = pdu.request_id, actual_request_id = response_pdu.request_id, peer = %self.peer_addr() }, "request ID mismatch in response");
589                        return Err(Error::MalformedResponse {
590                            target: self.peer_addr(),
591                        }
592                        .boxed());
593                    }
594
595                    tracing::debug!(target: "async_snmp::client", { snmp.pdu_type = ?response_pdu.pdu_type, snmp.varbind_count = response_pdu.varbinds.len(), snmp.error_status = response_pdu.error_status, snmp.error_index = response_pdu.error_index }, "received V3 {} response", response_pdu.pdu_type);
596
597                    // Update engine time from successful response
598                    {
599                        let usm_params = UsmSecurityParams::decode(response_security_params)?;
600                        let mut state = self.inner.engine_state.write().unwrap();
601                        if let Some(ref mut s) = *state {
602                            s.update_time(usm_params.engine_boots, usm_params.engine_time);
603                        }
604                    }
605
606                    // Check for SNMP error
607                    if response_pdu.is_error() {
608                        let status = response_pdu.error_status_enum();
609                        // error_index is 1-based; 0 means error applies to PDU, not a specific varbind
610                        let oid = (response_pdu.error_index as usize)
611                            .checked_sub(1)
612                            .and_then(|idx| response_pdu.varbinds.get(idx))
613                            .map(|vb| vb.oid.clone());
614
615                        Span::current()
616                            .record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
617                        return Err(Error::Snmp {
618                            target: self.peer_addr(),
619                            status,
620                            index: response_pdu.error_index.max(0) as u32,
621                            oid,
622                        }
623                        .boxed());
624                    }
625
626                    Span::current().record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
627                    return Ok(response_pdu);
628                }
629                Err(e) if matches!(*e, Error::Timeout { .. }) => {
630                    last_error = Some(e);
631                    // Apply backoff delay before next retry (if not last attempt)
632                    if attempt < max_attempts {
633                        let delay = self.inner.config.retry.compute_delay(attempt);
634                        if !delay.is_zero() {
635                            tracing::debug!(target: "async_snmp::client", { delay_ms = delay.as_millis() as u64 }, "backing off");
636                            tokio::time::sleep(delay).await;
637                        }
638                    }
639                    continue;
640                }
641                Err(e) => {
642                    Span::current().record("snmp.elapsed_ms", start.elapsed().as_millis() as u64);
643                    return Err(e);
644                }
645            }
646        }
647
648        // All retries exhausted
649        let elapsed = start.elapsed();
650        Span::current().record("snmp.elapsed_ms", elapsed.as_millis() as u64);
651        tracing::debug!(target: "async_snmp::client", { request_id = pdu.request_id, peer = %self.peer_addr(), ?elapsed, retries = max_attempts }, "request timed out");
652        Err(last_error.unwrap_or_else(|| {
653            Error::Timeout {
654                target: self.peer_addr(),
655                elapsed,
656                retries: max_attempts,
657            }
658            .boxed()
659        }))
660    }
661}