fusillade_core/request/transitions.rs
1//! State transitions for batch requests using the typestate pattern.
2//!
3//! This module implements state transitions for HTTP batch requests using Rust's
4//! type system to enforce valid state transitions at compile time. Each request
5//! state is represented as a distinct type parameter on `Request<State>`.
6//!
7//! # Typestate Pattern
8//!
9//! The typestate pattern leverages Rust's type system to make invalid states
10//! unrepresentable. A `Request<Pending>` can only call methods available for
11//! pending requests, and transitions return different types:
12//!
13//! ```text
14//! Request<Pending> ──claim()──> Request<Claimed> ──process()──> Request<Processing>
15//! │ │ │
16//! │ │ └──complete()──> Request<Completed>
17//! │ │ └──complete()──> Request<Failed>
18//! └──cancel()──> Request<Canceled> └──cancel()────> Request<Canceled>
19//! │
20//! └──unclaim()─> Request<Pending>
21//!
22//! Request<Failed> ──retry()──> Request<Pending> (if retries remain)
23//! ──retry()──> None (if max retries reached)
24//! ```
25//!
26//! # State Lifecycle
27//!
28//! ## 1. Pending → Claimed
29//!
30//! A daemon claims a pending request for processing:
31//! - Records which daemon claimed it
32//! - Sets claimed_at timestamp
33//! - Preserves retry attempt count
34//!
35//! ## 2. Claimed → Processing
36//!
37//! The daemon starts executing the HTTP request:
38//! - Spawns an async task to make the HTTP call
39//! - Creates a channel to receive the result
40//! - Provides an abort handle for cancellation
41//!
42//! ## 3. Processing → Completed or Failed
43//!
44//! The HTTP request completes:
45//! - **Success**: Transitions to `Completed` with response body
46//! - **Failure**: Transitions to `Failed` with error message
47//! - **Retriable**: HTTP succeeded but status code indicates retry (e.g., 429, 500)
48//!
49//! ## 4. Failed → Pending (Retry)
50//!
51//! Failed requests can be retried with exponential backoff:
52//! - Increments retry_attempt counter
53//! - Calculates backoff delay: `backoff_ms * (factor ^ attempt)`
54//! - Sets not_before timestamp to delay retry
55//! - Returns `None` if max retries exceeded
56//!
57//! ## 5. Any State → Canceled
58//!
59//! Requests can be canceled from most states:
60//! - `Pending`: Simply marks as canceled
61//! - `Claimed`: Releases claim and cancels
62//! - `Processing`: Aborts the in-flight HTTP request
63//!
64//! # Retry Configuration
65//!
66//! Exponential backoff and retry limits are configured via [`RetryConfig`]:
67//!
68//! ```rust
69//! # use fusillade_core::request::transitions::RetryConfig;
70//! let config = RetryConfig {
71//! max_retries: Some(1000),
72//! stop_before_deadline_ms: Some(900_000),
73//! backoff_ms: 1000, // Start with 1 second
74//! backoff_factor: 2, // Double each time (1s, 2s, 4s)
75//! max_backoff_ms: 60000, // Cap at 60 seconds
76//! };
77//! ```
78//!
79//! # Example Workflow
80//!
81//! ```ignore
82//! // Daemon claims a pending request
83//! let pending: Request<Pending> = storage.next_pending().await?;
84//! let claimed = pending.claim(daemon_id, &storage).await?;
85//!
86//! // Start processing
87//! let processing = claimed.process(http_client, &storage).await?;
88//!
89//! // Wait for completion
90//! let result = processing.complete(&storage, |resp| resp.status >= 500).await?;
91//!
92//! match result {
93//! Ok(completed) => println!("Success: {}", completed.state.response_status),
94//! Err(failed) => {
95//! // Attempt retry with backoff
96//! if let Some(retrying) = failed.retry(retry_attempt, config, &storage).await? {
97//! println!("Retrying request...");
98//! } else {
99//! println!("Max retries exceeded");
100//! }
101//! }
102//! }
103//! ```
104
105use std::sync::Arc;
106
107use tokio::sync::Mutex;
108use tracing::Instrument;
109
110use crate::{FusilladeError, error::Result, manager::Storage};
111
112use super::types::{
113 Canceled, Claimed, Completed, DaemonId, Failed, FailureReason, HttpResponse, Pending,
114 Processing, Request, RequestCompletionResult,
115};
116
117/// Reason for cancelling a request.
118#[derive(Debug, Clone, Copy)]
119pub enum CancellationReason {
120 /// User-initiated cancellation (should persist Canceled state).
121 User,
122 /// Daemon shutdown (abort HTTP but don't persist state change).
123 Shutdown,
124}
125
126impl Request<Pending> {
127 pub async fn claim<S: Storage + ?Sized>(
128 self,
129 daemon_id: DaemonId,
130 storage: &S,
131 ) -> Result<Request<Claimed>> {
132 let request = Request {
133 data: self.data,
134 state: Claimed {
135 daemon_id,
136 claimed_at: chrono::Utc::now(),
137 retry_attempt: self.state.retry_attempt, // Carry over retry attempt
138 batch_expires_at: self.state.batch_expires_at, // Carry over batch deadline
139 // This single-row claim path does not run the leaky-bucket gate.
140 leak: None,
141 },
142 };
143 storage.persist(&request).await?;
144 Ok(request)
145 }
146
147 pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
148 let request = Request {
149 data: self.data,
150 state: Canceled {
151 canceled_at: chrono::Utc::now(),
152 },
153 };
154 storage.persist(&request).await?;
155 Ok(request)
156 }
157}
158
159impl Request<Claimed> {
160 pub async fn unclaim<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Pending>> {
161 let request = Request {
162 data: self.data,
163 state: Pending {
164 retry_attempt: self.state.retry_attempt, // Preserve retry attempt
165 not_before: None, // Can be claimed immediately
166 batch_expires_at: self.state.batch_expires_at, // Carry over batch deadline
167 },
168 };
169 storage.persist(&request).await?;
170 Ok(request)
171 }
172
173 pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
174 let request = Request {
175 data: self.data,
176 state: Canceled {
177 canceled_at: chrono::Utc::now(),
178 },
179 };
180 storage.persist(&request).await?;
181 Ok(request)
182 }
183
184 pub async fn process<S, Fut>(
185 self,
186 storage: &S,
187 response_fut: Fut,
188 ) -> Result<Request<Processing>>
189 where
190 S: Storage,
191 Fut: std::future::Future<Output = Result<HttpResponse>> + Send + 'static,
192 {
193 // Create a channel for the HTTP result
194 let (tx, rx) = tokio::sync::mpsc::channel(1);
195
196 // Spawn the HTTP request as an async task, propagating the current
197 // span so that the execute span becomes a child of process_request.
198 let current_span = tracing::Span::current();
199 let task_handle = tokio::spawn(
200 async move {
201 let result = response_fut.await;
202 let _ = tx.send(result).await; // Ignore send errors (receiver dropped)
203 }
204 .instrument(current_span),
205 );
206
207 let processing_state = Processing {
208 daemon_id: self.state.daemon_id,
209 claimed_at: self.state.claimed_at,
210 started_at: chrono::Utc::now(),
211 retry_attempt: self.state.retry_attempt,
212 batch_expires_at: self.state.batch_expires_at,
213 result_rx: Arc::new(Mutex::new(rx)),
214 abort_handle: task_handle.abort_handle(),
215 };
216
217 let request = Request {
218 data: self.data,
219 state: processing_state,
220 };
221
222 // Persist the Processing state so we can cancel it if needed
223 // If persist fails, abort the spawned HTTP task
224 if let Err(e) = storage.persist(&request).await {
225 request.state.abort_handle.abort();
226 return Err(e);
227 }
228
229 Ok(request)
230 }
231}
232
233/// Configuration for retry behavior.
234#[derive(Debug, Clone)]
235pub struct RetryConfig {
236 pub max_retries: Option<u32>,
237 pub stop_before_deadline_ms: Option<i64>,
238 pub backoff_ms: u64,
239 pub backoff_factor: u64,
240 pub max_backoff_ms: u64,
241}
242
243impl Request<Failed> {
244 /// Attempt to retry this failed request.
245 ///
246 /// If retries are available, transitions the request back to Pending with:
247 /// - Incremented retry_attempt
248 /// - Calculated not_before timestamp for exponential backoff
249 ///
250 /// If no retries remain, returns None and the request stays Failed.
251 ///
252 /// The retry logic considers:
253 /// - max_retries: Hard cap on total retry attempts
254 /// - stop_before_deadline_ms: Deadline-aware retry (stops before batch expiration)
255 pub fn can_retry(
256 self,
257 retry_attempt: u32,
258 config: RetryConfig,
259 ) -> std::result::Result<Request<Pending>, Box<Self>> {
260 // Calculate exponential backoff: backoff_ms * (backoff_factor ^ retry_attempt)
261 let backoff_duration = {
262 let exponential = config
263 .backoff_ms
264 .saturating_mul(config.backoff_factor.saturating_pow(retry_attempt));
265 exponential.min(config.max_backoff_ms)
266 };
267
268 let now = chrono::Utc::now();
269 let not_before = now + chrono::Duration::milliseconds(backoff_duration as i64);
270
271 if let Some(max_retries) = config.max_retries
272 && retry_attempt >= max_retries
273 {
274 return Err(Box::new(self));
275 }
276
277 // Determine the effective deadline (with or without buffer)
278 let effective_deadline = if let Some(stop_before_deadline_ms) =
279 config.stop_before_deadline_ms
280 {
281 self.state.batch_expires_at - chrono::Duration::milliseconds(stop_before_deadline_ms)
282 } else {
283 // No buffer configured - use the actual deadline
284 self.state.batch_expires_at
285 };
286
287 // Check if the next retry would start before the effective deadline
288 if not_before >= effective_deadline {
289 return Err(Box::new(self));
290 }
291
292 // state_transition span emitted by caller after persist
293
294 let request = Request {
295 data: self.data,
296 state: Pending {
297 retry_attempt: retry_attempt + 1,
298 not_before: Some(not_before),
299 batch_expires_at: self.state.batch_expires_at,
300 },
301 };
302
303 Ok(request)
304 }
305}
306
307impl Request<Processing> {
308 /// Wait for the HTTP request to complete.
309 ///
310 /// This method awaits the result from the spawned HTTP task and transitions
311 /// the request to one of three terminal states: `Completed`, `Failed`, or `Canceled`.
312 ///
313 /// The `should_retry` predicate determines whether a response should be considered
314 /// a failure (and thus eligible for retry) or a success.
315 ///
316 /// The `cancellation` future allows external cancellation of the request. It should
317 /// resolve to a `CancellationReason`:
318 /// - `CancellationReason::User`: User-initiated cancellation (persists Canceled state)
319 /// - `CancellationReason::Shutdown`: Daemon shutdown (aborts HTTP but doesn't persist)
320 ///
321 /// Returns:
322 /// - `RequestCompletionResult::Completed` if the HTTP request succeeded
323 /// - `RequestCompletionResult::Failed` if the HTTP request failed or should be retried
324 /// - `RequestCompletionResult::Canceled` if the request was canceled by user
325 /// - `Err(FusilladeError::Shutdown)` if the daemon is shutting down
326 pub async fn complete<S, F, Fut>(
327 self,
328 storage: &S,
329 should_retry: F,
330 cancellation: Fut,
331 ) -> Result<RequestCompletionResult>
332 where
333 S: Storage + ?Sized,
334 F: Fn(&HttpResponse) -> bool,
335 Fut: std::future::Future<Output = CancellationReason>,
336 {
337 // Await the result from the channel (lock the mutex to access the receiver)
338 // We use an enum to track whether we got a result or cancellation so we can
339 // drop the mutex guard before calling self.cancel()
340 enum Outcome {
341 Result(Option<std::result::Result<HttpResponse, FusilladeError>>),
342 Canceled(CancellationReason),
343 }
344
345 let outcome = {
346 let mut rx = self.state.result_rx.lock().await;
347
348 tokio::select! {
349 // Wait for the HTTP request to finish processing
350 result = rx.recv() => Outcome::Result(result),
351 // Handle cancellation
352 reason = cancellation => Outcome::Canceled(reason),
353 }
354 };
355
356 // Handle cancellation outside the mutex guard
357 let result = match outcome {
358 Outcome::Canceled(CancellationReason::User) => {
359 // User cancellation: abort HTTP task but don't persist state change.
360 // The batch's cancelling_at flag causes these requests to be counted
361 // as canceled in queries, so no individual UPDATE is needed.
362 self.state.abort_handle.abort();
363 let canceled = Request {
364 data: self.data,
365 state: Canceled {
366 canceled_at: chrono::Utc::now(),
367 },
368 };
369 return Ok(RequestCompletionResult::Canceled(canceled));
370 }
371 Outcome::Canceled(CancellationReason::Shutdown) => {
372 // Shutdown: abort HTTP task but don't persist state change
373 // Request stays in Processing state and will be reclaimed later
374 self.state.abort_handle.abort();
375 return Err(FusilladeError::Shutdown);
376 }
377 Outcome::Result(result) => result,
378 };
379
380 match result {
381 Some(Ok(http_response)) => {
382 // Check if this is an error response (4xx or 5xx)
383 let is_error = http_response.status >= 400;
384
385 // Check if this response should be retried
386 if should_retry(&http_response) {
387 // Treat as failure for retry purposes
388 let failed_state = Failed {
389 reason: FailureReason::RetriableHttpStatus {
390 status: http_response.status,
391 body: http_response.body.clone(),
392 },
393 failed_at: chrono::Utc::now(),
394 retry_attempt: self.state.retry_attempt,
395 batch_expires_at: self.state.batch_expires_at,
396 routed_model: self.data.model.clone(),
397 };
398 let request = Request {
399 data: self.data,
400 state: failed_state,
401 };
402 Ok(RequestCompletionResult::Failed(request))
403 } else if is_error {
404 // Non-retriable error (e.g., 4xx client errors)
405 // Mark as failed but don't retry
406 let failed_state = Failed {
407 reason: FailureReason::NonRetriableHttpStatus {
408 status: http_response.status,
409 body: http_response.body.clone(),
410 },
411 failed_at: chrono::Utc::now(),
412 retry_attempt: self.state.retry_attempt,
413 batch_expires_at: self.state.batch_expires_at,
414 routed_model: self.data.model.clone(),
415 };
416 let request = Request {
417 data: self.data,
418 state: failed_state,
419 };
420 storage.persist(&request).await?;
421 Ok(RequestCompletionResult::Failed(request))
422 } else {
423 // HTTP request completed successfully
424 let completed_state = Completed {
425 response_status: http_response.status,
426 response_body: http_response.body,
427 claimed_at: self.state.claimed_at,
428 started_at: self.state.started_at,
429 completed_at: chrono::Utc::now(),
430 routed_model: self.data.model.clone(),
431 };
432 let request = Request {
433 data: self.data,
434 state: completed_state,
435 };
436 storage.persist(&request).await?;
437 Ok(RequestCompletionResult::Completed(request))
438 }
439 }
440 Some(Err(e)) => {
441 let reason = match &e {
442 FusilladeError::HttpRequestBuilder(error) => {
443 FailureReason::RequestBuilderError {
444 error: error.clone(),
445 }
446 }
447 FusilladeError::HttpClientTimeout(error) => FailureReason::Timeout {
448 error: error.clone(),
449 },
450 FusilladeError::FirstChunkTimeout(msg) => {
451 FailureReason::Timeout { error: msg.clone() }
452 }
453 FusilladeError::TokensTimeout(msg) => {
454 FailureReason::Timeout { error: msg.clone() }
455 }
456 FusilladeError::BodyTimeout(msg) => {
457 FailureReason::Timeout { error: msg.clone() }
458 }
459 _ => FailureReason::NetworkError {
460 error: crate::error::error_serialization::serialize_error(&e.into()),
461 },
462 };
463
464 let failed_state = Failed {
465 reason,
466 failed_at: chrono::Utc::now(),
467 retry_attempt: self.state.retry_attempt,
468 batch_expires_at: self.state.batch_expires_at,
469 routed_model: self.data.model.clone(),
470 };
471 let request = Request {
472 data: self.data,
473 state: failed_state,
474 };
475 Ok(RequestCompletionResult::Failed(request))
476 }
477 None => {
478 // Channel closed - task died without sending a result
479 let failed_state = Failed {
480 reason: FailureReason::TaskTerminated,
481 failed_at: chrono::Utc::now(),
482 retry_attempt: self.state.retry_attempt,
483 batch_expires_at: self.state.batch_expires_at,
484 routed_model: self.data.model.clone(),
485 };
486 let request = Request {
487 data: self.data,
488 state: failed_state,
489 };
490 storage.persist(&request).await?;
491 Ok(RequestCompletionResult::Failed(request))
492 }
493 }
494 }
495
496 pub async fn cancel<S: Storage + ?Sized>(self, storage: &S) -> Result<Request<Canceled>> {
497 // Abort the in-flight HTTP request
498 self.state.abort_handle.abort();
499
500 let request = Request {
501 data: self.data,
502 state: Canceled {
503 canceled_at: chrono::Utc::now(),
504 },
505 };
506 storage.persist(&request).await?;
507 Ok(request)
508 }
509}