fix(claw): read a blank since as unset instead of failing the request

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<i64>` 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.
This commit is contained in:
kosmos committed 2026-11-06 20:39:53 +00:00
1 parent 3283d92227
commit 9e132fdf71
2 files changed
+48 -4

No files matched your search

@@ -138,12 +138,21 @@ class ClawApi(
return execute(builder.delete().build()) { } 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<List<RhemaNotification>> { suspend fun notifications(deviceId: String, since: String): ClawResult<List<RhemaNotification>> {
val base = baseOrNull() ?: return ClawResult.Network("no base URL configured") val base = baseOrNull() ?: return ClawResult.Network("no base URL configured")
val cursor = since.trim().toLongOrNull()?.takeIf { it > 0 }
val url = base.toHttpUrlOrNull()?.newBuilder() val url = base.toHttpUrlOrNull()?.newBuilder()
?.addPathSegments("v1/notifications") ?.addPathSegments("v1/notifications")
?.addQueryParameter("device", deviceId) ?.addQueryParameter("device", deviceId)
?.addQueryParameter("since", since) ?.apply { cursor?.let { addQueryParameter("since", it.toString()) } }
?.build() ?.build()
?: return ClawResult.Network("bad base URL") ?: return ClawResult.Network("bad base URL")
val request = Request.Builder().url(url).header("Authorization", bearer()).build() val request = Request.Builder().url(url).header("Authorization", bearer()).build()
+38 -3
View File
@@ -49,7 +49,7 @@ use rhema_rsd::{
use rhema_tools_contract::Auth; use rhema_tools_contract::Auth;
use rhema_tools_contract::CONTRACT_HEADER; use rhema_tools_contract::CONTRACT_HEADER;
use rhema_tools_contract::CONTRACT_VERSION; use rhema_tools_contract::CONTRACT_VERSION;
use serde::Deserialize; use serde::{Deserialize, Deserializer};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
@@ -640,12 +640,44 @@ async fn messages(
.into_response() .into_response()
} }
/// Reads a query parameter the way a JSON body reads `null`: a blank
/// value (`?since=`) means "not set", not "unparseable".
///
/// `Option<T>` 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<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let raw = Option::<String>::deserialize(deserializer)?;
match raw.as_deref().map(str::trim) {
None | Some("") => Ok(None),
Some(value) => value
.parse::<T>()
.map(Some)
.map_err(serde::de::Error::custom),
}
}
/// The events route's query: `since` counts how many of the record's /// The events route's query: `since` counts how many of the record's
/// oldest events the client already holds in its local transcript cache, /// oldest events the client already holds in its local transcript cache,
/// and those are skipped in the replay. Absent (the web UI keeps no /// 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)] #[derive(Debug, Deserialize)]
struct EventStreamQuery { struct EventStreamQuery {
#[serde(default, deserialize_with = "blank_as_none")]
since: Option<usize>, since: Option<usize>,
} }
@@ -1465,7 +1497,10 @@ async fn ingest(State(state): State<ClawState>, Json(body): Json<IngestBody>) ->
pub struct NotificationQuery { pub struct NotificationQuery {
/// The device queue to read; the phone when unset. /// The device queue to read; the phone when unset.
pub device: Option<String>, pub device: Option<String>,
/// 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<i64>, pub since: Option<i64>,
} }