From 6bd531e5fca70af348ee8ab4255e9e1323e63e94 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 1 Mar 2026 10:38:45 +0000 Subject: [PATCH] feat: show rate-limit countdown in /status Track rate limit expiry from 429 responses. /status now displays remaining time (e.g. "Rate-Limit (frei in ~7m 30s)") instead of just "Rate-Limit (bitte warten)". Also skip unnecessary login attempts while rate limit is known to be active. Co-Authored-By: Claude Opus 4.6 --- src/payload/client.ts | 37 +++++++++++++++++++++++++++++-------- src/telegram/handlers.ts | 22 +++++++++++++++------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/payload/client.ts b/src/payload/client.ts index a5b7a8a..6a8ddb9 100644 --- a/src/payload/client.ts +++ b/src/payload/client.ts @@ -33,6 +33,11 @@ interface TenantDoc { slug: string; } +interface HealthResult { + status: 'ok' | 'rate_limited' | 'auth_error' | 'unreachable'; + retryAfter?: number; // seconds until rate limit clears +} + function decodeJwtExpiry(token: string): number { const parts = token.split('.'); if (parts.length !== 3) return 0; @@ -43,6 +48,7 @@ function decodeJwtExpiry(token: string): number { class PayloadClient { private token: string | null = null; private tokenExpiry: number = 0; + private rateLimitClearsAt: number = 0; // epoch ms when rate limit expires private readonly apiUrl: string; constructor() { @@ -62,6 +68,12 @@ class PayloadClient { if (!response.ok) { const text = await response.text(); + // Track rate limit expiry + if (response.status === 429) { + const match = text.match(/"retryAfter":(\d+)/); + const secs = match ? Number(match[1]) : 60; + this.rateLimitClearsAt = Date.now() + secs * 1000; + } throw new Error(`Payload login failed (${response.status}): ${text}`); } @@ -168,18 +180,27 @@ class PayloadClient { return data.docs; } - async checkHealth(): Promise<'ok' | 'rate_limited' | 'auth_error' | 'unreachable'> { + async checkHealth(): Promise { + // If we know we're rate-limited, don't even try (avoid wasting attempts) + const remaining = Math.ceil((this.rateLimitClearsAt - Date.now()) / 1000); + if (remaining > 0) { + return { status: 'rate_limited', retryAfter: remaining }; + } + try { const response = await this.authFetch(`${this.apiUrl}/users/me`); - if (response.ok) return 'ok'; - if (response.status === 401) return 'auth_error'; - return 'unreachable'; + if (response.ok) return { status: 'ok' }; + if (response.status === 401) return { status: 'auth_error' }; + return { status: 'unreachable' }; } catch (error) { const msg = error instanceof Error ? error.message : ''; log.error('Health check failed', error); - if (msg.includes('429') || msg.includes('RATE_LIMITED')) return 'rate_limited'; - if (msg.includes('401') || msg.includes('incorrect')) return 'auth_error'; - return 'unreachable'; + if (msg.includes('429') || msg.includes('RATE_LIMITED')) { + const secs = Math.ceil((this.rateLimitClearsAt - Date.now()) / 1000); + return { status: 'rate_limited', retryAfter: Math.max(secs, 0) }; + } + if (msg.includes('401') || msg.includes('incorrect')) return { status: 'auth_error' }; + return { status: 'unreachable' }; } } @@ -189,4 +210,4 @@ class PayloadClient { } export const payloadClient = new PayloadClient(); -export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc }; +export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc, HealthResult }; diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 69d1b75..2938033 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -385,13 +385,21 @@ export function registerHandlers(bot: Bot): void { // /status command bot.command('status', async (ctx) => { const health = await payloadClient.checkHealth(); - const apiLabels: Record = { - ok: '\ud83d\udfe2 Erreichbar', - rate_limited: '\ud83d\udfe1 Rate\\-Limit \\(bitte warten\\)', - auth_error: '\ud83d\udd34 Anmeldefehler', - unreachable: '\ud83d\udd34 Nicht erreichbar', - }; - const apiStatus = apiLabels[health] ?? '\ud83d\udd34 Unbekannt'; + + let apiStatus: string; + if (health.status === 'ok') { + apiStatus = '\ud83d\udfe2 Erreichbar'; + } else if (health.status === 'rate_limited') { + const secs = health.retryAfter ?? 0; + const mins = Math.floor(secs / 60); + const rest = secs % 60; + const timeStr = mins > 0 ? `${mins}m ${rest}s` : `${secs}s`; + apiStatus = `\ud83d\udfe1 Rate\\-Limit \\(frei in ~${escapeMarkdown(timeStr)}\\)`; + } else if (health.status === 'auth_error') { + apiStatus = '\ud83d\udd34 Anmeldefehler'; + } else { + apiStatus = '\ud83d\udd34 Nicht erreichbar'; + } const expiry = payloadClient.getTokenExpiry(); const expiryStr = expiry ? expiry.toLocaleString('de-DE') : 'Kein Token';