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}