From 9e132fdf719aaddc8360c62bb51a5c13a90b8eb6 Mon Sep 17 00:00:00 2001 From: Kosmos Date: Fri, 6 Nov 2026 20:23:10 +0000 Subject: [PATCH] fix(claw): read a blank `since` as unset instead of failing the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone's notification poll sent `since` unconditionally, and before the first ack there is no cursor at all, so it went out as `?since=`. Axum's `Query` hands a present-but-empty parameter through as an empty string, and `Option` only forgives a *missing* key, so the whole poll answered `400 Failed to deserialize query string: since: cannot parse integer from empty string` and the phone showed an error instead of an empty queue. Both ends: the client omits `since` unless it has a positive id to send, and a `blank_as_none` deserializer makes a blank value mean "not set" for `NotificationQuery` and `EventStreamQuery` alike — an empty `since` reads as "from the start" however it got there. --- .../main/java/rs/rhema/phone/net/ClawApi.kt | 11 ++++- crates/rhema-claw/src/http.rs | 41 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt b/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt index 4e2b893..653807c 100644 --- a/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt +++ b/android/app/src/main/java/rs/rhema/phone/net/ClawApi.kt @@ -138,12 +138,21 @@ class ClawApi( return execute(builder.delete().build()) { } } + // GET /v1/notifications?device=&since=N. `since` is the last id the + // device has acked; before the first ack there is no cursor at all, + // and an empty `since=` came back + // "HTTP 400 — Failed to deserialize query string: since: cannot + // parse integer from empty string", which failed the whole poll. + // So the parameter is sent only when there is a positive id to send: + // absent and blank mean the same thing to the claw, and the shorter + // request is the honest one. suspend fun notifications(deviceId: String, since: String): ClawResult> { val base = baseOrNull() ?: return ClawResult.Network("no base URL configured") + val cursor = since.trim().toLongOrNull()?.takeIf { it > 0 } val url = base.toHttpUrlOrNull()?.newBuilder() ?.addPathSegments("v1/notifications") ?.addQueryParameter("device", deviceId) - ?.addQueryParameter("since", since) + ?.apply { cursor?.let { addQueryParameter("since", it.toString()) } } ?.build() ?: return ClawResult.Network("bad base URL") val request = Request.Builder().url(url).header("Authorization", bearer()).build() diff --git a/crates/rhema-claw/src/http.rs b/crates/rhema-claw/src/http.rs index 4e4bc8b..70c9e6c 100644 --- a/crates/rhema-claw/src/http.rs +++ b/crates/rhema-claw/src/http.rs @@ -49,7 +49,7 @@ use rhema_rsd::{ use rhema_tools_contract::Auth; use rhema_tools_contract::CONTRACT_HEADER; use rhema_tools_contract::CONTRACT_VERSION; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use serde_json::{Value, json}; use tokio::sync::broadcast::error::RecvError; use tokio_stream::wrappers::ReceiverStream; @@ -640,12 +640,44 @@ async fn messages( .into_response() } +/// Reads a query parameter the way a JSON body reads `null`: a blank +/// value (`?since=`) means "not set", not "unparseable". +/// +/// `Option` alone only forgives a *missing* key. Axum's `Query` +/// hands a present-but-empty parameter through as an empty string, and +/// parsing that into `T` fails the whole request with a 400 — which is +/// what the Android client hit on its first notification poll: it sent +/// its cursor unconditionally, and before the first ack the cursor is +/// empty, so the poll answered +/// `Failed to deserialize query string: since: cannot parse integer from empty string` +/// and the phone showed an error instead of an empty queue. The client +/// now omits the parameter when it has nothing to say, and the door +/// forgives the shape anyway: a query string is a text form, and an +/// empty `since` reads as "from the start" however it got there. +fn blank_as_none<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + let raw = Option::::deserialize(deserializer)?; + match raw.as_deref().map(str::trim) { + None | Some("") => Ok(None), + Some(value) => value + .parse::() + .map(Some) + .map_err(serde::de::Error::custom), + } +} + /// The events route's query: `since` counts how many of the record's /// oldest events the client already holds in its local transcript cache, /// and those are skipped in the replay. Absent (the web UI keeps no -/// cache) the replay stays whole. +/// cache) the replay stays whole; a blank `since` is absent, see +/// [`blank_as_none`]. #[derive(Debug, Deserialize)] struct EventStreamQuery { + #[serde(default, deserialize_with = "blank_as_none")] since: Option, } @@ -1465,7 +1497,10 @@ async fn ingest(State(state): State, Json(body): Json) -> pub struct NotificationQuery { /// The device queue to read; the phone when unset. pub device: Option, - /// Only notifications with a greater id; `0` when unset. + /// Only notifications with a greater id; `0` when unset, and a + /// blank value counts as unset (the poll may send `?since=` before + /// its first ack). + #[serde(default, deserialize_with = "blank_as_none")] pub since: Option, }