on master help sign in
Remove error field from JSON responses; use HTTP status codes instead
commite3c6a4ab3528b7dae81cd7a1639e04f9519a54a9 authoriko <mail@iko.soy> dateFri 8 May 2026, 11:44 (5 months ago) parentaa3c3eb
2 files changed+92−80
client/ApiClient.fs+13−8 server.knot+79−72
client/ApiClient.fs+13−8
@@ -49,14 +49,19 @@ module ApiClient =
4949 token |> Option.iter (fun t -> request.Headers.Add("Authorization", $"Bearer {t}"))
5050 use! response = httpClient.SendAsync(request) |> awaitTask
5151 let! text = response.Content.ReadAsStringAsync() |> awaitTask
52− let doc = JsonDocument.Parse(text)
53− match doc.RootElement.TryGetProperty("error") with
54− | true, err when not (String.IsNullOrEmpty(err.GetString())) ->
55− let code = err.GetString()
56− doc.Dispose()
57− if code = "unauthorized" then return raise Unauthorized
58− else return raise (ApiError $"{url}: {code}")
59− | _ -> return doc
52+ if response.IsSuccessStatusCode then
53+ return JsonDocument.Parse(text)
54+ else
55+ if response.StatusCode = System.Net.HttpStatusCode.Unauthorized then
56+ return raise Unauthorized
57+ let code =
58+ try
59+ use d = JsonDocument.Parse(text)
60+ match d.RootElement.TryGetProperty("error") with
61+ | true, e -> e.GetString()
62+ | _ -> string (int response.StatusCode)
63+ with _ -> string (int response.StatusCode)
64+ return raise (ApiError $"{url}: {code}")
6065 }
6166
6267 let private postJson<'T> httpClient url body token =
server.knot+79−72
@@ -198,9 +198,9 @@ type GossipEvent = {eventType: GossipEventType, pubkey: PubkeyHex}
198198
199199-- /messages signals failure via the per-entry `error` field on each result:
200200-- empty = success (delivered, federated, or queued),
201−-- non-empty = "rate_limited" | "self_send". A non-empty top-level `error`
202−-- ("rate_limited" | "unauthorized") means the request was rejected before
203−-- any entry was processed and `results` is empty.
201+-- non-empty = "rate_limited" | "self_send". Top-level rejections (auth,
202+-- global rate limit) come back as a non-2xx HTTP status with an empty
203+-- response body.
204204-- The sender's UI treats every success the same and learns "they got it"
205205-- only via the recipient's E2E ack.
206206
@@ -316,21 +316,22 @@ type RateEntry = {key: Text, windowStart: Timestamp, count: Int}
316316-- ClientApi -- /poll, /messages (send)
317317-- FederationApi -- inter-server gossip, forward (single + batch)
318318-- ReadyApi -- observability
319−-- Response field shapes are kept stable for backwards compatibility even when
320−-- the underlying value is no longer computed (zero-filled).
319+-- Handlers return `Result HttpError T`: `Ok` produces a 200 with the
320+-- declared body shape, `Err` produces a non-2xx response whose body is
321+-- `{"error": "<code>"}`.
321322
322323route AuthApi where
323− POST {pubkey: PubkeyHex} /auth/challenge headers {clientIp: Text} -> {challenge: Text, ok: Bool, expiresAt: Timestamp, retryAfterMs: Int, error: Text} = ReqChallenge
324− POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex, revokeOthers: Bool} /auth/verify headers {clientIp: Text} -> {token: Text, expiresAt: Timestamp, error: Text} = VerifyAuth
324+ POST {pubkey: PubkeyHex} /auth/challenge headers {clientIp: Text} -> {challenge: Text, expiresAt: Timestamp} = ReqChallenge
325+ POST {pubkey: PubkeyHex, challenge: Text, signature: SignatureHex, revokeOthers: Bool} /auth/verify headers {clientIp: Text} -> {token: Text, expiresAt: Timestamp} = VerifyAuth
325326
326327route ClientApi where
327328 POST {cursor: Timestamp, generation: Int} /poll headers {authorization: Text, clientIp: Text} -> {events: [PollEvent], cursor: Timestamp, authorized: Bool, rateLimited: Bool, generation: Int, heartbeat: Bool, retryAfterMs: Int, cursorReset: Bool, hasMore: Bool, cursorStale: Bool, loadPct: Int} = Poll
328− POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, clientIp: Text} -> {results: [{error: Text}], error: Text, retryAfterMs: Int} = SendMessages
329+ POST {messages: [{to: PubkeyHex, encryptedBlob: BlobHex}]} /messages headers {authorization: Text, clientIp: Text} -> {results: [{error: Text}]} = SendMessages
329330
330331route FederationApi where
331− POST {events: [GossipEvent], fromServer: ServerName} /federation/gossip -> {ok: Bool, error: Text, retryAfterMs: Int} = RecvGossip
332− POST {toKey: PubkeyHex, encryptedBlob: BlobHex, fromServer: ServerName} /federation/forward -> {ok: Bool, error: Text, retryAfterMs: Int} = ForwardMessage
333− POST {forwards: [{toKey: PubkeyHex, encryptedBlob: BlobHex}], fromServer: ServerName} /federation/forward-batch -> {ok: Bool, error: Text, results: [{ok: Bool}], retryAfterMs: Int} = FederationBatchForward
332+ POST {events: [GossipEvent], fromServer: ServerName} /federation/gossip -> {} = RecvGossip
333+ POST {toKey: PubkeyHex, encryptedBlob: BlobHex, fromServer: ServerName} /federation/forward -> {} = ForwardMessage
334+ POST {forwards: [{toKey: PubkeyHex, encryptedBlob: BlobHex}], fromServer: ServerName} /federation/forward-batch -> {results: [{ok: Bool}]} = FederationBatchForward
334335
335336route ReadyApi where
336337 GET /ready -> {ready: Bool} = Ready
@@ -524,13 +525,15 @@ extractToken = \h -> if hasPrefix "Bearer " h && drop 7 h != ""
524525then Just {value: drop 7 h}
525526else Nothing {}
526527
527−-- Common gate for handlers: global rate, session auth.
528−-- mkErr is called with one of: "rate_limited", "unauthorized".
529−gateAuth : Text -> Text -> Timestamp -> (Text -> resp) -> (PubkeyHex -> IO {} resp) -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions} resp
530−gateAuth = \auth ip t mkErr onAuth -> do
528+-- Common gate for handlers: global rate, session auth. Short-circuits with
529+-- an `Err` response on failure; the `onAuth` callback returns its own
530+-- `Result HttpError T` for the success path.
531+gateAuth : Text -> Text -> Timestamp -> (PubkeyHex -> IO {} (Result HttpError t)) -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions} (Result HttpError t)
532+gateAuth = \auth ip t onAuth -> do
531533 g <- checkGlobalRate t
532− let err = \e -> yield (mkErr e)
533− if g then err "rate_limited" else withSessionAuth auth t ip (err "unauthorized") onAuth
534+ if g
535+ then yield (Err {error: {status: 429, message: "rate_limited"}})
536+ else withSessionAuth auth t ip (yield (Err {error: {status: 401, message: "unauthorized"}})) onAuth
534537
535538-- Resolve a Bearer token to its session pubkey if the session is unexpired and
536539-- the client IP matches. Runs `onAuth pubkey` on success, `onUnauth` otherwise.
@@ -765,9 +768,9 @@ retryPendingForwards = do
765768 Err _ -> do
766769 recordFailure server
767770 yield (False {})
768− Ok {value: r} -> do
769− when r.ok (clearFailure server)
770− yield r.ok
771+ Ok _ -> do
772+ clearFailure server
773+ yield (True {})
771774 let isMatch = \x -> x.encryptedBlob == f.encryptedBlob && x.toServer == server
772775 let isDone = \x -> isMatch x && (success || x.retries + 1 >= maxForwardRetries)
773776 atomic (do
@@ -844,50 +847,48 @@ refreshLocalPresence = \t -> do
844847-- POST /auth/challenge: issue a fresh signed-nonce challenge for `pubkey`.
845848-- Capped per-key (maxChallengesPerKey) and rate-limited; the IP is recorded
846849-- so /auth/verify can require the same client.
847−handleChallenge : PubkeyHex -> Text -> IO {rw *authGlobalRateCount, rw *authGlobalRateWindowStart, rw *challengeRates, rw *challenges, rw *globalRateCount, rw *globalRateWindowStart, rw *ipRates, clock, random} {challenge: Text, error: Text, expiresAt: Int, ok: Bool, retryAfterMs: Int}
850+handleChallenge : PubkeyHex -> Text -> IO {rw *authGlobalRateCount, rw *authGlobalRateWindowStart, rw *challengeRates, rw *challenges, rw *globalRateCount, rw *globalRateWindowStart, rw *ipRates, clock, random} (Result HttpError {challenge: Text, expiresAt: Int})
848851handleChallenge = \pubkey clientIp -> do
849852 t <- now
850853 preErr <- if requireClientIp && clientIp == ""
851854 then yield "client_ip_required"
852855 else authPreCheck clientIp t
853856 if preErr != ""
854− then yield {challenge: "", ok: False {}, expiresAt: 0, retryAfterMs: 0, error: preErr}
857+ then yield (Err {
858+ error: {status: if preErr == "client_ip_required" then 400 else 429, message: preErr}
859+ })
855860 else do
856861 challenge <- genToken
857862 result <- atomic (do
858863 ch <- *challenges
859864 let cappedActive = capPerKey (filter ch (\c -> c.expiresAt > t)) (\c -> c.pubkey == pubkey) maxChallengesPerKey (\c -> c.expiresAt)
860865 if count cappedActive >= maxChallenges
861− then yield {ok: False {}, error: "capacity"}
866+ then yield "capacity"
862867 else do
863868 chRates <- *challengeRates
864869 let chR = checkRate chRates pubkey maxChallengeRatePerKey challengeRateWindowMs t
865870 *challengeRates = chR.entries
866871 if chR.limited
867− then yield {ok: False {}, error: "rate_limited"}
872+ then yield "rate_limited"
868873 else do
869874 *challenges = union cappedActive [
870875 {pubkey, challenge, expiresAt: t + challengeExpiry, ip: clientIp}
871876 ]
872− yield {ok: True {}, error: ""})
873− yield {
874− challenge: if result.ok then challenge else "",
875− result.ok,
876− expiresAt: if result.ok then t + challengeExpiry else 0,
877− retryAfterMs: 0,
878− result.error
879− }
877+ yield "")
878+ if result == ""
879+ then yield (Ok {value: {challenge, expiresAt: t + challengeExpiry}})
880+ else yield (Err {error: {status: if result == "capacity" then 503 else 429, message: result}})
880881
881882-- POST /auth/verify: validate `signature` over `challenge` for `pubkey`.
882883-- On success, mints a session token, records local presence (`pubkey` is now
883884-- live on this server), and gossips an `online` event to known peers. If
884885-- `revokeOthers` is set, all other sessions for the same pubkey are dropped.
885−handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *authGlobalRateCount, rw *authGlobalRateWindowStart, rw *challenges, rw *globalRateCount, rw *globalRateWindowStart, rw *ipRates, rw *presence, rw *sessions, clock, random} {error: Text, expiresAt: Int, token: Text}
886+handleVerifyAuth : PubkeyHex -> Text -> Text -> Bool -> Text -> IO {rw *authGlobalRateCount, rw *authGlobalRateWindowStart, rw *challenges, rw *globalRateCount, rw *globalRateWindowStart, rw *ipRates, rw *presence, rw *sessions, clock, random} (Result HttpError {expiresAt: Int, token: Text})
886887handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
887888 t <- now
888889 preErr <- authPreCheck clientIp t
889890 if preErr != ""
890− then yield {token: "", expiresAt: 0, error: preErr}
891+ then yield (Err {error: {status: 429, message: preErr}})
891892 else do
892893 let expiry = t + sessionExpiry
893894 token <- genToken
@@ -917,12 +918,12 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
917918 *presence = newPres
918919 yield {valid: True {}, peers: knownPeerServersFromPresence newPres})
919920 if not result.valid
920− then yield {token: "", expiresAt: 0, error: "invalid"}
921+ then yield (Err {error: {status: 401, message: "invalid"}})
921922 else do
922923 when federationEnabled (fork (sendBatchedGossip result.peers [
923924 mkGossip (GossipOnline {}) pubkey
924925 ]))
925− yield {token, expiresAt: expiry, error: ""}
926+ yield (Ok {value: {token, expiresAt: expiry}})
926927
927928-- ============================================
928929-- Handler: poll
@@ -937,13 +938,15 @@ handleVerifyAuth = \pubkey challenge signature revokeOthers clientIp -> do
937938-- POST /poll: top-level dispatch. Validates the global rate gate, detects a
938939-- generation mismatch (server restart) to force a cursor reset, then hands
939940-- off to `pollOnce` for the actual long-poll body.
940−handlePoll : Int -> Int -> Text -> Text -> IO {r *pollWakeup, r *serverGeneration, r *sessions, rw *forwards, rw *globalRateCount, rw *globalRateWindowStart, rw *messages, clock} {authorized: Bool, cursor: Timestamp, cursorReset: Bool, cursorStale: Bool, events: [PollEvent], generation: Int, hasMore: Bool, heartbeat: Bool, loadPct: Int, rateLimited: Bool, retryAfterMs: Int}
941+handlePoll : Int -> Int -> Text -> Text -> IO {r *pollWakeup, r *serverGeneration, r *sessions, rw *forwards, rw *globalRateCount, rw *globalRateWindowStart, rw *messages, clock} (Result HttpError {authorized: Bool, cursor: Timestamp, cursorReset: Bool, cursorStale: Bool, events: [PollEvent], generation: Int, hasMore: Bool, heartbeat: Bool, loadPct: Int, rateLimited: Bool, retryAfterMs: Int})
941942handlePoll = \cursor generation authorization clientIp -> do
942943 t <- now
943944 serverGen <- *serverGeneration
944945 g <- checkGlobalRate t
945946 let cursorWasReset = generation != serverGen && cursor > 0
946− let er = \auth rate retryRate -> yield (emptyPollResp 0 auth rate serverGen retryRate cursorWasReset)
947+ let er = \auth rate retryRate -> yield (Ok {
948+ value: emptyPollResp 0 auth rate serverGen retryRate cursorWasReset
949+ })
947950 if g
948951 then er (False {}) (True {}) globalRateWindowMs
949952 else pollOnce authorization (if cursorWasReset then 0 else cursor) cursorWasReset clientIp serverGen t
@@ -951,7 +954,7 @@ handlePoll = \cursor generation authorization clientIp -> do
951954-- Body of a single poll. Treats the cursor advance as an implicit ack
952955-- (`ackPolledMessages`), then runs `doPoll` to collect events (or block
953956-- until wakeup) and assembles the response.
954−pollOnce : Text -> Int -> Bool -> Text -> Int -> Int<Ms> -> IO {r *pollWakeup, r *sessions, rw *forwards, rw *messages, clock} {authorized: Bool, cursor: Timestamp, cursorReset: Bool, cursorStale: Bool, events: [PollEvent], generation: Int, hasMore: Bool, heartbeat: Bool, loadPct: Int, rateLimited: Bool, retryAfterMs: Int}
957+pollOnce : Text -> Int -> Bool -> Text -> Int -> Int<Ms> -> IO {r *pollWakeup, r *sessions, rw *forwards, rw *messages, clock} (Result HttpError {authorized: Bool, cursor: Timestamp, cursorReset: Bool, cursorStale: Bool, events: [PollEvent], generation: Int, hasMore: Bool, heartbeat: Bool, loadPct: Int, rateLimited: Bool, retryAfterMs: Int})
955958pollOnce = \authorization cursor cursorWasReset clientIp serverGen t -> do
956959 setup <- atomic withSessionAuth authorization t clientIp (yield (Nothing {})) (\pubkey -> do
957960 let clampedCursor = clamp 0 t cursor
@@ -959,22 +962,26 @@ pollOnce = \authorization cursor cursorWasReset clientIp serverGen t -> do
959962 startWakeup <- *pollWakeup
960963 yield (Just {value: {pubkey, clampedCursor, startWakeup}}))
961964 case setup of
962− Nothing {} -> yield (emptyPollResp 0 (False {}) (False {}) serverGen 0 cursorWasReset)
965+ Nothing {} -> yield (Ok {
966+ value: emptyPollResp 0 (False {}) (False {}) serverGen 0 cursorWasReset
967+ })
963968 Just {value: s} -> do
964969 pollResult <- doPoll s t
965− yield {
966− pollResult.events,
967− pollResult.cursor,
968− authorized: True {},
969− rateLimited: False {},
970− generation: serverGen,
971− heartbeat: count pollResult.events == 0,
972− retryAfterMs: 0,
973− cursorReset: cursorWasReset,
974− pollResult.hasMore,
975− cursorStale: False {},
976− loadPct: 0
977− }
970+ yield (Ok {
971+ value: {
972+ pollResult.events,
973+ pollResult.cursor,
974+ authorized: True {},
975+ rateLimited: False {},
976+ generation: serverGen,
977+ heartbeat: count pollResult.events == 0,
978+ retryAfterMs: 0,
979+ cursorReset: cursorWasReset,
980+ pollResult.hasMore,
981+ cursorStale: False {},
982+ loadPct: 0
983+ }
984+ })
978985
979986-- One pass: collect events at `t`. If empty, retry-wait on `pollWakeup`
980987-- (bumped by send/heartbeat) and re-collect once at the new wall time.
@@ -1029,19 +1036,17 @@ ackPolledMessages = \pubkey cursor -> do
10291036
10301037-- POST /messages: send a list of ciphertexts. Each entry runs through
10311038-- `sendMessageCore` separately so per-entry rate limits/errors don't abort
1032−-- the rest; federated forwards are forked per entry. Top-level `error`
1033−-- carries global gate failures ("rate_limited" | "unauthorized") and
1034−-- short-circuits with empty `results`.
1035−handleSendMessages : [{encryptedBlob: BlobHex, to: PubkeyHex | a}] -> Text -> Text -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions, clock} {error: Text, results: [{error: Text}], retryAfterMs: Int}
1039+-- the rest; federated forwards are forked per entry. Top-level rejections
1040+-- (auth, global rate) come back as a non-2xx HTTP status via `gateAuth`.
1041+handleSendMessages : [{encryptedBlob: BlobHex, to: PubkeyHex | a}] -> Text -> Text -> IO {rw *globalRateCount, rw *globalRateWindowStart, r *sessions, clock} (Result HttpError {results: [{error: Text}]})
10361042handleSendMessages = \messages authorization clientIp -> do
10371043 t <- now
1038− let mkErr = \e -> {results: [], error: e, retryAfterMs: 0}
1039− gateAuth authorization clientIp t mkErr (\senderKey -> do
1044+ gateAuth authorization clientIp t (\senderKey -> do
10401045 results <- mapEach (takeRelation maxBatchSize messages) (\m -> do
10411046 r <- atomic (do sendMessageCore senderKey m.to m.encryptedBlob t)
10421047 when federationEnabled (fork (forkForwardsTo m.to m.encryptedBlob r.remoteServers))
10431048 yield {r.error})
1044− yield {results, error: "", retryAfterMs: 0})
1049+ yield (Ok {value: {results}}))
10451050
10461051-- Atomic core of /messages. Charges send/pair/recv rate
10471052-- counters together (so any limit denies the message), enforces caps and
@@ -1114,7 +1119,7 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> do
11141119 })
11151120 case result of
11161121 Err _ -> recordFailure server
1117− Ok {value: r} -> when r.ok (do
1122+ Ok _ -> do
11181123 clearFailure server
11191124 atomic (do
11201125 fwds2 <- *forwards
@@ -1125,7 +1130,7 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> do
11251130 let othersPending = any remaining (\x -> x.encryptedBlob == encryptedBlob)
11261131 when (not stillLocal && not othersPending) (do
11271132 msgs <- *messages
1128− *messages = filter msgs (\m -> m.encryptedBlob != encryptedBlob)))))
1133+ *messages = filter msgs (\m -> m.encryptedBlob != encryptedBlob))))
11291134
11301135-- ============================================
11311136-- Handler: federation receive
@@ -1140,11 +1145,11 @@ forkForwardsTo = \to encryptedBlob servers -> forEach servers (\server -> do
11401145-- pubkeys with a `GossipOnline` event (so Online wins on collision). For keys
11411146-- that transition offline → online, flushes any locally-buffered messages via
11421147-- `forwardQueuedToServer`.
1143−handleRecvGossip : [{eventType: <GossipOffline {} | GossipOnline {} | a>, pubkey: PubkeyHex | b}] -> Text -> IO {rw *presence, clock} {error: Text, ok: Bool, retryAfterMs: Int}
1148+handleRecvGossip : [{eventType: <GossipOffline {} | GossipOnline {} | a>, pubkey: PubkeyHex | b}] -> Text -> IO {rw *presence, clock} (Result HttpError {})
11441149handleRecvGossip = \events fromServer -> do
11451150 t <- now
11461151 if not isRemote fromServer || isBadServerName fromServer || count events > maxGossipEvents
1147− then yield {ok: False {}, error: "invalid_request", retryAfterMs: 0}
1152+ then yield (Err {error: {status: 400, message: "invalid_request"}})
11481153 else do
11491154 newlyOnline <- atomic (do
11501155 pres <- *presence
@@ -1157,7 +1162,7 @@ handleRecvGossip = \events fromServer -> do
11571162 *presence = union kept added
11581163 yield (filter onlinePubkeys (\pk -> none pres (\p -> p.pubkey == pk && p.server == fromServer && p.expiresAt > t))))
11591164 fork (forEach newlyOnline (\pk -> forwardQueuedToServer pk fromServer))
1160− yield {ok: True {}, error: "", retryAfterMs: 0}
1165+ yield (Ok {value: {}})
11611166
11621167-- Store a federated/forwarded message into the local mailbox. Caller wraps in `atomic`.
11631168-- Returns "" on success, otherwise an error code:
@@ -1195,24 +1200,26 @@ storeFederated = \toKey encryptedBlob checkRecvRate t -> do
11951200-- POST /federation/forward: receive a single federated message destined for
11961201-- a local key. Delegates the actual store decision to `storeFederated`,
11971202-- which returns "" on success.
1198−handleForwardMessage : PubkeyHex -> BlobHex -> Text -> IO {rw *messages, rw *pollWakeup, r *presence, rw *recvRates, rw *seqCounter, clock} {error: Text, ok: Bool, retryAfterMs: Int}
1203+handleForwardMessage : PubkeyHex -> BlobHex -> Text -> IO {rw *messages, rw *pollWakeup, r *presence, rw *recvRates, rw *seqCounter, clock} (Result HttpError {})
11991204handleForwardMessage = \toKey encryptedBlob _ -> do
12001205 t <- now
12011206 result <- atomic storeFederated toKey encryptedBlob (True {}) t
1202− yield {ok: result == "", error: result, retryAfterMs: 0}
1207+ if result == ""
1208+ then yield (Ok {value: {}})
1209+ else yield (Err {error: {status: if result == "no_presence" then 404 else 429, message: result}})
12031210
12041211-- POST /federation/forward-batch: bulk variant of `handleForwardMessage`.
12051212-- Skips per-entry recv-rate charging.
1206−handleFederationBatchForward : [{encryptedBlob: BlobHex, toKey: PubkeyHex | a}] -> Text -> IO {clock} {error: Text, ok: Bool, results: [{ok: Bool}], retryAfterMs: Int}
1213+handleFederationBatchForward : [{encryptedBlob: BlobHex, toKey: PubkeyHex | a}] -> Text -> IO {clock} (Result HttpError {results: [{ok: Bool}]})
12071214handleFederationBatchForward = \forwards _ -> do
12081215 t <- now
12091216 if count forwards > maxBatchSize
1210− then yield {ok: False {}, error: "invalid_request", results: [], retryAfterMs: 0}
1217+ then yield (Err {error: {status: 400, message: "invalid_request"}})
12111218 else do
12121219 results <- mapEach (takeRelation maxBatchSize forwards) (\e -> do
12131220 result <- atomic storeFederated e.toKey e.encryptedBlob (False {}) t
12141221 yield {ok: result == ""})
1215− yield {ok: True {}, error: "", results, retryAfterMs: 0}
1222+ yield (Ok {value: {results}})
12161223
12171224-- ============================================
12181225-- Readiness handlers
@@ -1220,11 +1227,11 @@ handleFederationBatchForward = \forwards _ -> do
12201227
12211228-- GET /ready: signals readiness once the first background prune has run
12221229-- (load-balancer / orchestrator probe).
1223−handleReady : IO {r *lastPrunedAt, r *startedAt} {ready: Bool}
1230+handleReady : IO {r *lastPrunedAt, r *startedAt} (Result HttpError {ready: Bool})
12241231handleReady = do
12251232 sa <- *startedAt
12261233 lastPrune <- *lastPrunedAt
1227− yield {ready: lastPrune > 0 && lastPrune >= sa}
1234+ yield (Ok {value: {ready: lastPrune > 0 && lastPrune >= sa}})
12281235
12291236-- ============================================
12301237-- Dispatch