ratelimit

This commit is contained in:
Sarto
2026-04-30 22:07:20 +03:30
parent 0defe0489b
commit ef90c0d72b
2 changed files with 64 additions and 4 deletions
+41 -4
View File
@@ -17,6 +17,20 @@ import (
"github.com/sartoopjj/thefeed/internal/protocol"
)
// ghRateLimitError is returned by fetchGitHubRaw when GitHub rejects
// with 403 + X-RateLimit-Remaining: 0. Surfacing it as a typed error
// lets the caller render a specific 429 to the browser so the UI can
// show a "rate limited, try again at HH:MM" popup instead of a generic
// relay failure.
type ghRateLimitError struct {
ResetUnix int64
Body string
}
func (e *ghRateLimitError) Error() string {
return fmt.Sprintf("github rate limit (reset=%d): %s", e.ResetUnix, e.Body)
}
// relayHTTPClient is the single shared HTTP client for the GitHub relay
// path. Reusing one client (and its underlying *http.Transport) gives us
// connection pooling and DNS-result caching for free across the many
@@ -161,7 +175,23 @@ func (s *Server) serveFromGitHubRelay(w http.ResponseWriter, r *http.Request, si
encBody, _, err := fetchGitHubRaw(ctx, relayHTTPClient, url, size+int64(aeadOverhead))
if err != nil {
s.addLog(fmt.Sprintf("relay: fetch %s: %v", url, err))
// Rate-limit gets surfaced specifically so the UI can show a
// "try again at HH:MM" popup. Other errors fall through to the
// caller's 502 → existing slow-DNS fallback prompt.
var rl *ghRateLimitError
if errors.As(err, &rl) {
minutes := int64(1)
if rl.ResetUnix > 0 {
secs := rl.ResetUnix - time.Now().Unix()
if secs > 0 {
minutes = (secs + 59) / 60
}
}
w.Header().Set("X-Relay-Reset", strconv.FormatInt(rl.ResetUnix, 10))
w.Header().Set("X-Relay-Reset-Min", strconv.FormatInt(minutes, 10))
http.Error(w, "github rate limit", http.StatusTooManyRequests)
return true
}
return false
}
body, err := protocol.DecryptRelayBlob(relayKey, encBody)
@@ -205,13 +235,20 @@ func fetchGitHubRaw(ctx context.Context, hc *http.Client, url string, expectedSi
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
// Surface enough context to tell rate-limit, abuse-detection,
// and plain auth errors apart. GitHub puts a JSON message in
// the body and rate-limit counters in the response headers.
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
rl := resp.Header.Get("X-RateLimit-Remaining")
reset := resp.Header.Get("X-RateLimit-Reset")
retry := resp.Header.Get("Retry-After")
// Distinct typed error for the rate-limit case so the caller
// can render a 429 with the reset time instead of a generic
// 502 / fallback error.
if resp.StatusCode == http.StatusForbidden && rl == "0" {
resetUnix, _ := strconv.ParseInt(reset, 10, 64)
return nil, "", &ghRateLimitError{
ResetUnix: resetUnix,
Body: strings.TrimSpace(string(errBody)),
}
}
hdr := ""
if rl != "" {
hdr += " rl-remaining=" + rl
+23
View File
@@ -2593,6 +2593,8 @@
auto_update_off: 'به‌روزرسانی خودکار برای @{name} خاموش شد',
media_relay_fallback: 'دانلود از مسیر سریع نشد. از مسیر کند (DNS) امتحان کنیم؟ توجه: ممکن است خیلی کند باشد.',
media_slow_only: 'رله گیتهاب در دسترس نیست. دانلود از مسیر DNS خیلی کند است. ادامه می‌دهی؟',
media_rate_limited: 'به محدودیت تعداد درخواست گیتهاب رسیدی. حدود {n} دقیقه دیگه دوباره فعال میشه. حالا از مسیر کند (DNS) دانلود کنیم؟',
media_rate_limited_short: 'محدودیت تعداد درخواست گیتهاب',
media_size_mismatch: 'سایز فایل با چیزی که سرور گفته بود نمی‌خونه',
clear_cache: 'پاک کردن کش', clear_cache_btn: '🗑 پاک کردن کش', cache_cleared: 'کش پاک شد!',
saved_resolvers_title: 'شروع سریع',
@@ -2760,6 +2762,8 @@
auto_update_off: 'Auto-update is now OFF for @{name}',
media_relay_fallback: 'Fast relay failed. Try the slow DNS path? Note: this can be very slow.',
media_slow_only: 'GitHub relay is unavailable for this file. The DNS path is very slow. Download anyway?',
media_rate_limited: 'GitHub rate limit hit. Resets in about {n} min. Try the slow DNS path now?',
media_rate_limited_short: 'GitHub rate limit',
media_size_mismatch: 'Downloaded size doesn\'t match the manifest',
clear_cache: 'Clear Cache', clear_cache_btn: '🗑 Clear Cache', cache_cleared: 'Cache cleared!',
saved_resolvers_title: 'Quick Start',
@@ -4509,10 +4513,29 @@
mediaShowBlob(card, blobURL);
mediaPersistBlob(msgID, card, xhr.response, mediaBlobs[msgID].mime);
} else {
// GitHub rate limit gets its own popup with the reset time so
// the user knows it's temporary and how long to wait.
if (source === 'fast' && xhr.status === 429) {
var resetMin = parseInt(xhr.getResponseHeader('X-Relay-Reset-Min') || '0', 10);
await handleRateLimit(resetMin);
return;
}
if (source === 'fast') { handleFastFailure(xhr.statusText || ('HTTP ' + xhr.status)); return; }
mediaShowError(card, xhr.statusText || ('HTTP ' + xhr.status));
}
}
async function handleRateLimit(resetMin) {
var minutes = (resetMin && resetMin > 0) ? resetMin : '?';
var msg = (t('media_rate_limited') || 'GitHub rate limit hit. Reset in {n} min. Try the slow DNS path?').replace('{n}', minutes);
if (dnsAvail) {
var ok = await showConfirmDialog(msg, t('yes') || 'Yes', t('no') || 'No');
if (ok) { restartWith('slow'); return; }
} else {
await showInfoDialog(msg, t('ok') || 'OK');
}
mediaShowError(card, t('media_rate_limited_short') || 'Rate limited');
finishSlot();
}
function finishSlot() {
mediaActiveCount = Math.max(0, mediaActiveCount - 1);
mediaPumpQueue();