audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: prevent now-playing flood from degrading API (#1225)

* fix: prevent now-playing flood from degrading API

a single client firing POST /now-playing/ every 5s for an hour caused
p95 latency to spike to 2.9s across the entire API (2026-04-02 incident).

two root causes:
- frontend: progressBucket rounded to 5s but throttle was 10s, so the
fingerprint changed mid-throttle and bypassed the interval check
- backend: @limiter.exempt trusted client-side throttle entirely

fixes:
- frontend: align progressBucket to REPORT_INTERVAL_MS (10s) so the
fingerprint can't change between throttled reports
- backend: add 12/minute rate limit on POST and DELETE /now-playing/
as a server-side safety net (generous for normal use, catches runaways)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* widen now-playing rate limit to 30/minute

12/minute was too tight — normal playback is 6/min but rapid
play/pause/seek interactions could briefly spike above 12.
30/min gives 5x headroom for normal use while still catching
runaways (the incident was 46/min).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
436ea15f f62ba892

+12 -8
+8 -5
backend/src/backend/api/now_playing.py
··· 2 2 3 3 exposes real-time playback state for services like teal.fm/Piper. 4 4 5 - note: these endpoints are exempt from rate limiting because they're 6 - already throttled client-side (10-second intervals, 1-second debounce). 5 + note: POST/DELETE are rate-limited server-side as a safety net. 6 + the frontend also throttles client-side (10-second intervals). 7 + GET endpoints for Piper are exempt since they're read-only. 7 8 """ 8 9 9 10 from typing import Annotated 10 11 11 - from fastapi import APIRouter, Depends, HTTPException, Response 12 + from fastapi import APIRouter, Depends, HTTPException, Request, Response 12 13 from pydantic import BaseModel 13 14 from sqlalchemy import select 14 15 from sqlalchemy.ext.asyncio import AsyncSession ··· 64 65 65 66 66 67 @router.post("/") 67 - @limiter.exempt 68 + @limiter.limit("30/minute") 68 69 async def update_now_playing( 70 + request: Request, 69 71 update: NowPlayingUpdate, 70 72 session: Session = Depends(require_auth), 71 73 ) -> StatusResponse: ··· 94 96 95 97 96 98 @router.delete("/") 97 - @limiter.exempt 99 + @limiter.limit("30/minute") 98 100 async def clear_now_playing( 101 + request: Request, 99 102 session: Session = Depends(require_auth), 100 103 ) -> StatusResponse: 101 104 """clear now playing state (authenticated).
+4 -3
frontend/src/lib/now-playing.svelte.ts
··· 38 38 } 39 39 40 40 // create state fingerprint to detect actual changes 41 + // bucket must be >= REPORT_INTERVAL_MS so the fingerprint doesn't 42 + // change mid-throttle and bypass the interval check 41 43 const stateFingerprint = JSON.stringify({ 42 44 trackId: track.id, 43 45 isPlaying, 44 - // round progress to nearest 5 seconds to reduce noise 45 - progressBucket: Math.floor(currentTimeMs / 5000) 46 + progressBucket: Math.floor(currentTimeMs / REPORT_INTERVAL_MS) 46 47 }); 47 48 48 49 // skip if state hasn't meaningfully changed ··· 103 104 this.lastReportedState = JSON.stringify({ 104 105 trackId: track.id, 105 106 isPlaying, 106 - progressBucket: Math.floor(currentTimeMs / 5000) 107 + progressBucket: Math.floor(currentTimeMs / REPORT_INTERVAL_MS) 107 108 }); 108 109 } 109 110 }, REPORT_DEBOUNCE_MS);