ff_sdk/lib.rs
1//! FlowFabric Worker SDK — public API for worker authors.
2//!
3//! This crate depends on `ff-script` for the Lua-function types, Lua error
4//! kinds (`ScriptError`), and retry helpers (`is_retryable_kind`,
5//! `kind_to_stable_str`). Consumers using `ff-sdk` do not need to import
6//! `ff-script` directly for normal worker operations, but can if they need
7//! the `ScriptError` or retry types.
8//!
9//! # Quick start
10//!
11//! The production claim path is
12//! [`FlowFabricWorker::claim_from_grant`]: obtain a
13//! [`ClaimGrant`] from `ff_scheduler::Scheduler::claim_for_worker`
14//! (the scheduler enforces budget, quota, and capability checks),
15//! then hand it to the SDK. `claim_next` is gated behind the
16//! default-off `direct-valkey-claim` feature and bypasses admission
17//! control — fine for benchmarks, not production.
18//!
19//! ```rust,ignore
20//! use ff_sdk::{FlowFabricWorker, WorkerConfig};
21//! use ff_core::backend::BackendConfig;
22//! use ff_core::types::{LaneId, Namespace, WorkerId, WorkerInstanceId};
23//!
24//! #[tokio::main]
25//! async fn main() -> Result<(), ff_sdk::SdkError> {
26//! let config = WorkerConfig {
27//! backend: Some(BackendConfig::valkey("localhost", 6379)),
28//! worker_id: WorkerId::new("my-worker"),
29//! worker_instance_id: WorkerInstanceId::new("my-worker-instance-1"),
30//! namespace: Namespace::new("default"),
31//! lanes: vec![LaneId::new("main")],
32//! capabilities: Vec::new(),
33//! lease_ttl_ms: 30_000,
34//! claim_poll_interval_ms: 1_000,
35//! max_concurrent_tasks: 1,
36//! partition_config: None,
37//! };
38//!
39//! let worker = FlowFabricWorker::connect(config).await?;
40//! let lane = LaneId::new("main");
41//!
42//! // In a real deployment `grant` is obtained from the
43//! // scheduler's `claim_for_worker` RPC/helper; it carries the
44//! // execution id, capability match, and admission result.
45//! # let grant: ff_sdk::ClaimGrant = unimplemented!();
46//! let task = worker.claim_from_grant(lane, grant).await?;
47//! println!("claimed: {}", task.execution_id());
48//! // Process task...
49//! task.complete(Some(b"done".to_vec())).await?;
50//! Ok(())
51//! }
52//! ```
53//!
54//! # Migration: `direct-valkey-claim` → scheduler-issued grants
55//!
56//! The `direct-valkey-claim` cargo feature — which gates
57//! [`FlowFabricWorker::claim_next`] — is **deprecated** in favour of
58//! the pair of scheduler-issued grant entry points:
59//!
60//! * [`FlowFabricWorker::claim_from_grant`] — fresh claims. Use
61//! `ff_scheduler::Scheduler::claim_for_worker` to obtain the
62//! [`ClaimGrant`], then hand it to the SDK.
63//! * [`FlowFabricWorker::claim_from_resume_grant`] — resumed claims
64//! for an `attempt_interrupted` execution. Wraps a
65//! [`ResumeGrant`].
66//!
67//! `claim_next` bypasses budget and quota admission control; the
68//! grant-based path does not. See each method's rustdoc for the
69//! exact migration recipe.
70//!
71//! `ClaimGrant` / `ResumeGrant` / `ReclaimGrant` (and the
72//! `ClaimPolicy` / `ResumeToken` wire types on the scheduler-owner
73//! side) are re-exported from `ff-sdk` (#283) so consumers do not need
74//! a direct `ff-scheduler` dep just to type these signatures.
75//!
76//! [`ClaimGrant`]: crate::ClaimGrant
77//! [`ResumeGrant`]: crate::ResumeGrant
78
79// v0.12 PR-6: `admin` is always compiled. The one Valkey-typed helper
80// (`rotate_waitpoint_hmac_secret_all_partitions`, which takes a
81// `&ferriskey::Client`) stays item-gated behind `valkey-default`;
82// everything else in the module is transport-agnostic (reqwest +
83// ff-core contracts + serde).
84pub mod admin;
85pub mod config;
86pub mod engine_error;
87#[cfg(any(
88 feature = "layer-tracing",
89 feature = "layer-ratelimit",
90 feature = "layer-metrics",
91 feature = "layer-circuit-breaker",
92))]
93pub mod layer;
94// v0.12 PR-6: `snapshot` is always compiled. Every method routes
95// through `self.backend_ref().*` — no direct ferriskey usage.
96pub mod snapshot;
97// v0.12 PR-2: `task` is always compiled. Items that depend on
98// ferriskey / ff-backend-valkey / ff-core streaming+suspension types
99// are gated at the item level inside `task.rs`; the module itself is
100// reachable under `default-features = false, features = ["sqlite"]`
101// so consumers can name `ClaimedTask` as a type.
102pub mod task;
103// v0.14 P1.2 — signal-bridge helper extracted from the v0.13
104// external-callback example. Module is always compiled: every item it
105// names (`FlowFabricWorker::deliver_signal`, `Signal`, `SignalOutcome`,
106// `EngineBackend::read_waitpoint_token`) is available under
107// `--no-default-features --features sqlite` per RFC-023 Phase 1a.
108pub mod signal_bridge;
109// v0.12 PR-6: Valkey-specific connect preamble extracted from
110// `FlowFabricWorker::connect`. Gated behind `valkey-default` because
111// the body is all ferriskey `client.cmd(...)` + `client.hgetall(...)`.
112#[cfg(feature = "valkey-default")]
113pub(crate) mod valkey_preamble;
114// RFC-023 Phase 1a (§4.4 item 10): `worker` is always compiled.
115// The ferriskey-dependent methods inside it are `valkey-default`-
116// gated at the item level; the module itself no longer is, so
117// `FlowFabricWorker::connect_with` + the trait-forwarder accessors
118// are reachable under `default-features = false, features = ["sqlite"]`.
119pub mod worker;
120
121// Re-exports for convenience.
122//
123// v0.12 PR-6: the transport-agnostic admin surface (HTTP REST client +
124// request/response shapes + `PartitionRotationOutcome`) is reachable
125// under `--no-default-features --features sqlite`.
126// `rotate_waitpoint_hmac_secret_all_partitions` stays `valkey-default`-
127// gated — it takes a `&ferriskey::Client` and calls the
128// `ff_rotate_waitpoint_hmac_secret` FCALL directly. Option 1 of the
129// PR-6 plan: keep the helper in `admin.rs` item-gated to minimize
130// consumer blast radius; relocation to `ff-backend-valkey` is v0.13
131// scope if ever justified.
132pub use admin::{
133 FlowFabricAdminClient, IssueReclaimGrantRequest, IssueReclaimGrantResponse,
134 PartitionRotationOutcome, RotateWaitpointSecretRequest, RotateWaitpointSecretResponse,
135};
136#[cfg(feature = "valkey-default")]
137pub use admin::rotate_waitpoint_hmac_secret_all_partitions;
138// RFC-023 Phase 1a: re-export `SqliteBackend` so consumers using
139// `ff-sdk = { default-features = false, features = ["sqlite"] }` can
140// name it as `ff_sdk::SqliteBackend` without pinning the
141// `ff-backend-sqlite` crate directly. Also keeps the dep graph
142// `ff-backend-sqlite` edge reachable from ff-sdk's public API.
143#[cfg(feature = "sqlite")]
144pub use ff_backend_sqlite::SqliteBackend;
145pub use config::WorkerConfig;
146pub use engine_error::{
147 BugKind, ConflictKind, ContentionKind, EngineError, StateKind, ValidationKind,
148};
149// #88: backend-agnostic transport error surface. Consumers that
150// previously matched on `ferriskey::ErrorKind` via `valkey_kind()`
151// now match on `BackendErrorKind` via `backend_kind()`.
152pub use ff_core::engine_error::{BackendError, BackendErrorKind};
153// `FailOutcome` is ff-core-native (Stage 1a move); re-export
154// unconditionally so consumers can name `ff_sdk::FailOutcome` even
155// under `--no-default-features`.
156pub use ff_core::backend::FailOutcome;
157// `ResumeSignal` is also ff-core-native (Stage 0 move).
158pub use ff_core::backend::ResumeSignal;
159#[cfg(feature = "valkey-default")]
160pub use task::{
161 read_stream, tail_stream, tail_stream_with_visibility, AppendFrameOutcome, ClaimedTask,
162 Signal, SignalOutcome, StreamCursor, StreamFrames, SuspendedHandle, TrySuspendOutcome,
163 MAX_TAIL_BLOCK_MS, STREAM_READ_HARD_CAP,
164};
165// RFC-015 stream-durability-mode public surface. Re-exported so
166// consumers can name `ff_sdk::StreamMode` etc. alongside the older
167// `AppendFrameOutcome` / `StreamCursor` re-exports. Gated on
168// `valkey-default` because the worker surface that uses them is too.
169#[cfg(feature = "valkey-default")]
170pub use ff_core::backend::{
171 PatchKind, StreamMode, SummaryDocument, TailVisibility, SUMMARY_NULL_SENTINEL,
172};
173// RFC-013 Stage 1d — typed suspend surface lives in `ff_core::contracts`
174// and is reachable via `ff_sdk::*` too. `SuspendOutcome` path is
175// preserved via this re-export so `ff_sdk::SuspendOutcome` still
176// compiles.
177#[cfg(feature = "valkey-default")]
178pub use ff_core::contracts::{
179 CompositeBody, CountKind, IdempotencyKey, ResumeCondition, ResumePolicy, ResumeTarget,
180 SignalMatcher, SuspendArgs, SuspendOutcome, SuspendOutcomeDetails, SuspensionReasonCode,
181 SuspensionRequester, TimeoutBehavior, WaitpointBinding,
182};
183// #283 — Claim-flow wire types. Consumers typing `claim_from_grant` /
184// `claim_from_resume_grant` / `claim_from_reclaim_grant` signatures (or
185// wrapping `EngineBackend::claim_for_worker`) can name these as
186// `ff_sdk::ClaimGrant` etc. without pinning `ff-scheduler` directly.
187// `Scheduler` itself is intentionally not re-exported: implementing a
188// scheduler is specialized and stays behind the `ff-scheduler` dep.
189pub use ff_core::backend::{ClaimPolicy, ResumeToken};
190pub use ff_core::contracts::{ClaimGrant, ReclaimGrant, ResumeGrant};
191// RFC-023 Phase 1a (§4.4 item 10): re-export always reachable. The
192// Valkey-specific methods (`connect`, `claim_*`, `deliver_signal`)
193// are item-level cfg-gated; under sqlite-only features the type
194// exposes `connect_with`, `backend`, `completion_backend`, `config`,
195// and `partition_config`.
196pub use worker::FlowFabricWorker;
197
198/// SDK error type.
199#[derive(Debug, thiserror::Error)]
200pub enum SdkError {
201 /// Backend transport error. Previously wrapped `ferriskey::Error`
202 /// directly (#88); now carries a backend-agnostic
203 /// [`BackendError`] so consumers match on
204 /// [`BackendErrorKind`] instead of ferriskey's native taxonomy.
205 /// The ferriskey → [`BackendError`] mapping lives in
206 /// `ff_backend_valkey::backend_error::backend_error_from_ferriskey`.
207 #[error("backend: {0}")]
208 Backend(#[from] BackendError),
209
210 /// Backend error with additional context (e.g. call-site label).
211 /// Previously `ValkeyContext { source: ferriskey::Error }` (#88).
212 #[error("backend: {context}: {source}")]
213 BackendContext {
214 #[source]
215 source: BackendError,
216 context: String,
217 },
218
219 /// FlowFabric engine error — typed sum over Lua error codes + transport
220 /// faults. See [`EngineError`] for the variant-granularity contract.
221 /// Replaces the previous `Script(ScriptError)` carrier (#58.6).
222 ///
223 /// `Box`ed to keep `SdkError`'s stack footprint small: the richest
224 /// variant (`ConflictKind::DependencyAlreadyExists { existing:
225 /// EdgeSnapshot }`) is ~200 bytes. Boxing keeps `Result<T, SdkError>`
226 /// at the same width every other variant pays.
227 #[error("engine: {0}")]
228 Engine(Box<EngineError>),
229
230 /// Configuration error. `context` identifies the call site / logical
231 /// operation (e.g. `"describe_execution: exec_core"`, `"admin_client"`).
232 /// `field` names the specific offending field when the error is
233 /// field-scoped (e.g. `Some("public_state")`), or `None` for
234 /// whole-object validation (e.g. `"at least one lane is required"`).
235 /// `message` carries dynamic detail: source-error rendering, the
236 /// offending raw value, etc.
237 #[error("{}", fmt_config(.context, .field.as_deref(), .message))]
238 Config {
239 context: String,
240 field: Option<String>,
241 message: String,
242 },
243
244 /// Worker is at its configured `max_concurrent_tasks` capacity —
245 /// the caller should retry later. Returned by
246 /// [`FlowFabricWorker::claim_from_grant`] and
247 /// [`FlowFabricWorker::claim_from_reclaim_grant`] when the
248 /// concurrency semaphore is saturated. Distinct from `Ok(None)`:
249 /// a `ClaimGrant`/`ResumeGrant` represents real work already
250 /// selected by the scheduler, so silently dropping it would waste
251 /// the grant and let the grant TTL elapse. Surfacing the
252 /// saturation lets the caller release the grant (or wait +
253 /// retry).
254 ///
255 /// # Classification
256 ///
257 /// * [`SdkError::is_retryable`] returns `true` — saturation is
258 /// transient: any in-flight task's
259 /// complete/fail/cancel/drop releases a permit. Retry after
260 /// milliseconds, not a retry loop with backoff for a backend
261 /// transport failure.
262 /// * [`SdkError::backend_kind`] returns `None` — this is not a
263 /// backend transport error, so there is no
264 /// [`BackendErrorKind`] to inspect. Callers that fan out on
265 /// `backend_kind()` should match `WorkerAtCapacity` explicitly
266 /// (or use `is_retryable()`).
267 ///
268 /// [`FlowFabricWorker::claim_from_grant`]: crate::FlowFabricWorker::claim_from_grant
269 /// [`FlowFabricWorker::claim_from_reclaim_grant`]: crate::FlowFabricWorker::claim_from_reclaim_grant
270 #[error("worker at capacity: max_concurrent_tasks reached")]
271 WorkerAtCapacity,
272
273 /// HTTP transport error from the admin REST surface. Carries
274 /// the underlying `reqwest::Error` via `#[source]` so callers
275 /// can inspect `is_timeout()` / `is_connect()` / etc. for
276 /// finer-grained retry logic. Distinct from
277 /// [`SdkError::Backend`] — this fires on the HTTP/JSON surface,
278 /// not on the Lua/Valkey hot path.
279 #[error("http: {context}: {source}")]
280 Http {
281 #[source]
282 source: reqwest::Error,
283 context: String,
284 },
285
286 /// The admin REST endpoint returned a non-2xx response.
287 ///
288 /// Fields surface the server-side `ErrorBody` JSON shape
289 /// (`{ error, kind?, retryable? }`) as structured values so
290 /// cairn-fabric and other consumers can match without
291 /// re-parsing the body:
292 ///
293 /// * `status` — HTTP status code.
294 /// * `message` — the `error` string from the JSON body (or
295 /// the raw body if it didn't parse as JSON).
296 /// * `kind` — server-supplied Valkey `ErrorKind` label for 5xxs
297 /// backed by a transport error; `None` for 4xxs.
298 /// * `retryable` — server-supplied hint; `None` for 4xxs.
299 /// * `raw_body` — the full response body, preserved for logging
300 /// when the JSON shape doesn't match.
301 #[error("admin api: {status}: {message}")]
302 AdminApi {
303 status: u16,
304 message: String,
305 kind: Option<String>,
306 retryable: Option<bool>,
307 raw_body: String,
308 },
309}
310
311/// Renders `SdkError::Config` as `config: <context>[.<field>]: <message>`.
312/// The `field` slot is omitted when `None` (whole-object validation).
313fn fmt_config(context: &str, field: Option<&str>, message: &str) -> String {
314 match field {
315 Some(f) => format!("config: {context}.{f}: {message}"),
316 None => format!("config: {context}: {message}"),
317 }
318}
319
320/// Lift a native `ferriskey::Error` into [`SdkError::Backend`] via
321/// [`ff_backend_valkey::backend_error_from_ferriskey`] (#88). Keeps
322/// `?`-propagation ergonomic at FCALL/transport call sites while
323/// the public variant stays backend-agnostic.
324#[cfg(feature = "valkey-default")]
325impl From<ferriskey::Error> for SdkError {
326 fn from(err: ferriskey::Error) -> Self {
327 Self::Backend(ff_backend_valkey::backend_error_from_ferriskey(&err))
328 }
329}
330
331/// Build an [`SdkError::BackendContext`] from a native
332/// `ferriskey::Error` and a call-site label, preserving the
333/// backend-agnostic shape on the public surface (#88).
334#[cfg(feature = "valkey-default")]
335pub(crate) fn backend_context(
336 err: ferriskey::Error,
337 context: impl Into<String>,
338) -> SdkError {
339 SdkError::BackendContext {
340 source: ff_backend_valkey::backend_error_from_ferriskey(&err),
341 context: context.into(),
342 }
343}
344
345/// Preserves the ergonomic `?`-propagation from FCALL sites that
346/// return `Result<_, ScriptError>`. Routes through `EngineError`'s
347/// typed classification so every call site gets the same
348/// variant-level detail without hand-written conversion.
349impl From<ff_script::error::ScriptError> for SdkError {
350 fn from(err: ff_script::error::ScriptError) -> Self {
351 // ff-script's `From<ScriptError> for EngineError` owns the
352 // mapping table (#58.6). See `ff_script::engine_error_ext`.
353 Self::Engine(Box::new(EngineError::from(err)))
354 }
355}
356
357impl From<EngineError> for SdkError {
358 fn from(err: EngineError) -> Self {
359 Self::Engine(Box::new(err))
360 }
361}
362
363impl SdkError {
364 /// Returns the classified [`BackendErrorKind`] if this error
365 /// carries a backend transport fault. Covers the direct
366 /// [`SdkError::Backend`] / [`SdkError::BackendContext`] variants
367 /// and `Engine(EngineError::Transport { .. })` via the
368 /// ScriptError-aware downcast in `ff_script::engine_error_ext`.
369 ///
370 /// Renamed from `valkey_kind` in #88 — the previous return type
371 /// `Option<ferriskey::ErrorKind>` leaked ferriskey into every
372 /// consumer doing retry classification.
373 pub fn backend_kind(&self) -> Option<BackendErrorKind> {
374 match self {
375 Self::Backend(be) => Some(be.kind()),
376 Self::BackendContext { source, .. } => Some(source.kind()),
377 #[cfg(feature = "valkey-default")]
378 Self::Engine(e) => ff_script::engine_error_ext::valkey_kind(e)
379 .map(ff_backend_valkey::classify_ferriskey_kind),
380 #[cfg(not(feature = "valkey-default"))]
381 Self::Engine(_) => None,
382 // HTTP/admin-surface errors carry no backend fault;
383 // the admin path never touches the backend directly from
384 // the SDK side. Use `AdminApi.kind` for the server-supplied
385 // label when present.
386 Self::Config { .. }
387 | Self::WorkerAtCapacity
388 | Self::Http { .. }
389 | Self::AdminApi { .. } => None,
390 }
391 }
392
393 /// Whether this error is safely retryable by a caller. For backend
394 /// transport variants, delegates to
395 /// [`BackendErrorKind::is_retryable`]. For `Engine` errors, returns
396 /// `true` iff the typed classification is
397 /// `ErrorClass::Retryable`. `Config` errors are never retryable.
398 pub fn is_retryable(&self) -> bool {
399 match self {
400 Self::Backend(be) | Self::BackendContext { source: be, .. } => {
401 be.kind().is_retryable()
402 }
403 Self::Engine(e) => {
404 matches!(
405 ff_script::engine_error_ext::class(e),
406 ff_core::error::ErrorClass::Retryable
407 )
408 }
409 // WorkerAtCapacity is retryable: the saturation is transient
410 // and clears as soon as a concurrent task completes.
411 Self::WorkerAtCapacity => true,
412 // HTTP transport: timeouts and connect failures are
413 // retryable (transient network state); body-decode or
414 // request-build errors are terminal (caller must fix
415 // the code). `reqwest::Error` exposes both predicates.
416 Self::Http { source, .. } => source.is_timeout() || source.is_connect(),
417 // Admin API errors: trust the server's `retryable` hint
418 // when present; otherwise fall back to the HTTP-standard
419 // retryable-status set (429, 502, 503, 504). 5xxs without
420 // a hint are conservatively non-retryable — the caller can
421 // override with `AdminApi.status`-based logic if needed.
422 // 502 covers reverse-proxy transients (ALB/nginx returning
423 // Bad Gateway when ff-server restarts mid-request).
424 Self::AdminApi {
425 status, retryable, ..
426 } => retryable.unwrap_or(matches!(*status, 429 | 502 | 503 | 504)),
427 Self::Config { .. } => false,
428 }
429 }
430}
431
432#[cfg(all(test, feature = "valkey-default"))]
433mod tests {
434 use super::*;
435 use ferriskey::ErrorKind;
436 use ff_script::error::ScriptError;
437
438 fn mk_fk_err(kind: ErrorKind) -> ferriskey::Error {
439 ferriskey::Error::from((kind, "synthetic"))
440 }
441
442 #[test]
443 fn backend_kind_direct_and_context() {
444 assert_eq!(
445 SdkError::from(mk_fk_err(ErrorKind::IoError)).backend_kind(),
446 Some(BackendErrorKind::Transport)
447 );
448 assert_eq!(
449 crate::backend_context(mk_fk_err(ErrorKind::BusyLoadingError), "connect")
450 .backend_kind(),
451 Some(BackendErrorKind::BusyLoading)
452 );
453 }
454
455 #[test]
456 fn backend_kind_delegates_through_engine_transport() {
457 let err = SdkError::from(ScriptError::Valkey(mk_fk_err(ErrorKind::ClusterDown)));
458 assert_eq!(err.backend_kind(), Some(BackendErrorKind::Cluster));
459 }
460
461 #[test]
462 fn backend_kind_none_for_lua_and_config() {
463 assert_eq!(
464 SdkError::from(ScriptError::LeaseExpired).backend_kind(),
465 None
466 );
467 assert_eq!(
468 SdkError::Config {
469 context: "worker_config".into(),
470 field: Some("bearer_token".into()),
471 message: "bad host".into(),
472 }
473 .backend_kind(),
474 None
475 );
476 }
477
478 #[test]
479 fn is_retryable_transport() {
480 // Transport-bucketed kinds (IoError, FatalSend/Receive,
481 // ProtocolDesync) are retryable under the #88 classifier.
482 assert!(SdkError::from(mk_fk_err(ErrorKind::IoError)).is_retryable());
483 // Auth-bucketed kinds are terminal.
484 assert!(!SdkError::from(mk_fk_err(ErrorKind::AuthenticationFailed)).is_retryable());
485 // Protocol-bucketed kinds (ResponseError, ParseError, TypeError,
486 // InvalidClientConfig, etc.) are terminal.
487 assert!(!SdkError::from(mk_fk_err(ErrorKind::ResponseError)).is_retryable());
488 }
489
490 #[test]
491 fn is_retryable_engine_delegates_to_class() {
492 // NoEligibleExecution is classified Retryable via EngineError::class().
493 assert!(SdkError::from(ScriptError::NoEligibleExecution).is_retryable());
494 // StaleLease is Terminal.
495 assert!(!SdkError::from(ScriptError::StaleLease).is_retryable());
496 // Transport(Valkey(IoError)) is Retryable via class() delegation.
497 assert!(
498 SdkError::from(ScriptError::Valkey(mk_fk_err(ErrorKind::IoError))).is_retryable()
499 );
500 }
501
502 /// Regression (#98): `SdkError::Config` carries `context`, optional
503 /// `field`, and `message` separately so consumers can pattern-match on
504 /// the offending field without parsing the Display string. Test covers
505 /// both the field-scoped and whole-object renderings.
506 #[test]
507 fn config_structured_fields_render_and_match() {
508 let with_field = SdkError::Config {
509 context: "admin_client".into(),
510 field: Some("bearer_token".into()),
511 message: "is empty or all-whitespace".into(),
512 };
513 assert_eq!(
514 with_field.to_string(),
515 "config: admin_client.bearer_token: is empty or all-whitespace"
516 );
517 assert!(matches!(
518 &with_field,
519 SdkError::Config { field: Some(f), .. } if f == "bearer_token"
520 ));
521
522 let whole_object = SdkError::Config {
523 context: "worker_config".into(),
524 field: None,
525 message: "at least one lane is required".into(),
526 };
527 assert_eq!(
528 whole_object.to_string(),
529 "config: worker_config: at least one lane is required"
530 );
531 assert!(matches!(
532 &whole_object,
533 SdkError::Config { field: None, .. }
534 ));
535 }
536
537 #[test]
538 fn is_retryable_config_false() {
539 assert!(
540 !SdkError::Config {
541 context: "worker_config".into(),
542 field: None,
543 message: "at least one lane is required".into(),
544 }
545 .is_retryable()
546 );
547 }
548
549 #[test]
550 fn is_retryable_admin_api_uses_server_hint_when_present() {
551 let err = SdkError::AdminApi {
552 status: 429,
553 message: "throttled".into(),
554 kind: None,
555 retryable: Some(false),
556 raw_body: String::new(),
557 };
558 assert!(!err.is_retryable());
559
560 let err = SdkError::AdminApi {
561 status: 500,
562 message: "valkey timeout".into(),
563 kind: Some("IoError".into()),
564 retryable: Some(true),
565 raw_body: String::new(),
566 };
567 assert!(err.is_retryable());
568 }
569
570 #[test]
571 fn is_retryable_admin_api_falls_back_to_standard_retryable_statuses() {
572 // 502 covers ALB/nginx Bad Gateway transients on ff-server
573 // restart — same retry-is-safe as 503/504 because rotation
574 // is idempotent server-side.
575 for s in [429u16, 502, 503, 504] {
576 let err = SdkError::AdminApi {
577 status: s,
578 message: "x".into(),
579 kind: None,
580 retryable: None,
581 raw_body: String::new(),
582 };
583 assert!(err.is_retryable(), "status {s} should be retryable");
584 }
585 for s in [400u16, 401, 403, 404, 500] {
586 let err = SdkError::AdminApi {
587 status: s,
588 message: "x".into(),
589 kind: None,
590 retryable: None,
591 raw_body: String::new(),
592 };
593 assert!(!err.is_retryable(), "status {s} should NOT be retryable without hint");
594 }
595 }
596
597 #[test]
598 fn valkey_kind_none_for_admin_surface() {
599 let err = SdkError::AdminApi {
600 status: 500,
601 message: "x".into(),
602 kind: Some("IoError".into()),
603 retryable: Some(true),
604 raw_body: String::new(),
605 };
606 assert_eq!(err.backend_kind(), None);
607 }
608}