feat: keep error status until polling recovers, classify unconfirmed deliveries, document the UI
Validate / validate (push) Successful in 9m52s

- the poll loop now clears its error status after the next successful getUpdates, not before the retry, so a slow retry never shows as healthy
- recovery is reported only when getUpdates itself failed
- a send that may have reached Telegram is reported as unconfirmed instead of failed
- README and CHANGELOG describe the states, toasts, queue preview and spinner text
This commit is contained in:
evandrodevbr committed 2026-10-01 17:48:15 -03:00
1 parent 057281a3bb
commit 6f7e266a6e
8 files changed
+144 -19

No files matched your search

+5
View File
@@ -61,11 +61,16 @@ test("Delivery failures explain the reason and pick a severity from it", () => {
harness.runtime.deliveryFailed(new Error("Telegram API sendMessage failed: HTTP 401: Unauthorized"));
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.runtime.deliveryFailed(new Error("message is too long"));
harness.advance(TELEGRAM_DELIVERY_FAILED_NOTICE_COOLDOWN_MS);
harness.runtime.deliveryFailed(
new Error("Telegram API sendRichMessage may have committed before transport failed."),
);
assert.deepEqual(harness.notices, [
"warning|session|Telegram: reply not delivered (rate limited 12s)",
"warning|session|Telegram: reply not delivered (offline)",
"error|session|Telegram: reply not delivered (invalid token)",
"error|session|Telegram: reply not delivered (message is too long)",
"warning|session|Telegram: reply delivery unconfirmed (check the chat)",
]);
});
+70 -1
View File
@@ -667,7 +667,6 @@ test("Poll loop reports recovery once, only after a failed attempt", async () =>
};
assert.deepEqual(await run(2), [
"status:network down",
"status:ok",
"status:network down",
"status:ok",
"recovered:ctx",
@@ -714,6 +713,7 @@ test("Poll loop runner ignores stale-context status failures while retrying", as
getUpdates: async () => {
calls += 1;
if (calls === 1) throw new Error("network down");
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
@@ -739,6 +739,74 @@ test("Poll loop runner ignores stale-context status failures while retrying", as
]);
});
test("Poll loop keeps the error status until a poll succeeds", async () => {
const events: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config: { botToken: "123:abc", lastUpdateId: 1 },
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 2) throw new Error("rate limited");
if (calls === 3) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {},
onErrorStatus: (message) => {
events.push(`error:${message}`);
},
onStatusReset: () => {
events.push("reset");
},
sleep: async () => {
events.push("sleep");
},
});
assert.deepEqual(events, [
"error:rate limited",
"sleep",
"error:rate limited",
"sleep",
"reset",
]);
});
test("Poll loop does not report recovery when only an update handler failed", async () => {
const events: string[] = [];
let calls = 0;
await runTelegramPollLoop({
ctx: TEST_CONTEXT,
signal: new AbortController().signal,
config: { botToken: "123:abc", lastUpdateId: 1 },
maxUpdateFailures: 3,
deleteWebhook: async () => {},
getUpdates: async () => {
calls += 1;
if (calls <= 3) return [{ update_id: 6 }];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
handleUpdate: async () => {
throw new Error("handler failed");
},
onErrorStatus: (message) => {
events.push(`error:${message}`);
},
onStatusReset: () => {
events.push("reset");
},
onRecovered: () => {
events.push("recovered");
},
sleep: async () => {},
});
assert.equal(events.includes("recovered"), false, events.join("|"));
assert.ok(events.includes("reset"), events.join("|"));
});
test("Poll loop initializes lastUpdateId and processes updates", async () => {
const handled: number[] = [];
const config: { botToken: string; lastUpdateId?: number } = {
@@ -995,6 +1063,7 @@ test("Poll loop reports retryable errors and sleeps before retrying", async () =
if (calls === 1) {
throw new Error("network down");
}
if (calls === 2) return [];
throw new DOMException("stop", "AbortError");
},
persistConfig: async () => {},
+1
View File
@@ -388,6 +388,7 @@ test("Error summaries turn transport failures into a short state and next step",
["Telegram API getUpdates failed: HTTP 502: Bad Gateway", "retrying", "Telegram unavailable", "retrying"],
["Unable to connect. Is the computer able to access the url?", "retrying", "offline", "retrying"],
["fetch failed", "retrying", "offline", "retrying"],
["Telegram API sendRichMessage may have committed before transport failed.", "attention", "delivery unconfirmed", undefined],
["typing failed", undefined, undefined, undefined],
["settingsManager.reload is not a function", undefined, undefined, undefined],
];