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}