From dbd4b7a15cf3d7a4f9f249fc8b0daf41915bf138 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 27 Jun 2026 13:57:50 +0200 Subject: [PATCH] fix(schedules): use typed error for pause/resume 404 discrimination Replace brittle msg.contains("not found") string matching in post_schedule_pause / post_schedule_resume with a typed ScheduleNotFoundOrCancelled error that handlers downcast on directly. pause() and resume() now return Err(ScheduleNotFoundOrCancelled(id).into()) instead of bail!("schedule {id} not found or is cancelled"); handlers call e.downcast_ref::().is_some() for the 404 branch, making the discrimination stable even if the error message wording changes. --- hive-c0re/src/dashboard/schedules.rs | 16 ++++++++-------- hive-c0re/src/scheduled_prompts.rs | 24 ++++++++++++++++++++---- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs index 9777e3de..967146f2 100644 --- a/hive-c0re/src/dashboard/schedules.rs +++ b/hive-c0re/src/dashboard/schedules.rs @@ -13,6 +13,8 @@ use axum::{ use problem_details::ProblemDetails; +use crate::scheduled_prompts::ScheduleNotFoundOrCancelled; + use super::{AppState, error_problem, error_response}; /// `GET /api/schedules` — snapshot of every schedule for the @@ -241,11 +243,10 @@ pub(super) async fn post_schedule_pause( (StatusCode::OK, "ok").into_response() } Err(e) => { - let msg = format!("{e:#}"); - if msg.contains("not found") || msg.contains("cancelled") { - (StatusCode::NOT_FOUND, msg).into_response() + if e.downcast_ref::().is_some() { + (StatusCode::NOT_FOUND, format!("{e}")).into_response() } else { - error_response(&format!("pause schedule {id}: {msg}")) + error_response(&format!("pause schedule {id}: {e:#}")) } } } @@ -264,11 +265,10 @@ pub(super) async fn post_schedule_resume( (StatusCode::OK, "ok").into_response() } Err(e) => { - let msg = format!("{e:#}"); - if msg.contains("not found") || msg.contains("cancelled") { - (StatusCode::NOT_FOUND, msg).into_response() + if e.downcast_ref::().is_some() { + (StatusCode::NOT_FOUND, format!("{e}")).into_response() } else { - error_response(&format!("resume schedule {id}: {msg}")) + error_response(&format!("resume schedule {id}: {e:#}")) } } } diff --git a/hive-c0re/src/scheduled_prompts.rs b/hive-c0re/src/scheduled_prompts.rs index 764f8500..9095e7b5 100644 --- a/hive-c0re/src/scheduled_prompts.rs +++ b/hive-c0re/src/scheduled_prompts.rs @@ -19,6 +19,20 @@ use anyhow::{Context, Result, bail}; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; +/// Typed error returned by [`ScheduledPrompts::pause`] and +/// [`ScheduledPrompts::resume`] when the target row does not exist or +/// is already cancelled. Handlers downcast on this type to emit 404 +/// rather than 500, avoiding brittle string-matching on the message. +#[derive(Debug)] +pub struct ScheduleNotFoundOrCancelled(pub i64); + +impl std::fmt::Display for ScheduleNotFoundOrCancelled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "schedule {} not found or is cancelled", self.0) + } +} +impl std::error::Error for ScheduleNotFoundOrCancelled {} + const SCHEMA: &str = r" CREATE TABLE IF NOT EXISTS scheduled_prompts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -577,7 +591,8 @@ impl ScheduledPrompts { /// `next_fire_at_unix` is preserved so the first resume fires at /// the next intended cadence instant (no catch-up needed — a /// paused schedule simply slips its upcoming fire). Returns - /// `Err` when the schedule is cancelled or does not exist. + /// `Err(ScheduleNotFoundOrCancelled)` when the schedule is + /// cancelled or does not exist (handlers downcast to emit 404). pub fn pause(&self, id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); let now = now_unix(); @@ -588,13 +603,14 @@ impl ScheduledPrompts { params![now, id], )?; if n == 0 { - bail!("schedule {id} not found or is cancelled"); + return Err(ScheduleNotFoundOrCancelled(id).into()); } Ok(()) } /// Resume a paused schedule. Idempotent; no-op on an active row. - /// Returns `Err` when the schedule is cancelled or does not exist. + /// Returns `Err(ScheduleNotFoundOrCancelled)` when the schedule is + /// cancelled or does not exist (handlers downcast to emit 404). pub fn resume(&self, id: i64) -> Result<()> { let conn = self.conn.lock().unwrap(); let n = conn.execute( @@ -604,7 +620,7 @@ impl ScheduledPrompts { params![id], )?; if n == 0 { - bail!("schedule {id} not found or is cancelled"); + return Err(ScheduleNotFoundOrCancelled(id).into()); } Ok(()) }