diff --git a/AGENTS.md b/AGENTS.md index 678eae4..9a13f26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **honcho-rust-sdk** (2342 symbols, 7380 relationships, 204 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **honcho-rust-sdk** (2383 symbols, 7591 relationships, 208 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index fc6fa31..c4acbb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **honcho-rust-sdk** (2342 symbols, 7380 relationships, 204 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **honcho-rust-sdk** (2383 symbols, 7591 relationships, 208 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/Cargo.lock b/Cargo.lock index 1e27d97..a37b1af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,9 +86,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -172,9 +172,9 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecount" @@ -190,9 +190,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -325,9 +325,9 @@ checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -530,9 +530,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -656,7 +656,7 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "honcho-ai" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-stream", "bon", @@ -686,9 +686,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -731,9 +731,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1030,9 +1030,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1061,7 +1061,7 @@ dependencies = [ "referencing", "regex", "regex-syntax", - "reqwest 0.13.3", + "reqwest 0.13.4", "rustls", "serde", "serde_json", @@ -1110,9 +1110,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru-slab" @@ -1122,9 +1122,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "micromap" @@ -1150,9 +1150,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1273,9 +1273,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", @@ -1304,9 +1304,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -1657,9 +1657,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -1938,9 +1938,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1974,9 +1974,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd_cesu8" @@ -2008,9 +2008,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2208,9 +2208,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime", @@ -2244,9 +2244,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", @@ -2430,9 +2430,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2443,9 +2443,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2453,9 +2453,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2463,9 +2463,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2476,9 +2476,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -2532,9 +2532,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -2794,9 +2794,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -2955,18 +2955,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7df43f1..43202ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "honcho-ai" -version = "0.1.5" +version = "0.1.6" edition = "2024" rust-version = "1.88" license = "MIT" diff --git a/src/blocking/client.rs b/src/blocking/client.rs index 9bf4b19..0395e17 100644 --- a/src/blocking/client.rs +++ b/src/blocking/client.rs @@ -51,7 +51,7 @@ impl Honcho { /// Eagerly ensure the workspace exists on the server. pub fn force_ensure(&self) -> Result<()> { - block_on(self.inner.force_ensure()) + block_on(self.inner.force_ensure())? } /// Workspace ID this client is scoped to. @@ -73,7 +73,7 @@ impl Honcho { metadata: Option>, configuration: Option>, ) -> Result { - block_on(self.inner.peer(id, metadata, configuration)).map(BlockingPeer::new) + block_on(self.inner.peer(id, metadata, configuration))?.map(BlockingPeer::new) } /// Get or create a session by ID. @@ -84,7 +84,7 @@ impl Honcho { peers: Option>, configuration: Option, ) -> Result { - block_on(self.inner.session(id, metadata, peers, configuration)).map(BlockingSession::new) + block_on(self.inner.session(id, metadata, peers, configuration))?.map(BlockingSession::new) } /// Search messages across the workspace. @@ -94,12 +94,12 @@ impl Honcho { limit: Option, filters: Option>, ) -> Result> { - block_on(self.inner.search(query, limit, filters)) + block_on(self.inner.search(query, limit, filters))? } /// Refresh workspace state. pub fn refresh(&self) -> Result<()> { - block_on(self.inner.refresh()) + block_on(self.inner.refresh())? } /// Get queue processing status. @@ -109,7 +109,7 @@ impl Honcho { sender_id: Option<&str>, session_id: Option<&str>, ) -> Result { - block_on(self.inner.queue_status(observer_id, sender_id, session_id)) + block_on(self.inner.queue_status(observer_id, sender_id, session_id))? } /// Schedule a dream task for memory consolidation. @@ -122,42 +122,42 @@ impl Honcho { block_on( self.inner .schedule_dream(observer, session_id, observed_peer), - ) + )? } /// Delete a workspace by ID. pub fn delete_workspace(&self, id: &str) -> Result<()> { - block_on(self.inner.delete_workspace(id)) + block_on(self.inner.delete_workspace(id))? } /// Fetch workspace metadata. pub fn get_metadata(&self) -> Result> { - block_on(self.inner.get_metadata()) + block_on(self.inner.get_metadata())? } /// Set workspace metadata. pub fn set_metadata(&self, metadata: HashMap) -> Result<()> { - block_on(self.inner.set_metadata(metadata)) + block_on(self.inner.set_metadata(metadata))? } /// Fetch workspace configuration as a typed [`WorkspaceConfiguration`]. pub fn get_configuration(&self) -> Result { - block_on(self.inner.get_configuration()) + block_on(self.inner.get_configuration())? } /// Set workspace configuration from a typed [`WorkspaceConfiguration`]. pub fn set_configuration(&self, config: &WorkspaceConfiguration) -> Result<()> { - block_on(self.inner.set_configuration(config)) + block_on(self.inner.set_configuration(config))? } /// Fetch workspace configuration as a raw JSON map. pub fn get_configuration_raw(&self) -> Result> { - block_on(self.inner.get_configuration_raw()) + block_on(self.inner.get_configuration_raw())? } /// Set workspace configuration from a raw JSON map. pub fn set_configuration_raw(&self, configuration: HashMap) -> Result<()> { - block_on(self.inner.set_configuration_raw(configuration)) + block_on(self.inner.set_configuration_raw(configuration))? } /// List all peers in the workspace, collecting across pages. @@ -165,7 +165,7 @@ impl Honcho { block_on(async { let page = self.inner.peers().await?; collect_all_pages(page).await - }) + })? } /// List peers with filters, collecting across pages. @@ -184,7 +184,7 @@ impl Honcho { .peers_with_filters(filters, page, size, reverse) .await?; collect_all_pages(page).await - }) + })? } /// List all sessions in the workspace, collecting across pages. @@ -192,7 +192,7 @@ impl Honcho { block_on(async { let page = self.inner.sessions().await?; collect_all_pages(page).await - }) + })? } /// List sessions with filters, collecting across pages. @@ -211,7 +211,7 @@ impl Honcho { .sessions_with_filters(filters, page, size, reverse) .await?; collect_all_pages(page).await - }) + })? } /// List all workspace IDs, collecting across pages. @@ -219,6 +219,6 @@ impl Honcho { block_on(async { let page = self.inner.workspaces().await?; collect_all_pages(page).await - }) + })? } } diff --git a/src/blocking/conclusion.rs b/src/blocking/conclusion.rs index cae773c..74e9242 100644 --- a/src/blocking/conclusion.rs +++ b/src/blocking/conclusion.rs @@ -107,7 +107,7 @@ impl ConclusionScope { &self, conclusions: impl IntoIterator>, ) -> Result> { - block_on(self.inner.create(conclusions)) + block_on(self.inner.create(conclusions))? .map(|v| v.into_iter().map(Conclusion::new).collect()) } @@ -137,7 +137,7 @@ impl ConclusionScope { /// Delete a conclusion by ID. pub fn delete(&self, conclusion_id: impl Into) -> Result<()> { - block_on(self.inner.delete(conclusion_id)) + block_on(self.inner.delete(conclusion_id))? } } @@ -189,7 +189,7 @@ impl BlockingConclusionRepresentationBuilder { /// Send the request. pub fn send(self) -> Result { - block_on(self.inner.send()) + block_on(self.inner.send())? } } @@ -240,7 +240,7 @@ impl BlockingListConclusionsBuilder { /// Send and return paginated result. pub fn send(self) -> Result { - block_on(self.inner.send()) + block_on(self.inner.send())? } } @@ -275,7 +275,7 @@ impl BlockingQueryConclusionsBuilder { /// Send the query. pub fn send(self) -> Result> { - block_on(self.inner.send()).map(|v| v.into_iter().map(Conclusion::new).collect()) + block_on(self.inner.send())?.map(|v| v.into_iter().map(Conclusion::new).collect()) } } diff --git a/src/blocking/iter.rs b/src/blocking/iter.rs index d5e2aa3..5fc88e6 100644 --- a/src/blocking/iter.rs +++ b/src/blocking/iter.rs @@ -42,10 +42,12 @@ where { type Item = S::Item; + #[expect(clippy::expect_used)] fn next(&mut self) -> Option { block_on(StreamNext { stream: &mut self.stream, }) + .expect("blocking::Honcho cannot be called from within an async runtime") } } diff --git a/src/blocking/peer.rs b/src/blocking/peer.rs index 631e1a3..1e9e040 100644 --- a/src/blocking/peer.rs +++ b/src/blocking/peer.rs @@ -55,52 +55,52 @@ impl Peer { /// Refresh cached state from the server. pub fn refresh(&self) -> Result<()> { - block_on(self.inner.refresh()) + block_on(self.inner.refresh())? } /// Fetch and return metadata, updating the cache. pub fn get_metadata(&self) -> Result> { - block_on(self.inner.get_metadata()) + block_on(self.inner.get_metadata())? } /// Set metadata on the server and update the cache. pub fn set_metadata(&self, metadata: HashMap) -> Result<()> { - block_on(self.inner.set_metadata(metadata)) + block_on(self.inner.set_metadata(metadata))? } /// Fetch and return configuration, updating the cache. pub fn get_configuration(&self) -> Result { - block_on(self.inner.get_configuration()) + block_on(self.inner.get_configuration())? } /// Set configuration on the server and update the cache. pub fn set_configuration(&self, config: &PeerConfig) -> Result<()> { - block_on(self.inner.set_configuration(config)) + block_on(self.inner.set_configuration(config))? } /// Fetch configuration as a raw JSON map. pub fn get_configuration_raw(&self) -> Result> { - block_on(self.inner.get_configuration_raw()) + block_on(self.inner.get_configuration_raw())? } /// Set configuration from a raw JSON map. pub fn set_configuration_raw(&self, config: HashMap) -> Result<()> { - block_on(self.inner.set_configuration_raw(config)) + block_on(self.inner.set_configuration_raw(config))? } /// Patch-update metadata. pub fn update(&self, metadata: HashMap) -> Result<()> { - block_on(self.inner.update(metadata)) + block_on(self.inner.update(metadata))? } /// Non-streaming dialectic chat. pub fn chat(&self, query: &str) -> Result> { - block_on(self.inner.chat(query)) + block_on(self.inner.chat(query))? } /// Non-streaming dialectic chat with full options. pub fn chat_with_options(&self, options: &DialecticOptions) -> Result> { - block_on(self.inner.chat_with_options(options)) + block_on(self.inner.chat_with_options(options))? } /// Start a streaming dialectic chat. Returns a builder; call `.send()` for an iterator. @@ -113,7 +113,7 @@ impl Peer { /// Get the peer's representation. pub fn representation(&self) -> Result { - block_on(self.inner.representation()) + block_on(self.inner.representation())? } /// Get a builder for fine-grained representation parameters. @@ -134,14 +134,14 @@ impl Peer { /// Get the peer's context. pub fn context(&self) -> Result { - block_on(self.inner.context()) + block_on(self.inner.context())? } /// Get the peer's context scoped to a target. #[deprecated(since = "0.1.1", note = "use `Peer::context_builder()` instead")] #[allow(deprecated)] pub fn context_with_target(&self, target: &str) -> Result { - block_on(self.inner.context_with_target(target)) + block_on(self.inner.context_with_target(target))? } /// Get the peer's context with custom options. @@ -151,7 +151,7 @@ impl Peer { &self, options: &crate::types::peer::PeerContextOptions, ) -> Result { - block_on(self.inner.context_with_options(options)) + block_on(self.inner.context_with_options(options))? } /// List sessions for this peer, collecting across pages. @@ -159,17 +159,17 @@ impl Peer { block_on(async { let page = self.inner.sessions().await?; collect_all_pages(page).await - }) + })? } /// List sessions with filters and pagination options. Returns a [`Page`]. pub fn sessions_with_options(&self, options: &SessionListOptions) -> Result> { - block_on(self.inner.sessions_with_options(options)) + block_on(self.inner.sessions_with_options(options))? } /// Search messages for this peer. pub fn search(&self, query: &str) -> Result> { - block_on(self.inner.search(query)) + block_on(self.inner.search(query))? } /// Search messages for this peer with custom options. @@ -177,22 +177,22 @@ impl Peer { &self, options: &MessageSearchOptions, ) -> Result> { - block_on(self.inner.search_with_options(options)) + block_on(self.inner.search_with_options(options))? } /// Get this peer's card. pub fn get_card(&self) -> Result>> { - block_on(self.inner.get_card()) + block_on(self.inner.get_card())? } /// Get this peer's card scoped to a target. pub fn get_card_with_target(&self, target: &str) -> Result>> { - block_on(self.inner.get_card_with_target(target)) + block_on(self.inner.get_card_with_target(target))? } /// Set this peer's card. pub fn set_card(&self, card: Vec) -> Result>> { - block_on(self.inner.set_card(card)) + block_on(self.inner.set_card(card))? } /// Set this peer's card scoped to a target. @@ -201,7 +201,7 @@ impl Peer { card: Vec, target: &str, ) -> Result>> { - block_on(self.inner.set_card_with_target(card, target)) + block_on(self.inner.set_card_with_target(card, target))? } /// Self-scoped conclusion handle. @@ -255,7 +255,7 @@ impl BlockingChatStreamBuilder { /// Send and return an iterator over SSE chunks. pub fn send(self) -> Result { - let stream = block_on(self.inner.send())?; + let stream = block_on(self.inner.send())??; Ok(ChatStreamIterator { inner: BlockingIter::new(stream), }) @@ -357,7 +357,7 @@ impl BlockingRepresentationBuilder { /// Send the representation request. pub fn send(self) -> Result { - block_on(self.inner.send()) + block_on(self.inner.send())? } } @@ -433,7 +433,7 @@ impl BlockingContextBuilder { /// Send the context request. pub fn send(self) -> Result { - block_on(self.inner.send()) + block_on(self.inner.send())? } } diff --git a/src/blocking/runtime.rs b/src/blocking/runtime.rs index 9f4a97f..adb46e0 100644 --- a/src/blocking/runtime.rs +++ b/src/blocking/runtime.rs @@ -2,12 +2,20 @@ use std::future::Future; use std::sync::OnceLock; use tokio::runtime::{Handle, Runtime}; +use crate::error::HonchoError; + static RUNTIME: OnceLock = OnceLock::new(); +/// Returns worker count: min(available parallelism, 8), default 4. +fn worker_count() -> usize { + std::thread::available_parallelism().map_or_else(|_| 4, |n| n.get().min(8)) +} + #[expect(clippy::expect_used)] fn get_or_create_runtime() -> &'static Runtime { RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_current_thread() + tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_count()) .enable_all() .build() .expect("failed to create honcho-ai blocking runtime") @@ -18,13 +26,13 @@ pub(crate) fn handle() -> Handle { get_or_create_runtime().handle().clone() } -#[expect(clippy::panic)] -pub(crate) fn block_on(future: F) -> F::Output { +pub(crate) fn block_on(future: F) -> crate::error::Result { match Handle::try_current() { - Ok(_) => panic!( + Ok(_) => Err(HonchoError::Configuration( "blocking::Honcho cannot be called from within an async runtime. \ Use the async Honcho client instead." - ), - Err(_) => get_or_create_runtime().block_on(future), + .to_string(), + )), + Err(_) => Ok(get_or_create_runtime().block_on(future)), } } diff --git a/src/blocking/session.rs b/src/blocking/session.rs index 3d78a4f..9bbc43d 100644 --- a/src/blocking/session.rs +++ b/src/blocking/session.rs @@ -88,67 +88,67 @@ impl Session { /// Refresh cached state from the server. pub fn refresh(&self) -> Result<()> { - block_on(self.inner.refresh()) + block_on(self.inner.refresh())? } /// Fetch and return metadata. pub fn get_metadata(&self) -> Result> { - block_on(self.inner.get_metadata()) + block_on(self.inner.get_metadata())? } /// Set metadata on the server. pub fn set_metadata(&self, metadata: HashMap) -> Result<()> { - block_on(self.inner.set_metadata(metadata)) + block_on(self.inner.set_metadata(metadata))? } /// Fetch and return configuration. pub fn get_configuration(&self) -> Result { - block_on(self.inner.get_configuration()) + block_on(self.inner.get_configuration())? } /// Set configuration on the server. pub fn set_configuration(&self, configuration: &SessionConfiguration) -> Result<()> { - block_on(self.inner.set_configuration(configuration)) + block_on(self.inner.set_configuration(configuration))? } /// Fetch configuration as a raw JSON map. pub fn get_configuration_raw(&self) -> Result> { - block_on(self.inner.get_configuration_raw()) + block_on(self.inner.get_configuration_raw())? } /// Set configuration from a raw JSON map. pub fn set_configuration_raw(&self, configuration: HashMap) -> Result<()> { - block_on(self.inner.set_configuration_raw(configuration)) + block_on(self.inner.set_configuration_raw(configuration))? } /// Add a single peer to this session. pub fn add_peer(&self, id: impl Into) -> Result<()> { - block_on(self.inner.add_peer(id)) + block_on(self.inner.add_peer(id))? } /// Add multiple peers. pub fn add_peers(&self, specs: impl IntoIterator>) -> Result<()> { - block_on(self.inner.add_peers(specs)) + block_on(self.inner.add_peers(specs))? } /// Set the complete peer list (replaces existing). pub fn set_peers(&self, specs: impl IntoIterator>) -> Result<()> { - block_on(self.inner.set_peers(specs)) + block_on(self.inner.set_peers(specs))? } /// Remove peers from this session. pub fn remove_peers(&self, ids: impl IntoIterator>) -> Result<()> { - block_on(self.inner.remove_peers(ids)) + block_on(self.inner.remove_peers(ids))? } /// List peers in this session. pub fn peers(&self) -> Result> { - block_on(self.inner.peers()).map(|peers| peers.into_iter().map(super::Peer::new).collect()) + block_on(self.inner.peers())?.map(|peers| peers.into_iter().map(super::Peer::new).collect()) } /// Get per-peer configuration. pub fn get_peer_configuration(&self, peer_id: &str) -> Result { - block_on(self.inner.get_peer_configuration(peer_id)) + block_on(self.inner.get_peer_configuration(peer_id))? } /// Set per-peer configuration. @@ -157,7 +157,7 @@ impl Session { /// create or add peers; use [`Session::add_peer`] or [`Session::add_peers`] /// first. If the peer is absent, the server may return 404/`NotFound`. pub fn set_peer_configuration(&self, peer_id: &str, config: &SessionPeerConfig) -> Result<()> { - block_on(self.inner.set_peer_configuration(peer_id, config)) + block_on(self.inner.set_peer_configuration(peer_id, config))? } /// Add messages to this session. @@ -165,7 +165,7 @@ impl Session { &self, messages: Vec, ) -> Result> { - block_on(self.inner.add_messages(messages)) + block_on(self.inner.add_messages(messages))? } /// List messages, collecting across pages. @@ -173,27 +173,27 @@ impl Session { block_on(async { let page = self.inner.messages().await?; super::iter::collect_all_pages(page).await - }) + })? } /// Delete this session. pub fn delete(&self) -> Result<()> { - block_on(self.inner.delete()) + block_on(self.inner.delete())? } /// Clone this session. pub fn clone_session(&self) -> Result { - block_on(self.inner.clone_session()).map(Session::new) + block_on(self.inner.clone_session())?.map(Session::new) } /// Clone this session up to a message. pub fn clone_session_with_message(&self, message_id: &str) -> Result { - block_on(self.inner.clone_session_with_message(message_id)).map(Session::new) + block_on(self.inner.clone_session_with_message(message_id))?.map(Session::new) } /// Get a single message by ID. pub fn get_message(&self, id: &str) -> Result { - block_on(self.inner.get_message(id)) + block_on(self.inner.get_message(id))? } /// Update a message's metadata. @@ -202,12 +202,12 @@ impl Session { id: &str, metadata: HashMap, ) -> Result { - block_on(self.inner.update_message(id, metadata)) + block_on(self.inner.update_message(id, metadata))? } /// Get session context. pub fn context(&self) -> Result { - block_on(self.inner.context()) + block_on(self.inner.context())? } /// Get session context with custom parameters. @@ -215,7 +215,7 @@ impl Session { &self, options: &crate::types::session::SessionContextOptions, ) -> Result { - block_on(self.inner.context_with_options(options)) + block_on(self.inner.context_with_options(options))? } /// Get a context builder for fine-grained control over session context parameters. @@ -227,12 +227,12 @@ impl Session { /// Get available summaries. pub fn summaries(&self) -> Result { - block_on(self.inner.summaries()) + block_on(self.inner.summaries())? } /// Search messages within this session. pub fn search(&self, query: &str) -> Result> { - block_on(self.inner.search(query)) + block_on(self.inner.search(query))? } /// Search messages within this session with custom options. @@ -240,12 +240,12 @@ impl Session { &self, options: &MessageSearchOptions, ) -> Result> { - block_on(self.inner.search_with_options(options)) + block_on(self.inner.search_with_options(options))? } /// Get a peer's representation scoped to this session. pub fn representation(&self, peer_id: &str) -> Result { - block_on(self.inner.representation(peer_id)) + block_on(self.inner.representation(peer_id))? } /// Get processing queue status for this session. @@ -254,7 +254,7 @@ impl Session { observer_id: Option<&str>, sender_id: Option<&str>, ) -> Result { - block_on(self.inner.queue_status(observer_id, sender_id)) + block_on(self.inner.queue_status(observer_id, sender_id))? } /// Begin a file upload to this session. @@ -401,10 +401,10 @@ impl BlockingUploadFileBuilder<'_> { /// Returns [`HonchoError::Validation`](crate::error::HonchoError::Validation) /// if no peer was set via `.peer()`. pub fn send(self) -> Result> { - let result = block_on(self.inner.send()); + let result = block_on(self.inner.send())?; if let Some(handle) = self.reader_handle { - let join_result = block_on(handle); + let join_result = block_on(handle)?; if result.is_ok() && let Err(join_error) = join_result { @@ -465,7 +465,7 @@ impl BlockingSessionRepresentationBuilder { /// Execute the request and return the representation. pub fn send(self) -> Result { - block_on(self.inner.send()) + block_on(self.inner.send())? } } @@ -552,6 +552,6 @@ impl BlockingSessionContextBuilder { /// Execute the request and return the session context. pub fn send(self) -> Result { - block_on(self.inner.send()) + block_on(self.inner.send())? } } diff --git a/src/client.rs b/src/client.rs index a54c8c9..461b171 100644 --- a/src/client.rs +++ b/src/client.rs @@ -10,7 +10,7 @@ use tokio::sync::OnceCell; use url::Url; use crate::error::{HonchoError, Result}; -use crate::http::client::{HttpClient, normalize_base_url}; +use crate::http::client::{DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, HttpClient, normalize_base_url}; use crate::http::routes; use crate::peer::Peer; use crate::session::{PeerSpec, Session}; @@ -159,8 +159,8 @@ impl Honcho { .base_url(resolved_base_url) .maybe_api_key(resolved_api_key) .maybe_http_client(params.http_client) - .timeout(params.timeout.unwrap_or(Duration::from_secs(60))) - .max_retries(params.max_retries.unwrap_or(2)) + .timeout(params.timeout.unwrap_or(DEFAULT_TIMEOUT)) + .max_retries(params.max_retries.unwrap_or(DEFAULT_MAX_RETRIES)) .default_headers(params.default_headers.unwrap_or_default()) .default_query(params.default_query.unwrap_or_default()) .build(), @@ -244,15 +244,10 @@ impl Honcho { /// Fetch workspace metadata from the server. pub async fn get_metadata(&self) -> Result> { - let body = crate::types::workspace::WorkspaceCreate { - id: self.inner.workspace_id.clone(), - metadata: None, - configuration: None, - }; let ws: Workspace = self .inner .http - .post(&routes::workspaces(), Some(&body), &[]) + .get(&routes::workspace(&self.inner.workspace_id)?, &[]) .await?; Ok(ws.metadata) } @@ -279,15 +274,10 @@ impl Honcho { /// } /// ``` pub async fn get_configuration(&self) -> Result { - let body = crate::types::workspace::WorkspaceCreate { - id: self.inner.workspace_id.clone(), - metadata: None, - configuration: None, - }; let ws: Workspace = self .inner .http - .post(&routes::workspaces(), Some(&body), &[]) + .get(&routes::workspace(&self.inner.workspace_id)?, &[]) .await?; Ok(ws.configuration) } @@ -322,15 +312,10 @@ impl Honcho { /// Use this when the server returns fields not yet represented in /// [`WorkspaceConfiguration`]. pub async fn get_configuration_raw(&self) -> Result> { - let body = crate::types::workspace::WorkspaceCreate { - id: self.inner.workspace_id.clone(), - metadata: None, - configuration: None, - }; let raw: serde_json::Value = self .inner .http - .post(&routes::workspaces(), Some(&body), &[]) + .get(&routes::workspace(&self.inner.workspace_id)?, &[]) .await?; match raw.get("configuration") { Some(serde_json::Value::Object(map)) => { @@ -457,6 +442,8 @@ impl Honcho { /// Refresh workspace state by re-fetching metadata and configuration. /// + /// Issues a single `GET /v3/workspaces/{id}` request. + /// /// # Examples /// /// ```no_run @@ -467,8 +454,11 @@ impl Honcho { /// # } /// ``` pub async fn refresh(&self) -> Result<()> { - let _ = self.get_metadata().await?; - let _ = self.get_configuration().await?; + let _: Workspace = self + .inner + .http + .get(&routes::workspace(&self.inner.workspace_id)?, &[]) + .await?; Ok(()) } diff --git a/src/conclusion.rs b/src/conclusion.rs index f673e0e..1bb739c 100644 --- a/src/conclusion.rs +++ b/src/conclusion.rs @@ -15,6 +15,7 @@ use crate::types::conclusion::{ConclusionBatchCreate, ConclusionCreate}; use crate::types::conclusion::{ConclusionFilters, ConclusionGet, ConclusionQuery}; use crate::types::dialectic::RepresentationResponse; use crate::types::pagination::paginate_post; +use crate::types::session::validate_search_params; pub(crate) struct ConclusionInner { workspace_id: String, @@ -278,8 +279,7 @@ impl ConclusionScope { /// Create one or more conclusions in this scope. /// /// Auto-injects `observer_id` and `observed_id` from the scope. If more - /// than 100 conclusions are provided they are sent in a single request. - /// If the server enforces a limit, the request will fail. + /// than 100 conclusions are provided they are sent in batches of 100. /// /// # Examples /// @@ -430,7 +430,7 @@ impl ConclusionScope { /// /// # Errors /// - /// Returns [`HonchoError::Validation`] if `top_k` ∉ [1, 100] + /// Returns [`HonchoError::Validation`] if `query` is empty, `top_k` ∉ [1, 100], /// or `distance` ∉ [0.0, 1.0]. Returns [`HonchoError::Server`] on /// transport or API errors. pub fn query(&self, query: impl Into) -> QueryConclusionsBuilder { @@ -574,27 +574,11 @@ impl ConclusionRepresentationBuilder { /// Returns [`HonchoError::Validation`] /// if `search_top_k`, `search_max_distance`, or `max_conclusions` are out of range. pub async fn send(self) -> Result { - if let Some(k) = self.search_top_k - && !(1..=100).contains(&k) - { - return Err(crate::error::HonchoError::Validation(format!( - "search_top_k must be between 1 and 100, got {k}" - ))); - } - if let Some(d) = self.search_max_distance - && !(0.0..=1.0).contains(&d) - { - return Err(crate::error::HonchoError::Validation(format!( - "search_max_distance must be between 0.0 and 1.0, got {d}" - ))); - } - if let Some(c) = self.max_conclusions - && !(1..=100).contains(&c) - { - return Err(crate::error::HonchoError::Validation(format!( - "max_conclusions must be between 1 and 100, got {c}" - ))); - } + validate_search_params( + self.search_top_k, + self.search_max_distance, + self.max_conclusions, + )?; let params = crate::types::peer::PeerRepresentationGet { session_id: None, @@ -771,22 +755,21 @@ impl QueryConclusionsBuilder { /// /// # Errors /// - /// Returns [`HonchoError::Validation`] if `top_k` ∉ [1, 100] + /// Returns [`HonchoError::Validation`] if `query` is empty, `top_k` ∉ [1, 100], /// or `distance` ∉ [0.0, 1.0]. pub async fn send(self) -> Result> { - if !(1..=100).contains(&self.top_k) { - return Err(HonchoError::Validation(format!( - "top_k must be between 1 and 100, got {}", - self.top_k - ))); - } - if let Some(d) = self.distance - && !(0.0..=1.0).contains(&d) - { - return Err(HonchoError::Validation(format!( - "distance must be between 0.0 and 1.0, got {d}" - ))); + if self.query.is_empty() { + return Err(HonchoError::Validation( + "query must not be empty".to_string(), + )); } + validate_search_params(Some(self.top_k), self.distance, None).map_err(|e| { + if let HonchoError::Validation(msg) = &e { + HonchoError::Validation(msg.replace("search_max_distance", "distance")) + } else { + e + } + })?; let body = ConclusionQuery::builder() .query(self.query) .top_k(self.top_k) @@ -1314,6 +1297,16 @@ mod tests { assert_eq!(results.len(), 1); } + #[tokio::test] + async fn query_validates_empty_query() { + let scope = + ConclusionScope::new(test_http(), "ws".to_owned(), "a".to_owned(), "b".to_owned()); + + let err = scope.query("").send().await.unwrap_err(); + assert!(matches!(err, HonchoError::Validation(_))); + assert_eq!(err.code(), "validation_error"); + } + #[tokio::test] async fn query_validates_top_k_range() { let scope = diff --git a/src/error.rs b/src/error.rs index f1efa33..aecd5e0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -110,6 +110,21 @@ pub enum HonchoError { /// Validation error (e.g. duplicate inputs, invalid arguments). #[error("Validation error: {0}")] Validation(String), + /// Partial failure in a chunked batch operation. + /// + /// Some chunks succeeded before an error occurred. The `messages` field + /// contains the successfully created messages from earlier chunks, and + /// `error` holds the underlying error that caused the failure. + #[error("Partial failure after {sent} messages: {error}")] + PartialFailure { + /// Messages that were successfully created before the failure. + messages: Vec, + /// The number of messages successfully sent. + sent: usize, + /// The underlying error that caused the partial failure. + #[source] + error: Box, + }, } impl HonchoError { @@ -135,6 +150,7 @@ impl HonchoError { Self::Io(_) => "io_error", Self::Configuration(_) => "configuration_error", Self::Validation(_) => "validation_error", + Self::PartialFailure { .. } => "partial_failure", } } @@ -157,6 +173,7 @@ impl HonchoError { | Self::Io(_) | Self::Configuration(_) | Self::Validation(_) => None, + Self::PartialFailure { error, .. } => error.status_code(), } } @@ -176,6 +193,26 @@ impl HonchoError { } } + /// Returns `true` if this is a partial failure with some successful messages. + #[must_use] + pub fn is_partial_failure(&self) -> bool { + matches!(self, Self::PartialFailure { .. }) + } + + /// Extract the partial failure data, consuming the error. + /// + /// Returns `Some((messages, error))` if this is a `PartialFailure`, + /// `None` otherwise. + #[must_use] + pub fn into_partial_failure(self) -> Option<(Vec, Box)> { + match self { + Self::PartialFailure { + messages, error, .. + } => Some((messages, error)), + _ => None, + } + } + /// Returns the human-readable error message. #[must_use] #[allow(clippy::match_same_arms)] @@ -197,6 +234,7 @@ impl HonchoError { Self::Decode { .. } => "failed to decode response", Self::Configuration(s) => s, Self::Validation(s) => s, + Self::PartialFailure { error, .. } => error.message(), } } } @@ -214,26 +252,24 @@ pub fn parse_error_body(body: &[u8]) -> (String, Option) { return (msg, None); }; - let full_body = Some(value.clone()); - if let Some(obj) = value.as_object() { if let Some(detail) = obj.get("detail").and_then(|v| v.as_str()) { - return (detail.to_string(), full_body); + return (detail.to_string(), Some(value)); } if let Some(message) = obj.get("message").and_then(|v| v.as_str()) { - return (message.to_string(), full_body); + return (message.to_string(), Some(value)); } if let Some(error) = obj.get("error").and_then(|v| v.as_str()) { - return (error.to_string(), full_body); + return (error.to_string(), Some(value)); } - return (value.to_string(), full_body); + return (value.to_string(), Some(value)); } if let Some(s) = value.as_str() { - return (s.to_string(), full_body); + return (s.to_string(), Some(value)); } - (value.to_string(), full_body) + (value.to_string(), Some(value)) } /// Parse a Retry-After header value. diff --git a/src/http/client.rs b/src/http/client.rs index d5bddc4..29863e3 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -1,5 +1,5 @@ use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use chrono::Utc; use reqwest::Method; @@ -11,8 +11,17 @@ use url::Url; use crate::error::{self, HonchoError, Result}; use crate::http::decode; -const DEFAULT_MAX_RETRIES: u32 = 2; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +/// Returns `true` for HTTP methods safe to retry (idempotent). +/// POST and PATCH are excluded — retrying them risks duplicates. +fn is_idempotent(method: &Method) -> bool { + matches!( + *method, + Method::GET | Method::HEAD | Method::DELETE | Method::PUT | Method::OPTIONS + ) +} + +pub(crate) const DEFAULT_MAX_RETRIES: u32 = 2; +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); const INITIAL_RETRY_DELAY: Duration = Duration::from_millis(500); pub(crate) fn normalize_base_url(base_url: &str) -> Result { @@ -86,6 +95,19 @@ impl HttpClient { self.inner.base_url.to_string() } + /// Build a full URL by appending `path` to the base URL. + /// + /// Unlike [`Url::join`], an absolute `path` (e.g. `/v3/workspaces`) does NOT + /// replace the base URL's path component. Instead the path is appended as-is. + /// This is required because route helpers in [`crate::http::routes`] return + /// absolute paths like `/v3/...`. + fn build_url(&self, path: &str) -> Result { + let base = self.inner.base_url.as_str().trim_end_matches('/'); + let path = path.strip_prefix('/').unwrap_or(path); + Url::parse(&format!("{base}/{path}")) + .map_err(|e| HonchoError::Configuration(format!("failed to build URL: {e}"))) + } + pub fn from_params(params: HttpClientParams) -> Result { let base_url = normalize_base_url(¶ms.base_url)?; Self::from_params_with_base_url(params, base_url) @@ -154,11 +176,7 @@ impl HttpClient { TBody: Serialize + ?Sized, TResp: DeserializeOwned + 'static, { - let url = self - .inner - .base_url - .join(path) - .map_err(|e| HonchoError::Configuration(format!("failed to join URL path: {e}")))?; + let url = self.build_url(path)?; let merged_query: Vec<(&str, &str)> = self .inner @@ -197,11 +215,12 @@ impl HttpClient { HonchoError::Transport(e) }; - let should_retry = !matches!(error, HonchoError::Transport(_)) + let should_retry = is_idempotent(&method) + && !matches!(error, HonchoError::Transport(_)) && attempt < self.inner.max_retries; if should_retry { - tokio::time::sleep(delay_for_attempt(attempt)).await; + tokio::time::sleep(jittered_delay(attempt)).await; attempt += 1; continue; } @@ -236,13 +255,14 @@ impl HttpClient { }; let api_error = error::from_response(status, &headers, &body_bytes, Utc::now()); - let is_retryable = matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504); + let is_retryable = + is_idempotent(&method) && matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504); if is_retryable && attempt < self.inner.max_retries { let retry_after = headers .get("retry-after") .and_then(|v| error::parse_retry_after(v, Utc::now())); - let delay = retry_after.unwrap_or_else(|| delay_for_attempt(attempt)); + let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt)); tokio::time::sleep(delay).await; attempt += 1; continue; @@ -335,11 +355,7 @@ impl HttpClient { F: Fn() -> Fut, Fut: std::future::Future>, { - let url = self - .inner - .base_url - .join(path) - .map_err(|e| HonchoError::Configuration(format!("failed to join URL path: {e}")))?; + let url = self.build_url(path)?; let merged_query: Vec<(&str, &str)> = self .inner @@ -380,11 +396,12 @@ impl HttpClient { HonchoError::Transport(e) }; - let should_retry = !matches!(error, HonchoError::Transport(_)) + let should_retry = is_idempotent(&method) + && !matches!(error, HonchoError::Transport(_)) && attempt < self.inner.max_retries; if should_retry { - tokio::time::sleep(delay_for_attempt(attempt)).await; + tokio::time::sleep(jittered_delay(attempt)).await; attempt += 1; continue; } @@ -419,13 +436,14 @@ impl HttpClient { }; let api_error = error::from_response(status, &headers, &body_bytes, Utc::now()); - let is_retryable = matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504); + let is_retryable = + is_idempotent(&method) && matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504); if is_retryable && attempt < self.inner.max_retries { let retry_after = headers .get("retry-after") .and_then(|v| error::parse_retry_after(v, Utc::now())); - let delay = retry_after.unwrap_or_else(|| delay_for_attempt(attempt)); + let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt)); tokio::time::sleep(delay).await; attempt += 1; continue; @@ -442,11 +460,7 @@ impl HttpClient { body: Option<&serde_json::Value>, query: &[(&str, &str)], ) -> Result { - let url = self - .inner - .base_url - .join(path) - .map_err(|e| HonchoError::Configuration(format!("failed to join URL path: {e}")))?; + let url = self.build_url(path)?; let merged_query: Vec<(&str, &str)> = self .inner @@ -527,6 +541,27 @@ pub fn delay_for_attempt(attempt: u32) -> Duration { .min(Duration::from_secs(60)) } +/// Returns `delay_for_attempt(attempt)` with uniform jitter applied. +/// +/// The returned duration is in the half-open range `[delay/2, delay)` to avoid +/// thundering herd when many clients retry simultaneously. Jitter entropy comes +/// from the wall-clock sub-second nanoseconds — no external RNG crate needed. +fn jittered_delay(attempt: u32) -> Duration { + let base = delay_for_attempt(attempt); + // Use the sub-second nanoseconds of the wall clock as a cheap pseudo-random + // source. This varies on every call, giving a roughly uniform distribution. + let entropy = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|e| e.duration()) + .subsec_nanos(); + // Mix the entropy to avoid issues on platforms with coarse clock resolutions + // (e.g., microsecond or millisecond clocks where subsec_nanos is a multiple of 500). + let scrambled = entropy.wrapping_mul(1_103_515_245).wrapping_add(12_345); + // Map to [0.5, 1.0): factor in [500, 999], scaled by 1000. + let jitter_factor = 500 + (scrambled % 500); + base * jitter_factor / 1000 +} + #[cfg(test)] mod tests { #![allow( @@ -640,6 +675,22 @@ mod tests { assert_eq!(result.id, "p1"); } + #[tokio::test] + async fn base_url_with_path_prefix_preserved() { + let server = MockServer::start().await; + let base = format!("{}/api", server.uri()); + let client = HttpClient::from_params(HttpClient::builder().base_url(base).build()).unwrap(); + + Mock::given(method("GET")) + .and(path("/api/v3/test")) + .respond_with(ResponseTemplate::new(200).set_body_json(peer_json())) + .mount(&server) + .await; + + let result: Peer = client.get("/v3/test", &[]).await.unwrap(); + assert_eq!(result.id, "p1"); + } + #[tokio::test] async fn builder_default_max_retries_is_2() { let server = MockServer::start().await; @@ -1038,6 +1089,49 @@ mod tests { assert!(client.get::("/v3/test", &[]).await.is_err()); } + #[rstest::rstest] + #[case(429)] + #[case(500)] + #[case(502)] + #[case(503)] + #[case(504)] + #[tokio::test] + async fn no_retry_post_on_retryable_status(#[case] status: u16) { + let server = MockServer::start().await; + let client = HttpClient::from_params( + HttpClient::builder() + .base_url(server.uri()) + .max_retries(5) + .build(), + ) + .unwrap(); + + // POST is non-idempotent — no retry even on retryable status codes + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(status)) + .expect(1) + .mount(&server) + .await; + + let body = serde_json::json!({"key": "value"}); + let err = client + .post::<_, Peer>("/v3/test", Some(&body), &[]) + .await + .unwrap_err(); + // 429 maps to RateLimit, 5xx maps to Server + if status == 429 { + assert!( + matches!(err, HonchoError::RateLimit { .. }), + "expected RateLimit, got {err:?}" + ); + } else { + assert!( + matches!(err, HonchoError::Server { status: s, .. } if s == status), + "expected Server({status}), got {err:?}" + ); + } + } + // ── Backoff ────────────────────────────────────────────────────────── #[test] @@ -1054,6 +1148,23 @@ mod tests { assert_eq!(delay_for_attempt(10), Duration::from_secs(60)); } + #[test] + fn jittered_delay_within_expected_range() { + // Many samples per attempt so we exercise the entropy distribution and + // catch any factor that escapes the half-open range [0.5, 1.0). + for attempt in 0..6 { + let base = delay_for_attempt(attempt); + for _ in 0..1000 { + let jittered = jittered_delay(attempt); + assert!( + jittered >= base / 2 && jittered < base, + "jittered={jittered:?} not in [{:?}, {base:?}) for attempt={attempt}", + base / 2, + ); + } + } + } + #[tokio::test] async fn retry_after_zero_retries_immediately() { let server = MockServer::start().await; @@ -1232,9 +1343,10 @@ mod tests { let server = MockServer::start().await; let client = make_client(&server); + // POST is non-idempotent — no retry, only 1 attempt Mock::given(method("POST")) .respond_with(ResponseTemplate::new(503)) - .expect(3) + .expect(1) .mount(&server) .await; @@ -1259,30 +1371,37 @@ mod tests { #[case(503)] #[case(504)] #[tokio::test] - async fn retries_multipart_on_retryable_status(#[case] status: u16) { + async fn no_retry_multipart_on_retryable_status_for_post(#[case] status: u16) { let server = MockServer::start().await; let client = make_client(&server); - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200).set_body_json(workspace_json())) - .mount(&server) - .await; - + // POST is non-idempotent — no retry even on retryable status codes Mock::given(method("POST")) .respond_with(ResponseTemplate::new(status)) - .up_to_n_times(1) + .expect(1) .mount(&server) .await; - let result: Workspace = client - .post_multipart( + let err = client + .post_multipart::( "/v3/upload", || async { Ok(reqwest::multipart::Form::new().text("key", "value")) }, &[], ) .await - .unwrap(); - assert_eq!(result.id, "ws_abc123"); + .unwrap_err(); + // 429 maps to RateLimit, 5xx maps to Server + if status == 429 { + assert!( + matches!(err, HonchoError::RateLimit { .. }), + "expected RateLimit, got {err:?}" + ); + } else { + assert!( + matches!(err, HonchoError::Server { status: s, .. } if s == status), + "expected Server({status}), got {err:?}" + ); + } } // ── Streaming ──────────────────────────────────────────────────────── diff --git a/src/http/sse.rs b/src/http/sse.rs index a2d8587..49e803e 100644 --- a/src/http/sse.rs +++ b/src/http/sse.rs @@ -176,7 +176,10 @@ impl SseParser { tracing::warn!( "Failed to decode streaming chunk: {} (data: {})", e, - &json_str[..json_str.len().min(100)] + &json_str[..json_str + .char_indices() + .nth(100) + .map_or(json_str.len(), |(i, _)| i)] ); let _ = &e; return None; diff --git a/src/peer.rs b/src/peer.rs index 695f380..a3a9f7f 100644 --- a/src/peer.rs +++ b/src/peer.rs @@ -166,14 +166,8 @@ impl Peer { /// ``` #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))] pub async fn get_metadata(&self) -> Result> { - self.refresh().await?; - Ok(self - .inner - .metadata - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - .unwrap_or_default()) + let resp = self.fetch_and_update_cache().await?; + Ok(resp.metadata) } /// Set the peer's metadata on the server and update the cache. @@ -220,14 +214,8 @@ impl Peer { /// ``` #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(peer_id = self.inner.id.as_str())))] pub async fn get_configuration(&self) -> Result { - self.refresh().await?; - Ok(self - .inner - .configuration - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - .unwrap_or_default()) + let resp = self.fetch_and_update_cache().await?; + Ok(map_to_peer_config(&resp.configuration)?.unwrap_or_default()) } /// Set the peer's configuration on the server and update the cache. @@ -737,7 +725,7 @@ impl Peer { .http .post( &routes::peer_search(&self.inner.workspace_id, &self.inner.id)?, - Some(&options), + Some(options), &[], ) .await?; @@ -1459,9 +1447,8 @@ fn validate_search_params( } fn map_to_peer_config(map: &HashMap) -> Result> { - let obj: serde_json::Map = - map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - serde_json::from_value(Value::Object(obj)) + let val = serde_json::to_value(map).map_err(|e| HonchoError::Configuration(e.to_string()))?; + serde_json::from_value(val) .map(Some) .map_err(|e| HonchoError::Configuration(e.to_string())) } diff --git a/src/session.rs b/src/session.rs index 119c5da..a0a4fae 100644 --- a/src/session.rs +++ b/src/session.rs @@ -814,7 +814,10 @@ impl Session { /// /// If more than 100 messages are provided, they are automatically chunked /// into batches of 100 and sent as separate requests. On chunk failure the - /// already-sent messages are **not** rolled back (non-atomic). + /// already-sent messages are **not** rolled back (non-atomic). When a chunk + /// fails after earlier chunks succeeded, the error is a + /// [`HonchoError::PartialFailure`] containing the successfully created + /// messages from the earlier chunks. /// /// # Examples /// @@ -844,9 +847,27 @@ impl Session { for chunk in messages.chunks(100) { let batch = chunk.to_vec(); let body = crate::types::message::MessageBatchCreate { messages: batch }; - let batch_responses: Vec = - self.inner.http.post(&route, Some(&body), &[]).await?; - all.extend(batch_responses); + match self + .inner + .http + .post::<_, Vec>(&route, Some(&body), &[]) + .await + { + Ok(batch_responses) => all.extend(batch_responses), + Err(e) if all.is_empty() => return Err(e), + Err(e) => { + let sent = all.len(); + let partial: Vec = all + .into_iter() + .map(|r| Message::from_raw(self.inner.workspace_id.clone(), r)) + .collect(); + return Err(HonchoError::PartialFailure { + messages: partial, + sent, + error: Box::new(e), + }); + } + } } all }; @@ -1125,48 +1146,7 @@ impl Session { ) -> Result { options.validate()?; let route = routes::session_context(&self.inner.workspace_id, &self.inner.id)?; - let mut params: Vec<(&str, String)> = vec![ - ( - "summary", - if options.summary { "true" } else { "false" }.to_string(), - ), - ( - "limit_to_session", - if options.limit_to_session { - "true" - } else { - "false" - } - .to_string(), - ), - ]; - if let Some(ref v) = options.tokens { - params.push(("tokens", v.to_string())); - } - if let Some(ref v) = options.peer_target { - params.push(("peer_target", v.clone())); - } - if let Some(ref v) = options.peer_perspective { - params.push(("peer_perspective", v.clone())); - } - if let Some(ref v) = options.search_query { - params.push(("search_query", v.clone())); - } - if let Some(ref v) = options.search_top_k { - params.push(("search_top_k", v.to_string())); - } - if let Some(ref v) = options.search_max_distance { - params.push(("search_max_distance", v.to_string())); - } - if let Some(ref v) = options.include_most_frequent { - params.push(( - "include_most_frequent", - if *v { "true" } else { "false" }.to_string(), - )); - } - if let Some(ref v) = options.max_conclusions { - params.push(("max_conclusions", v.to_string())); - } + let params = options.to_query_params(); let refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); self.inner.http.get(&route, &refs).await } @@ -1491,27 +1471,11 @@ impl SessionRepresentationBuilder { /// or `max_conclusions` are out of range. #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), fields(session_id = self.session_id.as_str(), peer_id = self.peer_id.as_str())))] pub async fn send(self) -> Result { - if let Some(k) = self.search_top_k - && !(1..=100).contains(&k) - { - return Err(HonchoError::Validation(format!( - "search_top_k must be between 1 and 100, got {k}" - ))); - } - if let Some(d) = self.search_max_distance - && !(0.0..=1.0).contains(&d) - { - return Err(HonchoError::Validation(format!( - "search_max_distance must be between 0.0 and 1.0, got {d}" - ))); - } - if let Some(c) = self.max_conclusions - && !(1..=100).contains(&c) - { - return Err(HonchoError::Validation(format!( - "max_conclusions must be between 1 and 100, got {c}" - ))); - } + crate::types::session::validate_search_params( + self.search_top_k, + self.search_max_distance, + self.max_conclusions, + )?; let params = crate::types::peer::PeerRepresentationGet { session_id: Some(self.session_id), @@ -1650,48 +1614,7 @@ impl SessionContextBuilder { options.validate()?; let route = routes::session_context(&self.workspace_id, &self.session_id)?; - let mut params: Vec<(&str, String)> = vec![ - ( - "summary", - if options.summary { "true" } else { "false" }.to_string(), - ), - ( - "limit_to_session", - if options.limit_to_session { - "true" - } else { - "false" - } - .to_string(), - ), - ]; - if let Some(v) = options.tokens { - params.push(("tokens", v.to_string())); - } - if let Some(ref v) = options.peer_target { - params.push(("peer_target", v.clone())); - } - if let Some(ref v) = options.peer_perspective { - params.push(("peer_perspective", v.clone())); - } - if let Some(ref v) = options.search_query { - params.push(("search_query", v.clone())); - } - if let Some(v) = options.search_top_k { - params.push(("search_top_k", v.to_string())); - } - if let Some(v) = options.search_max_distance { - params.push(("search_max_distance", v.to_string())); - } - if let Some(v) = options.include_most_frequent { - params.push(( - "include_most_frequent", - if v { "true" } else { "false" }.to_string(), - )); - } - if let Some(v) = options.max_conclusions { - params.push(("max_conclusions", v.to_string())); - } + let params = options.to_query_params(); let refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect(); self.http.get(&route, &refs).await } diff --git a/src/types/pagination.rs b/src/types/pagination.rs index ccdd636..f6db87f 100644 --- a/src/types/pagination.rs +++ b/src/types/pagination.rs @@ -19,7 +19,8 @@ type PageFetcher = Arc< /// /// Deserializes directly from paginated JSON responses. Convert to /// [`Page`] for lazy-fetch and transform support via -/// [`Page::from_page_response`]. +/// [`Page::from_page_response`]. Use [`PageResponse::with_fetcher`] to +/// attach a fetcher in one step without cloning the items vector. #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct PageResponse { @@ -61,6 +62,33 @@ impl Default for PageResponse { } } +#[allow(clippy::mismatching_type_param_order)] +impl PageResponse { + /// Convert this response into a [`Page`] with an attached fetcher, + /// without cloning the items vector. + /// + /// This is more efficient than `Page::from_page_response(resp).with_fetcher(f)` + /// which clones the items during the `with_fetcher` step. + #[must_use] + pub fn with_fetcher(self, fetcher: F) -> Page + where + F: Fn(u64) -> Fut + Send + Sync + 'static, + Fut: Future>> + Send + 'static, + { + Page { + inner: Arc::new(PageInner { + items: self.items, + total: self.total, + page: self.page, + size: self.size, + pages: self.pages, + next_fetcher: Some(Arc::new(move |pn| Box::pin(fetcher(pn)))), + transform: Arc::new(std::convert::identity), + }), + } + } +} + /// A page of results with lazy next-page fetching and item transform support. /// /// `Page` holds raw items of type `TRaw` and lazily transforms @@ -289,6 +317,30 @@ impl Page { pub fn from_page_response(resp: PageResponse) -> Self { Self::new(resp.items, resp.total, resp.page, resp.size, resp.pages) } + + /// Returns a slice of the raw items without cloning. + /// + /// This is the identity-transform equivalent of [`items`](Page::items) + /// that avoids allocating a new `Vec`. + #[must_use] + pub fn items_ref(&self) -> &[TRaw] { + &self.inner.items + } + + /// Consume the page and return the items, avoiding cloning when possible. + /// + /// If this is the only reference to the inner data, the items are moved + /// out without cloning. Otherwise, falls back to cloning each item. + #[must_use] + pub fn into_items(self) -> Vec + where + TRaw: Clone, + { + match Arc::try_unwrap(self.inner) { + Ok(inner) => inner.items, + Err(arc) => arc.items.clone(), + } + } } #[allow(clippy::mismatching_type_param_order)] @@ -401,13 +453,12 @@ where } let resp: PageResponse = http.post(route, body, &query).await?; - let result = Page::from_page_response(resp); let http_clone = http.clone(); let route_owned = route.to_owned(); let body_clone = body.cloned(); - Ok(result.with_fetcher(move |page_num| { + Ok(resp.with_fetcher(move |page_num| { let http = http_clone.clone(); let route = route_owned.clone(); let body = body_clone.clone(); diff --git a/src/types/session.rs b/src/types/session.rs index 777fb97..a9ece40 100644 --- a/src/types/session.rs +++ b/src/types/session.rs @@ -198,6 +198,40 @@ pub(crate) const SEARCH_MAX_DISTANCE_MAX: f64 = 1.0; pub(crate) const MAX_CONCLUSIONS_MIN: u32 = 1; pub(crate) const MAX_CONCLUSIONS_MAX: u32 = 100; +/// Validate optional search/conclusion parameters. +/// +/// Checks that `search_top_k` is in 1–100, `search_max_distance` is in 0.0–1.0, +/// and `max_conclusions` is in 1–100. Returns `Err(HonchoError::Validation)` on +/// any out-of-range value. +pub(crate) fn validate_search_params( + search_top_k: Option, + search_max_distance: Option, + max_conclusions: Option, +) -> std::result::Result<(), crate::error::HonchoError> { + if let Some(k) = search_top_k + && !(SEARCH_TOP_K_MIN..=SEARCH_TOP_K_MAX).contains(&k) + { + return Err(crate::error::HonchoError::Validation(format!( + "search_top_k must be between {SEARCH_TOP_K_MIN} and {SEARCH_TOP_K_MAX}, got {k}" + ))); + } + if let Some(d) = search_max_distance + && !(SEARCH_MAX_DISTANCE_MIN..=SEARCH_MAX_DISTANCE_MAX).contains(&d) + { + return Err(crate::error::HonchoError::Validation(format!( + "search_max_distance must be between {SEARCH_MAX_DISTANCE_MIN} and {SEARCH_MAX_DISTANCE_MAX}, got {d}" + ))); + } + if let Some(c) = max_conclusions + && !(MAX_CONCLUSIONS_MIN..=MAX_CONCLUSIONS_MAX).contains(&c) + { + return Err(crate::error::HonchoError::Validation(format!( + "max_conclusions must be between {MAX_CONCLUSIONS_MIN} and {MAX_CONCLUSIONS_MAX}, got {c}" + ))); + } + Ok(()) +} + impl SessionContextOptions { /// Validate cross-field constraints. pub fn validate(&self) -> std::result::Result<(), crate::error::HonchoError> { @@ -211,27 +245,11 @@ impl SessionContextOptions { "search_query requires peer_target to be set".into(), )); } - if let Some(k) = self.search_top_k - && !(SEARCH_TOP_K_MIN..=SEARCH_TOP_K_MAX).contains(&k) - { - return Err(crate::error::HonchoError::Validation(format!( - "search_top_k must be between {SEARCH_TOP_K_MIN} and {SEARCH_TOP_K_MAX}" - ))); - } - if let Some(d) = self.search_max_distance - && !(SEARCH_MAX_DISTANCE_MIN..=SEARCH_MAX_DISTANCE_MAX).contains(&d) - { - return Err(crate::error::HonchoError::Validation(format!( - "search_max_distance must be between {SEARCH_MAX_DISTANCE_MIN} and {SEARCH_MAX_DISTANCE_MAX}" - ))); - } - if let Some(m) = self.max_conclusions - && !(MAX_CONCLUSIONS_MIN..=MAX_CONCLUSIONS_MAX).contains(&m) - { - return Err(crate::error::HonchoError::Validation(format!( - "max_conclusions must be between {MAX_CONCLUSIONS_MIN} and {MAX_CONCLUSIONS_MAX}" - ))); - } + validate_search_params( + self.search_top_k, + self.search_max_distance, + self.max_conclusions, + )?; if let Some(t) = self.tokens && t == 0 { @@ -241,6 +259,52 @@ impl SessionContextOptions { } Ok(()) } + + pub(crate) fn to_query_params(&self) -> Vec<(&str, String)> { + let mut params: Vec<(&str, String)> = vec![ + ( + "summary", + if self.summary { "true" } else { "false" }.to_string(), + ), + ( + "limit_to_session", + if self.limit_to_session { + "true" + } else { + "false" + } + .to_string(), + ), + ]; + if let Some(v) = self.tokens { + params.push(("tokens", v.to_string())); + } + if let Some(ref v) = self.peer_target { + params.push(("peer_target", v.clone())); + } + if let Some(ref v) = self.peer_perspective { + params.push(("peer_perspective", v.clone())); + } + if let Some(ref v) = self.search_query { + params.push(("search_query", v.clone())); + } + if let Some(v) = self.search_top_k { + params.push(("search_top_k", v.to_string())); + } + if let Some(v) = self.search_max_distance { + params.push(("search_max_distance", v.to_string())); + } + if let Some(v) = self.include_most_frequent { + params.push(( + "include_most_frequent", + if v { "true" } else { "false" }.to_string(), + )); + } + if let Some(v) = self.max_conclusions { + params.push(("max_conclusions", v.to_string())); + } + params + } } fn default_true() -> bool { diff --git a/tests/blocking_runtime_guard.rs b/tests/blocking_runtime_guard.rs index 6d2c627..b3bff2c 100644 --- a/tests/blocking_runtime_guard.rs +++ b/tests/blocking_runtime_guard.rs @@ -9,8 +9,15 @@ fn honcho_new_does_not_panic() { #[cfg(feature = "blocking")] #[tokio::test] -#[should_panic(expected = "cannot be called from within an async runtime")] -async fn blocking_force_ensure_inside_async_panics() { +async fn blocking_force_ensure_inside_async_returns_error() { let honcho = honcho_ai::blocking::Honcho::new("http://localhost:9999", "ws").unwrap(); - let _ = honcho.force_ensure(); + let err = honcho.force_ensure().unwrap_err(); + assert!( + matches!(err, honcho_ai::error::HonchoError::Configuration(_)), + "expected Configuration error, got {err:?}" + ); + assert!( + err.to_string() + .contains("cannot be called from within an async runtime") + ); } diff --git a/tests/blocking_smoke.rs b/tests/blocking_smoke.rs index b64af16..c7b3830 100644 --- a/tests/blocking_smoke.rs +++ b/tests/blocking_smoke.rs @@ -652,9 +652,8 @@ async fn blocking_peer_representation_builder_with_options() { async fn blocking_client_get_configuration() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(serde_json::json!({"id": "ws1"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/ws1")) .respond_with(ResponseTemplate::new(200).set_body_json(ws_json_with_config())) .mount(&server) .await; @@ -926,9 +925,8 @@ async fn blocking_client_delete_workspace() { async fn blocking_client_get_metadata() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(serde_json::json!({"id": "ws1"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/ws1")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": "ws1", "metadata": {"env": "test"}, @@ -971,9 +969,8 @@ async fn blocking_client_set_metadata() { async fn blocking_client_refresh() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(serde_json::json!({"id": "ws1"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/ws1")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": "ws1", "metadata": {"env": "test"}, diff --git a/tests/client_ensure_workspace.rs b/tests/client_ensure_workspace.rs index b595308..ca5e4a6 100644 --- a/tests/client_ensure_workspace.rs +++ b/tests/client_ensure_workspace.rs @@ -89,8 +89,10 @@ async fn ensure_workspace_failure_retries_next_call() { Mock::given(method("POST")) .respond_with(move |_: &Request| { + // POST is non-idempotent, so it is not retried within a single call. + // The first call fails on 503; the second call succeeds. let n = call_count.fetch_add(1, Ordering::SeqCst); - if n < 3 { + if n < 1 { ResponseTemplate::new(503) } else { ResponseTemplate::new(200).set_body_json(&ws_json) diff --git a/tests/client_metadata.rs b/tests/client_metadata.rs index a60e390..e1db134 100644 --- a/tests/client_metadata.rs +++ b/tests/client_metadata.rs @@ -28,15 +28,14 @@ fn workspace_response( } #[tokio::test] -async fn gets_workspace_metadata_by_post_get_or_create() { +async fn gets_workspace_metadata_by_get() { let server = MockServer::start().await; let metadata = json!({"env": "production", "team": "core"}); let response = workspace_response(metadata, json!({})); - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "test-ws"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/test-ws")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(&server) .await; @@ -54,9 +53,8 @@ async fn get_metadata_empty_when_no_metadata() { let response = workspace_response(json!({}), json!({})); - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "test-ws"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/test-ws")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(&server) .await; @@ -112,15 +110,14 @@ async fn set_metadata_server_error_returns_error() { } #[tokio::test] -async fn gets_workspace_configuration_by_post_get_or_create() { +async fn gets_workspace_configuration_by_get() { let server = MockServer::start().await; let config = json!({"reasoning": {"enabled": true}}); let response = workspace_response(json!({}), config); - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "test-ws"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/test-ws")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(&server) .await; @@ -137,9 +134,8 @@ async fn get_configuration_empty_when_no_configuration() { let response = workspace_response(json!({}), json!({})); - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "test-ws"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/test-ws")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(&server) .await; @@ -154,15 +150,14 @@ async fn get_configuration_empty_when_no_configuration() { } #[tokio::test] -async fn gets_workspace_configuration_raw_by_post_get_or_create() { +async fn gets_workspace_configuration_raw_by_get() { let server = MockServer::start().await; let config = json!({"unknown_future_field": {"enabled": true}}); let response = workspace_response(json!({}), config); - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "test-ws"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/test-ws")) .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(&server) .await; @@ -205,12 +200,11 @@ async fn workspace_id_accessor() { } #[tokio::test] -async fn get_metadata_returns_error_when_get_or_create_fails() { +async fn get_metadata_returns_error_when_get_fails() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "nonexistent"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/nonexistent")) .respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"}))) .mount(&server) .await; @@ -225,12 +219,11 @@ async fn get_metadata_returns_error_when_get_or_create_fails() { } #[tokio::test] -async fn get_configuration_returns_error_when_get_or_create_fails() { +async fn get_configuration_returns_error_when_get_fails() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v3/workspaces")) - .and(body_json(json!({"id": "nonexistent"}))) + Mock::given(method("GET")) + .and(path("/v3/workspaces/nonexistent")) .respond_with(ResponseTemplate::new(404).set_body_json(json!({"error": "not found"}))) .mount(&server) .await;