Skip to content

Commit

Permalink
Create nowplaying/albumart rate limiter based on current song (#249)
Browse files Browse the repository at this point in the history
PR creates a new rate limiter that will slow down requests on the
`/api/nowplaying/albumart` API. This API serves relatively large file
image, so it's necessary to have a rate limiter mostly for the sake of
data transfer volume.

This works based on current song playing. When a new song starts, a
client under a single IP can request the song's album art up to **16
times over the _duration_ of the entire song**. 16 was arbitrarily
selected as an amount I expect most people to be able to comfortably
pull album art under normal radio usage. We'll adjust or consider
parameterizing this if necessary. If the client hits the limit before
the current song ends they will receive a `304` error to indicate that
Cadence will not be re-transmitting the art anymore, though the album
art is still the same and it is safe to use whatever it last sent.

When the current song ends, the database tracking the artwork request
count is flushed and all clients reset their allowed usage of the API.
  • Loading branch information
kenellorando authored Mar 28, 2023
2 parents fad4ddf + d8a0a0a commit 1396ca0
Show file tree
Hide file tree
Showing 4 changed files with 73 additions and 12 deletions.
4 changes: 4 additions & 0 deletions cadence/server/api_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ func icecastMonitor() {

if (prev.Song.Title != now.Song.Title) || (prev.Song.Artist != now.Song.Artist) {
clog.Info("icecastMonitor", fmt.Sprintf("Now Playing: %s by %s", now.Song.Title, now.Song.Artist))
// Dump the artwork rate limiter database first thing before updates
// are sent out to reset artwork request count.
dbr.RateLimitArt.FlushDB(ctx)

radiodata_sse.SendEventMessage(now.Song.Title, "title", "")
radiodata_sse.SendEventMessage(now.Song.Artist, "artist", "")
if (prev.Song.Title != "") && (prev.Song.Artist != "") {
Expand Down
73 changes: 65 additions & 8 deletions cadence/server/db_redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,46 +18,103 @@ var ctx = context.Background()
var dbr = RedisClient{}

type RedisClient struct {
RateLimit *redis.Client
RateLimitRequest *redis.Client
RateLimitArt *redis.Client
}

func redisInit() {
dbr.RateLimit = redis.NewClient(&redis.Options{
dbr.RateLimitRequest = redis.NewClient(&redis.Options{
Addr: c.RedisAddress + c.RedisPort,
Password: "",
DB: 0,
})
dbr.RateLimitArt = redis.NewClient(&redis.Options{
Addr: c.RedisAddress + c.RedisPort,
Password: "",
DB: 1,
})
}

func rateLimit(next http.Handler) http.Handler {
func rateLimitRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, err := checkIP(r)
if err != nil {
clog.Error("rateLimit", "Error encountered while checking IP address.", err)
clog.Error("rateLimitRequest", "Error encountered while checking IP address.", err)
w.WriteHeader(http.StatusInternalServerError) // 500 Internal Server Error
return
}
_, err = dbr.RateLimit.Get(ctx, ip).Result()
_, err = dbr.RateLimitRequest.Get(ctx, ip).Result()
if err != nil {
if err == redis.Nil {
// redis.Nil means the IP is not in the database.
// We create a new entry for the IP which will automatically
// expire after the configured rate limit time expires.
dbr.RateLimit.Set(ctx, ip, nil, time.Duration(c.RequestRateLimit)*time.Second)
dbr.RateLimitRequest.Set(ctx, ip, nil, time.Duration(c.RequestRateLimit)*time.Second)
next.ServeHTTP(w, r)
} else {
clog.Error("rateLimit", "Error while attempting to check for IP in rate limiter.", err)
clog.Error("rateLimitRequest", "Error while attempting to check for IP in rate limiter.", err)
w.WriteHeader(http.StatusInternalServerError) // 500 Internal Server Error
return
}
} else {
clog.Debug("rateLimit", fmt.Sprintf("Client <%s> is rate limited.", ip))
clog.Debug("rateLimitRequest", fmt.Sprintf("Client <%s> is rate limited.", ip))
w.WriteHeader(http.StatusTooManyRequests) // 429 Too Many Requests
return
}
})
}

func rateLimitArt(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, err := checkIP(r)
if err != nil {
clog.Error("rateLimitArt", "Error encountered while checking IP address.", err)
w.WriteHeader(http.StatusInternalServerError) // 500 Internal Server Error
return
}
_, err = dbr.RateLimitArt.Get(ctx, ip).Result()
if err != nil {
if err == redis.Nil {
// redis.Nil means the IP is not in the database.
// We create a new entry for the IP with start value 1,
// representing the first request for art.
dbr.RateLimitArt.Set(ctx, ip, 1, time.Duration(200)*time.Second)
next.ServeHTTP(w, r)
} else {
clog.Error("rateLimitArt", "Error while attempting to check for IP in rate limiter.", err)
w.WriteHeader(http.StatusInternalServerError) // 500 Internal Server Error
return
}
} else {
// If there is no error, the IP is at least in the database.
// Check the value of the IP address.
count, err := dbr.RateLimitArt.Get(ctx, ip).Int()
if err != nil {
clog.Error("rateLimitArt", "Error while converting art served value to integer.", err)
w.WriteHeader(http.StatusInternalServerError) // 500 Internal Server Error
return
}
// We're using 16 as an arbitrary maximum number of times we expect any client to need
// to legitimately need to get album art over the course of a duration of one song.
// This strikes a balance between allowing a user to get album art when they need it
// and preventing malicious users from unnecessarily consuming bandwidth.
//
// Basically, a 304 response means "You've requested artwork a bit too much,
// so we're not going to send you new artwork for now. The artwork hasn't changed
// since you last asked, so you're safe to use whatever you last cached."
if count >= 16 {
clog.Debug("rateLimitArt", fmt.Sprintf("Client <%s> is rate limited.", ip))
w.WriteHeader(http.StatusNotModified) // 304 Not Modified
return
} else {
clog.Debug("rateLimitArt", fmt.Sprintf("Client <%s> is rate limited.", ip))
dbr.RateLimitArt.Set(ctx, ip, count+1, time.Duration(200)*time.Second)
next.ServeHTTP(w, r)
}
}
})
}

func checkIP(r *http.Request) (ip string, err error) {
// We look at the remote address and check the IP.
// If for some reason no remote IP is there, we error to reject.
Expand Down
6 changes: 3 additions & 3 deletions cadence/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ func routes() *http.ServeMux {
r := http.NewServeMux()
r.Handle("/api/radiodata/sse", radiodata_sse)
r.Handle("/api/search", Search())
r.Handle("/api/request/id", rateLimit(RequestID()))
r.Handle("/api/request/bestmatch", rateLimit(RequestBestMatch()))
r.Handle("/api/request/id", rateLimitRequest(RequestID()))
r.Handle("/api/request/bestmatch", rateLimitRequest(RequestBestMatch()))
r.Handle("/api/nowplaying/metadata", NowPlayingMetadata())
r.Handle("/api/nowplaying/albumart", NowPlayingAlbumArt())
r.Handle("/api/nowplaying/albumart", rateLimitArt(NowPlayingAlbumArt()))
r.Handle("/api/history", History())
r.Handle("/api/listenurl", ListenURL())
r.Handle("/api/listeners", Listeners())
Expand Down
2 changes: 1 addition & 1 deletion config/cadence.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ POSTGRES_PASSWORD=CADENCE_PASS_EXAMPLE

# Development
CSERVER_DEVMODE=0
CSERVER_VERSION=5.4.1
CSERVER_VERSION=5.4.2
CSERVER_LOGLEVEL=5
CSERVER_ROOTPATH=/cadence/server/

Expand Down

0 comments on commit 1396ca0

Please # to comment.