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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-03-01 10:38:45 +00:00
parent 86d3eeb51f
commit 6bd531e5fc
2 changed files with 44 additions and 15 deletions

View file

@ -33,6 +33,11 @@ interface TenantDoc {
slug: string; slug: string;
} }
interface HealthResult {
status: 'ok' | 'rate_limited' | 'auth_error' | 'unreachable';
retryAfter?: number; // seconds until rate limit clears
}
function decodeJwtExpiry(token: string): number { function decodeJwtExpiry(token: string): number {
const parts = token.split('.'); const parts = token.split('.');
if (parts.length !== 3) return 0; if (parts.length !== 3) return 0;
@ -43,6 +48,7 @@ function decodeJwtExpiry(token: string): number {
class PayloadClient { class PayloadClient {
private token: string | null = null; private token: string | null = null;
private tokenExpiry: number = 0; private tokenExpiry: number = 0;
private rateLimitClearsAt: number = 0; // epoch ms when rate limit expires
private readonly apiUrl: string; private readonly apiUrl: string;
constructor() { constructor() {
@ -62,6 +68,12 @@ class PayloadClient {
if (!response.ok) { if (!response.ok) {
const text = await response.text(); 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}`); throw new Error(`Payload login failed (${response.status}): ${text}`);
} }
@ -168,18 +180,27 @@ class PayloadClient {
return data.docs; return data.docs;
} }
async checkHealth(): Promise<'ok' | 'rate_limited' | 'auth_error' | 'unreachable'> { async checkHealth(): Promise<HealthResult> {
// 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 { try {
const response = await this.authFetch(`${this.apiUrl}/users/me`); const response = await this.authFetch(`${this.apiUrl}/users/me`);
if (response.ok) return 'ok'; if (response.ok) return { status: 'ok' };
if (response.status === 401) return 'auth_error'; if (response.status === 401) return { status: 'auth_error' };
return 'unreachable'; return { status: 'unreachable' };
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : ''; const msg = error instanceof Error ? error.message : '';
log.error('Health check failed', error); log.error('Health check failed', error);
if (msg.includes('429') || msg.includes('RATE_LIMITED')) return 'rate_limited'; if (msg.includes('429') || msg.includes('RATE_LIMITED')) {
if (msg.includes('401') || msg.includes('incorrect')) return 'auth_error'; const secs = Math.ceil((this.rateLimitClearsAt - Date.now()) / 1000);
return 'unreachable'; 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 const payloadClient = new PayloadClient();
export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc }; export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc, HealthResult };

View file

@ -385,13 +385,21 @@ export function registerHandlers(bot: Bot<BotContext>): void {
// /status command // /status command
bot.command('status', async (ctx) => { bot.command('status', async (ctx) => {
const health = await payloadClient.checkHealth(); const health = await payloadClient.checkHealth();
const apiLabels: Record<string, string> = {
ok: '\ud83d\udfe2 Erreichbar', let apiStatus: string;
rate_limited: '\ud83d\udfe1 Rate\\-Limit \\(bitte warten\\)', if (health.status === 'ok') {
auth_error: '\ud83d\udd34 Anmeldefehler', apiStatus = '\ud83d\udfe2 Erreichbar';
unreachable: '\ud83d\udd34 Nicht erreichbar', } else if (health.status === 'rate_limited') {
}; const secs = health.retryAfter ?? 0;
const apiStatus = apiLabels[health] ?? '\ud83d\udd34 Unbekannt'; 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 expiry = payloadClient.getTokenExpiry();
const expiryStr = expiry ? expiry.toLocaleString('de-DE') : 'Kein Token'; const expiryStr = expiry ? expiry.toLocaleString('de-DE') : 'Kein Token';