runtara_sdk/
client.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Main SDK client for instance communication with runtara-core.
4
5use std::time::Duration;
6#[cfg(feature = "quic")]
7use std::time::Instant;
8
9use tracing::{info, instrument};
10
11#[cfg(feature = "quic")]
12use runtara_protocol::instance_proto::{
13    self as proto, PollSignalsRequest, RpcRequest, RpcResponse, SignalAck, rpc_request,
14    rpc_response,
15};
16
17use crate::backend::SdkBackend;
18#[cfg(feature = "quic")]
19use crate::config::SdkConfig;
20use crate::error::Result;
21#[cfg(feature = "quic")]
22use crate::error::SdkError;
23#[cfg(feature = "quic")]
24use crate::signals::from_proto_signal;
25use crate::types::{CheckpointResult, StatusResponse};
26#[cfg(feature = "quic")]
27use crate::types::{Signal, SignalType};
28#[cfg(feature = "quic")]
29use tracing::debug;
30
31/// High-level SDK client for instance communication with runtara-core.
32///
33/// This client wraps a backend (QUIC or embedded) and provides ergonomic methods
34/// for all instance lifecycle operations.
35///
36/// # Example (QUIC mode)
37///
38/// ```ignore
39/// use runtara_sdk::RuntaraSdk;
40///
41/// let mut sdk = RuntaraSdk::localhost("my-instance", "my-tenant")?;
42/// sdk.connect().await?;
43/// sdk.register(None).await?;
44///
45/// // Process items with checkpointing
46/// for i in 0..items.len() {
47///     let state = serde_json::to_vec(&my_state)?;
48///     if let Some(existing) = sdk.checkpoint(&format!("item-{}", i), &state).await? {
49///         // Resuming - restore state and skip
50///         my_state = serde_json::from_slice(&existing)?;
51///         continue;
52///     }
53///     // Fresh execution - process item
54///     process_item(&items[i]);
55/// }
56///
57/// sdk.completed(b"result").await?;
58/// ```
59///
60/// # Example (Embedded mode)
61///
62/// ```ignore
63/// use runtara_sdk::RuntaraSdk;
64/// use std::sync::Arc;
65///
66/// // Create persistence layer (e.g., SQLite or PostgreSQL)
67/// let persistence: Arc<dyn Persistence> = create_persistence().await?;
68///
69/// let mut sdk = RuntaraSdk::embedded(persistence, "my-instance", "my-tenant");
70/// sdk.connect().await?;  // No-op for embedded
71/// sdk.register(None).await?;
72///
73/// // Same checkpoint API works with embedded mode
74/// for i in 0..items.len() {
75///     let state = serde_json::to_vec(&my_state)?;
76///     let result = sdk.checkpoint(&format!("item-{}", i), &state).await?;
77///     // ...
78/// }
79///
80/// sdk.completed(b"result").await?;
81/// ```
82pub struct RuntaraSdk {
83    /// Backend implementation (QUIC or embedded)
84    backend: Box<dyn SdkBackend>,
85    /// Registration state
86    registered: bool,
87    /// Last signal poll time (for rate limiting) - only used with QUIC
88    #[cfg(feature = "quic")]
89    last_signal_poll: Instant,
90    /// Cached pending signal (if any) - only used with QUIC
91    #[cfg(feature = "quic")]
92    pending_signal: Option<Signal>,
93    /// Signal poll interval (ms) - only used with QUIC
94    #[cfg(feature = "quic")]
95    signal_poll_interval_ms: u64,
96    /// Background heartbeat interval (ms). 0 = disabled.
97    heartbeat_interval_ms: u64,
98}
99
100impl RuntaraSdk {
101    // ========== QUIC Construction ==========
102
103    /// Create a new SDK instance with the given configuration.
104    ///
105    /// This creates a QUIC-based SDK that connects to runtara-core over the network.
106    #[cfg(feature = "quic")]
107    pub fn new(config: SdkConfig) -> Result<Self> {
108        use crate::backend::quic::QuicBackend;
109
110        let signal_poll_interval_ms = config.signal_poll_interval_ms;
111        let heartbeat_interval_ms = config.heartbeat_interval_ms;
112        let backend = QuicBackend::new(&config)?;
113
114        Ok(Self {
115            backend: Box::new(backend),
116            registered: false,
117            last_signal_poll: Instant::now() - Duration::from_secs(60), // Allow immediate first poll
118            pending_signal: None,
119            signal_poll_interval_ms,
120            heartbeat_interval_ms,
121        })
122    }
123
124    /// Create an SDK instance from environment variables.
125    ///
126    /// See [`SdkConfig::from_env`] for required and optional environment variables.
127    #[cfg(feature = "quic")]
128    pub fn from_env() -> Result<Self> {
129        let config = SdkConfig::from_env()?;
130        Self::new(config)
131    }
132
133    /// Create an SDK instance for local development.
134    ///
135    /// This connects to `127.0.0.1:8001` with TLS verification disabled.
136    #[cfg(feature = "quic")]
137    pub fn localhost(instance_id: impl Into<String>, tenant_id: impl Into<String>) -> Result<Self> {
138        let config = SdkConfig::localhost(instance_id, tenant_id);
139        Self::new(config)
140    }
141
142    // ========== Embedded Construction ==========
143
144    /// Create an embedded SDK instance with direct database access.
145    ///
146    /// This bypasses QUIC and communicates directly with the persistence layer.
147    /// Ideal for embedding runtara-core within the same process.
148    ///
149    /// Note: Signals and durable sleep are not supported in embedded mode.
150    #[cfg(feature = "embedded")]
151    pub fn embedded(
152        persistence: std::sync::Arc<dyn runtara_core::persistence::Persistence>,
153        instance_id: impl Into<String>,
154        tenant_id: impl Into<String>,
155    ) -> Self {
156        use crate::backend::embedded::EmbeddedBackend;
157
158        let backend = EmbeddedBackend::new(persistence, instance_id, tenant_id);
159
160        Self {
161            backend: Box::new(backend),
162            registered: false,
163            #[cfg(feature = "quic")]
164            last_signal_poll: Instant::now() - Duration::from_secs(60),
165            #[cfg(feature = "quic")]
166            pending_signal: None,
167            #[cfg(feature = "quic")]
168            signal_poll_interval_ms: 1_000,
169            heartbeat_interval_ms: 30_000,
170        }
171    }
172
173    // ========== Initialization ==========
174
175    /// Initialize SDK: connect, register, and make available globally for #[durable].
176    ///
177    /// This is a convenience method that combines:
178    /// 1. `connect()` - establish connection to runtara-core
179    /// 2. `register(checkpoint_id)` - register this instance
180    /// 3. `register_sdk()` - make SDK available globally for #[durable] functions
181    ///
182    /// # Example
183    ///
184    /// ```ignore
185    /// use runtara_sdk::RuntaraSdk;
186    ///
187    /// #[tokio::main]
188    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
189    ///     // One-liner setup for #[durable] functions
190    ///     RuntaraSdk::localhost("my-instance", "my-tenant")?
191    ///         .init(None)
192    ///         .await?;
193    ///
194    ///     // Now #[durable] functions work automatically
195    ///     my_durable_function("key".to_string(), args).await?;
196    ///     Ok(())
197    /// }
198    /// ```
199    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
200    pub async fn init(mut self, checkpoint_id: Option<&str>) -> Result<()> {
201        self.connect().await?;
202        self.register(checkpoint_id).await?;
203        crate::register_sdk(self);
204        info!("SDK initialized globally");
205        Ok(())
206    }
207
208    // ========== Connection ==========
209
210    /// Connect to runtara-core.
211    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
212    pub async fn connect(&self) -> Result<()> {
213        info!("Connecting to runtara-core");
214        self.backend.connect().await?;
215        info!("Connected to runtara-core");
216        Ok(())
217    }
218
219    /// Check if connected to runtara-core.
220    pub async fn is_connected(&self) -> bool {
221        self.backend.is_connected().await
222    }
223
224    /// Close the connection to runtara-core.
225    pub async fn close(&self) {
226        self.backend.close().await;
227    }
228
229    // ========== Registration ==========
230
231    /// Register this instance with runtara-core.
232    ///
233    /// This should be called at instance startup. If `checkpoint_id` is provided,
234    /// the instance is resuming from a checkpoint.
235    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
236    pub async fn register(&mut self, checkpoint_id: Option<&str>) -> Result<()> {
237        self.backend.register(checkpoint_id).await?;
238        self.registered = true;
239        info!("Instance registered");
240        Ok(())
241    }
242
243    // ========== Checkpointing ==========
244
245    /// Checkpoint with the given ID and state.
246    ///
247    /// This is the primary checkpoint method that handles both save and resume:
248    /// - If a checkpoint with this ID already exists, returns the existing state (for resume)
249    /// - If no checkpoint exists, saves the provided state and returns None
250    ///
251    /// This also serves as a heartbeat - each checkpoint call reports progress to runtara-core.
252    ///
253    /// The returned [`CheckpointResult`] also includes any pending signal (cancel, pause)
254    /// that the instance should handle after processing the checkpoint.
255    ///
256    /// # Example
257    /// ```ignore
258    /// // In a loop - checkpoint handles both fresh runs and resumes
259    /// for i in 0..items.len() {
260    ///     let checkpoint_id = format!("item-{}", i);
261    ///     let result = sdk.checkpoint(&checkpoint_id, &state).await?;
262    ///
263    ///     // Check for pending signals
264    ///     if result.should_cancel() {
265    ///         return Err("Cancelled".into());
266    ///     }
267    ///     if result.should_pause() {
268    ///         // Exit cleanly - will be resumed later
269    ///         return Ok(());
270    ///     }
271    ///
272    ///     if let Some(existing_state) = result.existing_state() {
273    ///         // Resuming - restore state and skip already-processed work
274    ///         state = serde_json::from_slice(existing_state)?;
275    ///         continue;
276    ///     }
277    ///     // Fresh execution - process item
278    ///     process_item(&items[i]);
279    /// }
280    /// ```
281    #[instrument(skip(self, state), fields(instance_id = %self.backend.instance_id(), checkpoint_id = %checkpoint_id, state_size = state.len()))]
282    pub async fn checkpoint(&self, checkpoint_id: &str, state: &[u8]) -> Result<CheckpointResult> {
283        self.backend.checkpoint(checkpoint_id, state).await
284    }
285
286    /// Get a checkpoint by ID without saving (read-only lookup).
287    ///
288    /// Returns the checkpoint state if found, or None if not found.
289    /// Use this when you want to check if a cached result exists before executing.
290    ///
291    /// # Example
292    /// ```ignore
293    /// // Check if result is already cached
294    /// if let Some(cached_state) = sdk.get_checkpoint("my-operation").await? {
295    ///     let result: MyResult = serde_json::from_slice(&cached_state)?;
296    ///     return Ok(result);
297    /// }
298    /// // Not cached - execute operation and save result
299    /// let result = do_expensive_operation();
300    /// let state = serde_json::to_vec(&result)?;
301    /// sdk.checkpoint("my-operation", &state).await?;
302    /// ```
303    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id(), checkpoint_id = %checkpoint_id))]
304    pub async fn get_checkpoint(&self, checkpoint_id: &str) -> Result<Option<Vec<u8>>> {
305        self.backend.get_checkpoint(checkpoint_id).await
306    }
307
308    // ========== Sleep/Wake ==========
309
310    /// Request to sleep for the specified duration.
311    ///
312    /// This is a durable sleep that persists across restarts:
313    /// - Saves a checkpoint with the provided state
314    /// - Records the wake time (`sleep_until`) in the database
315    /// - On resume, calculates remaining time and only sleeps for the remainder
316    ///
317    /// In QUIC mode, the server tracks the wake time. In embedded mode, the
318    /// persistence layer tracks it directly.
319    #[instrument(skip(self, state), fields(instance_id = %self.backend.instance_id(), duration_ms = duration.as_millis() as u64))]
320    pub async fn sleep(&self, duration: Duration, checkpoint_id: &str, state: &[u8]) -> Result<()> {
321        self.backend
322            .durable_sleep(duration, checkpoint_id, state)
323            .await
324    }
325
326    // ========== Events ==========
327
328    /// Send a heartbeat event (simple "I'm alive" signal).
329    ///
330    /// Use this for progress reporting without checkpointing.
331    /// For durable progress, use `checkpoint()` instead.
332    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
333    pub async fn heartbeat(&self) -> Result<()> {
334        self.backend.heartbeat().await
335    }
336
337    /// Send a completed event with output.
338    #[instrument(skip(self, output), fields(instance_id = %self.backend.instance_id(), output_size = output.len()))]
339    pub async fn completed(&self, output: &[u8]) -> Result<()> {
340        self.backend.completed(output).await
341    }
342
343    /// Send a failed event with error message.
344    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
345    pub async fn failed(&self, error: &str) -> Result<()> {
346        self.backend.failed(error).await
347    }
348
349    /// Send a suspended event.
350    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
351    pub async fn suspended(&self) -> Result<()> {
352        self.backend.suspended().await
353    }
354
355    /// Send a custom event with arbitrary subtype and payload.
356    ///
357    /// This is a fire-and-forget event stored by runtara-core with the given subtype.
358    /// Core treats the subtype as an opaque string without any semantic interpretation.
359    ///
360    /// # Arguments
361    ///
362    /// * `subtype` - Arbitrary event subtype string
363    /// * `payload` - Event payload as raw bytes (typically JSON serialized)
364    ///
365    /// # Example
366    ///
367    /// ```ignore
368    /// let payload = serde_json::to_vec(&my_event_data)?;
369    /// sdk.custom_event("my_custom_event", payload).await?;
370    /// ```
371    #[instrument(skip(self, payload), fields(instance_id = %self.backend.instance_id(), subtype = %subtype))]
372    pub async fn custom_event(&self, subtype: &str, payload: Vec<u8>) -> Result<()> {
373        self.backend.send_custom_event(subtype, payload).await
374    }
375
376    // ========== Signals (QUIC only) ==========
377
378    /// Poll for pending signals.
379    ///
380    /// Rate-limited to avoid hammering the server.
381    /// Returns `Some(Signal)` if a signal is pending, `None` otherwise.
382    ///
383    /// Note: Only available with QUIC backend.
384    #[cfg(feature = "quic")]
385    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
386    pub async fn poll_signal(&mut self) -> Result<Option<Signal>> {
387        // Check cached signal first
388        if self.pending_signal.is_some() {
389            return Ok(self.pending_signal.take());
390        }
391
392        // Rate limit
393        let poll_interval = Duration::from_millis(self.signal_poll_interval_ms);
394        if self.last_signal_poll.elapsed() < poll_interval {
395            return Ok(None);
396        }
397
398        self.poll_signal_now().await
399    }
400
401    /// Force poll for signals (ignoring rate limit).
402    ///
403    /// Note: Only available with QUIC backend.
404    #[cfg(feature = "quic")]
405    pub async fn poll_signal_now(&mut self) -> Result<Option<Signal>> {
406        use crate::backend::quic::QuicBackend;
407
408        self.last_signal_poll = Instant::now();
409
410        let backend = self
411            .backend
412            .as_any()
413            .downcast_ref::<QuicBackend>()
414            .ok_or_else(|| SdkError::Internal("poll_signal() requires QUIC backend".to_string()))?;
415
416        let request = PollSignalsRequest {
417            instance_id: self.backend.instance_id().to_string(),
418            checkpoint_id: None,
419        };
420
421        let rpc_request = RpcRequest {
422            request: Some(rpc_request::Request::PollSignals(request)),
423        };
424
425        let rpc_response: RpcResponse = backend.client().request(&rpc_request).await?;
426
427        match rpc_response.response {
428            Some(rpc_response::Response::PollSignals(resp)) => {
429                if let Some(signal) = resp.signal {
430                    let sdk_signal = from_proto_signal(signal);
431                    debug!(signal_type = ?sdk_signal.signal_type, "Signal received");
432                    return Ok(Some(sdk_signal));
433                }
434
435                if let Some(custom) = resp.custom_signal {
436                    let sdk_signal = Signal {
437                        signal_type: SignalType::Resume, // custom signals are scoped; type unused here
438                        payload: custom.payload,
439                        checkpoint_id: Some(custom.checkpoint_id),
440                    };
441                    debug!("Custom signal received for checkpoint");
442                    return Ok(Some(sdk_signal));
443                }
444
445                Ok(None)
446            }
447            Some(rpc_response::Response::Error(e)) => Err(SdkError::Server {
448                code: e.code,
449                message: e.message,
450            }),
451            _ => Err(SdkError::UnexpectedResponse(
452                "expected PollSignalsResponse".to_string(),
453            )),
454        }
455    }
456
457    /// Acknowledge a received signal.
458    ///
459    /// Note: Only available with QUIC backend.
460    #[cfg(feature = "quic")]
461    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
462    pub async fn acknowledge_signal(
463        &self,
464        signal_type: SignalType,
465        acknowledged: bool,
466    ) -> Result<()> {
467        use crate::backend::quic::QuicBackend;
468
469        let backend = self
470            .backend
471            .as_any()
472            .downcast_ref::<QuicBackend>()
473            .ok_or_else(|| {
474                SdkError::Internal("acknowledge_signal() requires QUIC backend".to_string())
475            })?;
476
477        let request = SignalAck {
478            instance_id: self.backend.instance_id().to_string(),
479            signal_type: proto::SignalType::from(signal_type).into(),
480            acknowledged,
481        };
482
483        let rpc_request = RpcRequest {
484            request: Some(rpc_request::Request::SignalAck(request)),
485        };
486
487        backend.client().send_fire_and_forget(&rpc_request).await?;
488        debug!("Signal acknowledged");
489        Ok(())
490    }
491
492    /// Check for cancellation and return error if cancelled.
493    ///
494    /// Convenience method for use in execution loops:
495    /// ```ignore
496    /// for item in items {
497    ///     sdk.check_cancelled().await?;
498    ///     // process item...
499    /// }
500    /// ```
501    ///
502    /// Note: Only available with QUIC backend.
503    #[cfg(feature = "quic")]
504    pub async fn check_cancelled(&mut self) -> Result<()> {
505        if let Some(signal) = self.poll_signal().await? {
506            if signal.signal_type == SignalType::Cancel {
507                return Err(SdkError::Cancelled);
508            }
509            // Cache non-cancel signals for later
510            self.pending_signal = Some(signal);
511        }
512        Ok(())
513    }
514
515    /// Check for pause and return error if paused.
516    ///
517    /// Note: Only available with QUIC backend.
518    #[cfg(feature = "quic")]
519    pub async fn check_paused(&mut self) -> Result<()> {
520        if let Some(signal) = self.poll_signal().await? {
521            if signal.signal_type == SignalType::Pause {
522                return Err(SdkError::Paused);
523            }
524            // Cache non-pause signals for later
525            self.pending_signal = Some(signal);
526        }
527        Ok(())
528    }
529
530    // ========== Retry Tracking ==========
531
532    /// Record a retry attempt for audit trail.
533    ///
534    /// This is a fire-and-forget operation that records a retry attempt
535    /// in the checkpoint history. Called by the `#[durable]` macro when
536    /// a function fails and is about to be retried.
537    ///
538    /// # Arguments
539    ///
540    /// * `checkpoint_id` - The durable function's cache key
541    /// * `attempt_number` - The 1-indexed retry attempt number
542    /// * `error_message` - Error message from the previous failed attempt
543    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id(), checkpoint_id = %checkpoint_id, attempt = attempt_number))]
544    pub async fn record_retry_attempt(
545        &self,
546        checkpoint_id: &str,
547        attempt_number: u32,
548        error_message: Option<&str>,
549    ) -> Result<()> {
550        self.backend
551            .record_retry_attempt(checkpoint_id, attempt_number, error_message)
552            .await
553    }
554
555    // ========== Status ==========
556
557    /// Get the current status of this instance.
558    #[instrument(skip(self), fields(instance_id = %self.backend.instance_id()))]
559    pub async fn get_status(&self) -> Result<StatusResponse> {
560        self.backend.get_status().await
561    }
562
563    /// Get the status of another instance.
564    ///
565    /// Note: Only available with QUIC backend.
566    #[cfg(feature = "quic")]
567    pub async fn get_instance_status(&self, instance_id: &str) -> Result<StatusResponse> {
568        use crate::backend::quic::QuicBackend;
569        use runtara_protocol::instance_proto::GetInstanceStatusRequest;
570
571        let backend = self
572            .backend
573            .as_any()
574            .downcast_ref::<QuicBackend>()
575            .ok_or_else(|| {
576                SdkError::Internal("get_instance_status() requires QUIC backend".to_string())
577            })?;
578
579        let request = GetInstanceStatusRequest {
580            instance_id: instance_id.to_string(),
581        };
582
583        let rpc_request = RpcRequest {
584            request: Some(rpc_request::Request::GetInstanceStatus(request)),
585        };
586
587        let rpc_response: RpcResponse = backend.client().request(&rpc_request).await?;
588
589        match rpc_response.response {
590            Some(rpc_response::Response::GetInstanceStatus(resp)) => Ok(StatusResponse::from(resp)),
591            Some(rpc_response::Response::Error(e)) => Err(SdkError::Server {
592                code: e.code,
593                message: e.message,
594            }),
595            _ => Err(SdkError::UnexpectedResponse(
596                "expected GetInstanceStatusResponse".to_string(),
597            )),
598        }
599    }
600
601    // ========== Helpers ==========
602
603    /// Get the instance ID.
604    pub fn instance_id(&self) -> &str {
605        self.backend.instance_id()
606    }
607
608    /// Get the tenant ID.
609    pub fn tenant_id(&self) -> &str {
610        self.backend.tenant_id()
611    }
612
613    /// Check if the instance is registered.
614    pub fn is_registered(&self) -> bool {
615        self.registered
616    }
617
618    /// Get the configured heartbeat interval in milliseconds.
619    /// Returns 0 if automatic heartbeats are disabled.
620    pub fn heartbeat_interval_ms(&self) -> u64 {
621        self.heartbeat_interval_ms
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    #[cfg(feature = "quic")]
628    use super::*;
629
630    #[cfg(feature = "quic")]
631    #[test]
632    fn test_sdk_creation() {
633        // Note: This test may fail if the UDP socket cannot be bound (e.g., in sandboxed environments)
634        let sdk = RuntaraSdk::localhost("test-instance", "test-tenant");
635
636        // If we can't create the SDK, just skip the test assertions
637        if let Ok(sdk) = sdk {
638            assert_eq!(sdk.instance_id(), "test-instance");
639            assert_eq!(sdk.tenant_id(), "test-tenant");
640            assert!(!sdk.is_registered());
641        }
642    }
643
644    #[cfg(feature = "quic")]
645    #[test]
646    fn test_config_creation() {
647        let config = SdkConfig::localhost("test-instance", "test-tenant");
648        assert_eq!(config.instance_id, "test-instance");
649        assert_eq!(config.tenant_id, "test-tenant");
650        assert!(config.skip_cert_verification);
651    }
652
653    #[cfg(feature = "quic")]
654    #[test]
655    fn test_sdk_with_custom_config() {
656        let config = SdkConfig {
657            instance_id: "custom-instance".to_string(),
658            tenant_id: "custom-tenant".to_string(),
659            server_addr: "127.0.0.1:9999".parse().unwrap(),
660            server_name: "custom-server".to_string(),
661            skip_cert_verification: true,
662            request_timeout_ms: 5000,
663            connect_timeout_ms: 3000,
664            signal_poll_interval_ms: 500,
665            heartbeat_interval_ms: 30000,
666        };
667
668        // May fail in sandboxed environments
669        if let Ok(sdk) = RuntaraSdk::new(config) {
670            assert_eq!(sdk.instance_id(), "custom-instance");
671            assert_eq!(sdk.tenant_id(), "custom-tenant");
672        }
673    }
674
675    #[cfg(feature = "quic")]
676    #[test]
677    fn test_sdk_localhost_sets_defaults() {
678        // May fail in sandboxed environments
679        if let Ok(sdk) = RuntaraSdk::localhost("inst", "tenant") {
680            assert!(!sdk.is_registered());
681            assert_eq!(sdk.instance_id(), "inst");
682        }
683    }
684
685    #[cfg(feature = "quic")]
686    #[test]
687    fn test_sdk_config_defaults() {
688        let config = SdkConfig::localhost("a", "b");
689        assert_eq!(config.request_timeout_ms, 30_000);
690        assert_eq!(config.connect_timeout_ms, 10_000);
691        assert_eq!(config.signal_poll_interval_ms, 1_000);
692    }
693
694    #[cfg(feature = "quic")]
695    #[test]
696    fn test_sdk_config_with_string_types() {
697        // Test that String types work as well as &str
698        let config = SdkConfig::localhost(String::from("instance"), String::from("tenant"));
699        assert_eq!(config.instance_id, "instance");
700        assert_eq!(config.tenant_id, "tenant");
701    }
702
703    #[cfg(feature = "quic")]
704    #[test]
705    fn test_sdk_initial_state() {
706        if let Ok(sdk) = RuntaraSdk::localhost("test", "test") {
707            // SDK should start unregistered
708            assert!(!sdk.is_registered());
709            // pending_signal should be None
710            assert!(sdk.pending_signal.is_none());
711        }
712    }
713}