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