mirror of
https://github.com/complexcaresolutions/telegram-media-bot.git
synced 2026-03-17 16:13:42 +00:00
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:
parent
86d3eeb51f
commit
6bd531e5fc
2 changed files with 44 additions and 15 deletions
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue