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

+12 -8
View File
@@ -36,22 +36,26 @@ export interface TelegramNoticeRuntime<TContext> {
}
function describeTelegramDeliveryFailure(error: unknown): {
reason: string;
message: string;
level: TelegramNoticeLevel;
} {
const message = error instanceof Error ? error.message : String(error);
const summary = summarizeTelegramStatusError(message);
if (summary?.deliveryUnknown) {
return {
message: "Telegram: reply delivery unconfirmed (check the chat)",
level: "warning",
};
}
if (summary) {
return {
reason: summary.label,
message: `Telegram: reply not delivered (${summary.label})`,
level: summary.kind === "fatal" ? "error" : "warning",
};
}
const raw = sanitizeTelegramStatusText(message);
return {
reason: raw.length > 80 ? `${raw.slice(0, 79)}…` : raw,
level: "error",
};
const reason = raw.length > 80 ? `${raw.slice(0, 79)}…` : raw;
return { message: `Telegram: reply not delivered (${reason})`, level: "error" };
}
/**
@@ -93,8 +97,8 @@ export function createTelegramNoticeRuntime<TContext>(
return;
}
lastDeliveryNoticeAtMs = nowMs;
const { reason, level } = describeTelegramDeliveryFailure(error);
show(ctx, `Telegram: reply not delivered (${reason})`, level);
const failure = describeTelegramDeliveryFailure(error);
show(ctx, failure.message, failure.level);
},
};
}
+22 -7
View File
@@ -1055,16 +1055,32 @@ export async function runTelegramPollLoop<
const admittedUpdates = new Set<number>();
let handledUpdateFailureRethrown = false;
let consecutiveGetUpdatesConflicts = 0;
let failing = false;
// An error status is on screen; it stays until polling works again, so a
// slow retry (for example a long rate-limit wait) never shows as healthy.
let errorShown = false;
// The last getUpdates failed, so the next successful one is a recovery.
// Failures of an update handler do not count: the transport kept working.
let transportDown = false;
const showError = (message: string): void => {
errorShown = true;
deps.onErrorStatus(message);
};
while (!deps.signal.aborted) {
let inGetUpdates = false;
try {
inGetUpdates = true;
const updates = await deps.getUpdates(
buildTelegramLongPollRequest(deps.config.lastUpdateId),
deps.signal,
);
inGetUpdates = false;
consecutiveGetUpdatesConflicts = 0;
if (failing) {
failing = false;
if (errorShown) {
errorShown = false;
deps.onStatusReset();
}
if (transportDown) {
transportDown = false;
deps.onRecovered?.();
}
for (const update of updates) {
@@ -1095,7 +1111,7 @@ export async function runTelegramPollLoop<
throw error;
}
const message = getTelegramPollingErrorMessage(error);
deps.onErrorStatus(
showError(
`skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
);
admittedUpdates.add(update.update_id);
@@ -1124,11 +1140,10 @@ export async function runTelegramPollLoop<
continue;
}
consecutiveGetUpdatesConflicts = 0;
failing = true;
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
if (inGetUpdates) transportDown = true;
showError(getTelegramPollingErrorMessage(error));
await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
if (deps.signal.aborted) return;
deps.onStatusReset();
}
}
}
+12
View File
@@ -908,6 +908,11 @@ export interface TelegramStatusErrorSummary {
hint?: string;
/** fatal needs the operator, attention needs a look, retrying heals itself. */
kind: "fatal" | "attention" | "retrying";
/**
* The failed call may still have reached Telegram: a non-idempotent send hit
* a transport failure after the request left, so the outcome is unknown.
*/
deliveryUnknown?: boolean;
}
/**
@@ -917,6 +922,13 @@ export interface TelegramStatusErrorSummary {
export function summarizeTelegramStatusError(
message: string,
): TelegramStatusErrorSummary | undefined {
if (/may have committed before transport failed/i.test(message)) {
return {
kind: "attention",
label: "delivery unconfirmed",
deliveryUnknown: true,
};
}
if (/HTTP 401\b|\bUnauthorized\b/i.test(message)) {
return { kind: "fatal", label: "invalid token", hint: "/telegram-setup" };
}