|
| 1 | +--- |
| 2 | +author: tejesh-vaish |
| 3 | +date: "2026-10-26" |
| 4 | +title: Handling late replies with ReScript records and events |
| 5 | +badge: community |
| 6 | +description: | |
| 7 | + Match typed events against the current state and use immutable record updates |
| 8 | + to handle deadlines, stale replies, and late completions in ReScript. |
| 9 | +--- |
| 10 | + |
| 11 | +A product-title update can finish after the client stops waiting. If the final reply arrives late, the client still needs to decide whether it belongs to the current attempt and whether it can change the result. |
| 12 | + |
| 13 | +[Modeling uncertain outcomes with ReScript variants](./modeling-uncertain-tool-call-outcomes.mdx) gives us four states. `Pending` means we're waiting, and `Unresolved` means the deadline passed without a final reply. `Completed(string)` and `Rejected(string)` carry final results. Here those states live in a record alongside an attempt ID. |
| 14 | + |
| 15 | +## Typed events and a tuple match |
| 16 | + |
| 17 | +`signal` is a variant for the events we accept. A deadline has no payload, a completion carries output, and a rejection carries a reason. The `event` record pairs each signal with an attempt ID. |
| 18 | + |
| 19 | +`receive` checks that ID first, then matches the tuple `(call.status, event.signal)`. Each arm specifies the state and signal it accepts. A deadline moves only `Pending` to `Unresolved`. A matching final reply can settle either state. |
| 20 | + |
| 21 | +Assume replies have already been validated and authenticated, and every new attempt gets a fresh ID that is never reused. The types below describe events after that boundary. |
| 22 | + |
| 23 | +Open the example with the external-link icon on the ReScript tab and select stable [ReScript 12.3.1](https://github.com/rescript-lang/rescript/releases/tag/v12.3.1). Run it in the playground to compare with the Output tab. |
| 24 | + |
| 25 | +<CodeTab labels={["ReScript", "Output"]}> |
| 26 | + |
| 27 | +```res example |
| 28 | +type status = |
| 29 | + | Pending |
| 30 | + | Unresolved |
| 31 | + | Completed(string) |
| 32 | + | Rejected(string) |
| 33 | +
|
| 34 | +type call = {attemptId: string, status: status} |
| 35 | +type signal = Deadline | CompletedReply(string) | RejectedReply(string) |
| 36 | +type event = {attemptId: string, signal: signal} |
| 37 | +
|
| 38 | +let receive = (call: call, event) => { |
| 39 | + if call.attemptId !== event.attemptId { |
| 40 | + call |
| 41 | + } else { |
| 42 | + switch (call.status, event.signal) { |
| 43 | + | (Pending, Deadline) => {...call, status: Unresolved} |
| 44 | + | (Pending, CompletedReply(output)) |
| 45 | + | (Unresolved, CompletedReply(output)) => {...call, status: Completed(output)} |
| 46 | + | (Pending, RejectedReply(reason)) |
| 47 | + | (Unresolved, RejectedReply(reason)) => {...call, status: Rejected(reason)} |
| 48 | + | (Unresolved, Deadline) |
| 49 | + | (Completed(_), _) |
| 50 | + | (Rejected(_), _) => call |
| 51 | + } |
| 52 | + } |
| 53 | +} |
| 54 | +
|
| 55 | +let describe = call => |
| 56 | + switch call.status { |
| 57 | + | Pending => "Pending" |
| 58 | + | Unresolved => "Unresolved" |
| 59 | + | Completed(output) => "Completed: " + output |
| 60 | + | Rejected(reason) => "Rejected: " + reason |
| 61 | + } |
| 62 | +
|
| 63 | +let pending = {attemptId: "title-1", status: Pending} |
| 64 | +let waiting = pending->receive({attemptId: "title-1", signal: Deadline}) |
| 65 | +waiting->describe->Console.log |
| 66 | +let stale = waiting->receive({attemptId: "title-0", signal: CompletedReply("Old title")}) |
| 67 | +stale->describe->Console.log |
| 68 | +let saved = stale->receive({attemptId: "title-1", signal: CompletedReply("Title saved")}) |
| 69 | +saved->describe->Console.log |
| 70 | +saved->receive({attemptId: "title-1", signal: RejectedReply("Too late")}) |
| 71 | +->describe->Console.log |
| 72 | +``` |
| 73 | + |
| 74 | +```text |
| 75 | +Unresolved |
| 76 | +Unresolved |
| 77 | +Completed: Title saved |
| 78 | +Completed: Title saved |
| 79 | +``` |
| 80 | + |
| 81 | +</CodeTab> |
| 82 | + |
| 83 | +## Record spread preserves earlier values |
| 84 | + |
| 85 | +ReScript [record fields](../docs/manual/record.mdx) are immutable by default. `{...call, status: Unresolved}` creates a new record with the same attempt ID and a different status. The earlier `pending` value stays `Pending`, even after `waiting` becomes `Unresolved`. |
| 86 | + |
| 87 | +The stale completion for `title-0` leaves `title-1` unresolved. The next completion matches `title-1` and settles it. The final rejection also matches, but the result stays completed because the terminal-state patterns return `call` unchanged. Repeated deadlines leave an unresolved attempt alone too. |
| 88 | + |
| 89 | +This is a first accepted final reply policy. It keeps `Completed` and `Rejected` terminal for this client, even if later replies conflict. Conflicting replies still need reconciliation outside this function. |
| 90 | + |
| 91 | +The tuple match covers every state and signal combination. In the last two patterns, `_` in the signal position matches any signal. That deliberately keeps later events from changing a final result. |
| 92 | + |
| 93 | +A local timeout can't establish that the write failed, was cancelled, or rolled back. It can't establish that retrying is safe either. Before retrying the title update, check whether the first write took effect and reconcile the result with the tool. |
0 commit comments