aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Crute <mike@crute.us>2019-04-02 05:23:23 +0000
committerMike Crute <mike@crute.us>2020-06-16 00:14:47 +0000
commit1080ae62dd6ae3a47b0f222537b5343f6715490f (patch)
treea17bbdb5d83262a9709bb64679c3ffb0c81e2ceb
parent95b3f19c042b8dd582a46c1b58e2b0c5e81f4d5b (diff)
downloadpydora-rate-limits.tar.bz2
pydora-rate-limits.tar.xz
pydora-rate-limits.zip
WIP: rate limitsrate-limits
-rw-r--r--pandora/client.py36
-rw-r--r--pandora/clientbuilder.py5
-rw-r--r--pandora/ratelimit.py164
-rw-r--r--pydora/player.py28
4 files changed, 227 insertions, 6 deletions
diff --git a/pandora/client.py b/pandora/client.py
index a98056c..ff27678 100644
--- a/pandora/client.py
+++ b/pandora/client.py
@@ -14,6 +14,7 @@ For simplicity use a client builder from pandora.clientbuilder to create an
14instance of a client. 14instance of a client.
15""" 15"""
16from . import errors 16from . import errors
17from .ratelimit import WarningTokenBucket
17 18
18 19
19class BaseAPIClient: 20class BaseAPIClient:
@@ -36,7 +37,27 @@ class BaseAPIClient:
36 partner_password, 37 partner_password,
37 device, 38 device,
38 default_audio_quality=MED_AUDIO_QUALITY, 39 default_audio_quality=MED_AUDIO_QUALITY,
40 rate_limiter=WarningTokenBucket,
39 ): 41 ):
42 """Initialize an API Client
43
44 transport
45 instance of a Pandora transport
46 partner_user
47 partner username
48 partner_password
49 partner password
50 device
51 device type identifier
52 default_audio_quality
53 audio quality level, one of the *_AUDIO_QUALITY constants in the
54 BaseAPIClient class
55 rate_limiter
56 class (not instance) implementing the pandora.ratelimit.TokenBucket
57 interface. Used by various client components to handle rate
58 limits with the Pandora API. The default rate limited warns when
59 the rate limit has been exceeded but does not enforce the limit.
60 """
40 self.transport = transport 61 self.transport = transport
41 self.partner_user = partner_user 62 self.partner_user = partner_user
42 self.partner_password = partner_password 63 self.partner_password = partner_password
@@ -45,6 +66,17 @@ class BaseAPIClient:
45 self.username = None 66 self.username = None
46 self.password = None 67 self.password = None
47 68
69 # Global rate limiter for all methods, allows a call rate of 2 calls
70 # per second. This limit is based on nothing but seems sane to prevent
71 # runaway code from hitting Pandora too hard.
72 self._api_limiter = rate_limiter(120, 1, 1)
73
74 # Rate limiter for the get_playlist API which has a much lower
75 # server-side limit than other APIs. This was determined
76 # experimentally. This is applied before and in addition to the global
77 # API rate limit.
78 self._playlist_limiter = rate_limiter(5, 1, 1)
79
48 def _partner_login(self): 80 def _partner_login(self):
49 partner = self.transport( 81 partner = self.transport(
50 "auth.partnerLogin", 82 "auth.partnerLogin",
@@ -98,6 +130,8 @@ class BaseAPIClient:
98 return [] 130 return []
99 131
100 def __call__(self, method, **kwargs): 132 def __call__(self, method, **kwargs):
133 self._api_limiter.consume(1)
134
101 try: 135 try:
102 return self.transport(method, **kwargs) 136 return self.transport(method, **kwargs)
103 except errors.InvalidAuthToken: 137 except errors.InvalidAuthToken:
@@ -125,6 +159,8 @@ class APIClient(BaseAPIClient):
125 def get_playlist(self, station_token, additional_urls=None): 159 def get_playlist(self, station_token, additional_urls=None):
126 from .models.playlist import Playlist 160 from .models.playlist import Playlist
127 161
162 self._playlist_limiter.consume(1)
163
128 if additional_urls is None: 164 if additional_urls is None:
129 additional_urls = [] 165 additional_urls = []
130 166
diff --git a/pandora/clientbuilder.py b/pandora/clientbuilder.py
index 2b11ffb..1eb3811 100644
--- a/pandora/clientbuilder.py
+++ b/pandora/clientbuilder.py
@@ -8,6 +8,7 @@ import os.path
8 8
9from configparser import ConfigParser 9from configparser import ConfigParser
10from .client import APIClient 10from .client import APIClient
11from .ratelimit import WarningTokenBucket
11from .transport import Encryptor, APITransport, DEFAULT_API_HOST 12from .transport import Encryptor, APITransport, DEFAULT_API_HOST
12 13
13 14
@@ -94,8 +95,9 @@ class APIClientBuilder:
94 95
95 DEFAULT_CLIENT_CLASS = APIClient 96 DEFAULT_CLIENT_CLASS = APIClient
96 97
97 def __init__(self, client_class=None): 98 def __init__(self, client_class=None, rate_limiter=WarningTokenBucket):
98 self.client_class = client_class or self.DEFAULT_CLIENT_CLASS 99 self.client_class = client_class or self.DEFAULT_CLIENT_CLASS
100 self.rate_limiter = rate_limiter
99 101
100 def build_from_settings_dict(self, settings): 102 def build_from_settings_dict(self, settings):
101 enc = Encryptor(settings["DECRYPTION_KEY"], settings["ENCRYPTION_KEY"]) 103 enc = Encryptor(settings["DECRYPTION_KEY"], settings["ENCRYPTION_KEY"])
@@ -116,6 +118,7 @@ class APIClientBuilder:
116 settings["PARTNER_PASSWORD"], 118 settings["PARTNER_PASSWORD"],
117 settings["DEVICE"], 119 settings["DEVICE"],
118 quality, 120 quality,
121 self.rate_limiter,
119 ) 122 )
120 123
121 124
diff --git a/pandora/ratelimit.py b/pandora/ratelimit.py
new file mode 100644
index 0000000..427ede9
--- /dev/null
+++ b/pandora/ratelimit.py
@@ -0,0 +1,164 @@
1"""
2Pandora Rate Limiter
3"""
4import time
5import warnings
6
7from .errors import PandoraException
8
9
10class RateLimitExceeded(PandoraException):
11 """Exception thrown when rate limit is exceeded
12 """
13
14 code = 0
15 message = "Rate Limit Exceeded"
16
17
18class TokenBucketCallbacks(object):
19 """Interface for TokenBucket Callbacks
20
21 These methods get called by the token bucket during certain parts of the
22 bucket lifecycle.
23 """
24
25 def near_depletion(self, tokens_left):
26 """Bucket near depletion callback
27
28 This callback is called when the token bucket is nearly depleted, as
29 defined by the depletion_level member of the TokenBucket class. This
30 method provides a hook for clients to warn when the token bucket is
31 nearly consumed. The return value of this method is ignored.
32 """
33 return
34
35 def depleted(self, tokens_wanted, tokens_left):
36 """Bucket depletion callback
37
38 This callback is called when the token bucket has been depleted.
39 Returns a boolean indicating if the bucket should call its default
40 depletion handler. If this method returns False the token bucket will
41 not return any tokens but will also not call the default token
42 depletion handler.
43
44 This method is useful if clients want to customize depletion behavior.
45 """
46 return True
47
48 def consumed(self, tokens_wanted, tokens_left):
49 """Bucket consumption callback
50
51 This callback is called when a client successfully consumes tokens from
52 a bucket. The return value of this method is ignored.
53 """
54 return
55
56
57class TokenBucket(object):
58 def __init__(self, capacity, refill_tokens, refill_rate, callbacks=None):
59 """Initialize a rate limiter
60
61 capacity
62 the number of tokens the bucket holds when completely full
63 refill_tokens
64 number of tokens to add to the bucket during each refill cycle
65 refill_rate
66 the number of seconds between token refills
67 """
68 self.capacity = capacity
69 self.refill_tokens = refill_tokens
70 self.refill_rate = refill_rate
71 self.callbacks = callbacks or TokenBucketCallbacks()
72
73 # Depletion level at which the near_depletion callback is called.
74 # Defaults to 20% of the bucket available. Not exposed in the
75 # initializer because this should generally be good enough.
76 self.depletion_level = self.capacity / 5
77
78 self._available_tokens = capacity
79 self._last_refill = time.time()
80
81 @classmethod
82 def creator(cls, callbacks=None):
83 """Returns a TokenBucket creator
84
85 This method is used when clients want to customize the callbacks but
86 defer class construction to the real consumer.
87 """
88
89 def constructor(capacity, refill_tokens, refill_rate):
90 return cls(capacity, refill_tokens, refill_rate, callbacks)
91
92 return constructor
93
94 def _replentish(self):
95 now = time.time()
96
97 refill_unit = round((now - self._last_refill) / self.refill_rate)
98 if refill_unit > 0:
99 self._last_refill = now
100 self._available_tokens = min(
101 self.capacity, refill_unit * self.refill_tokens
102 )
103
104 def _insufficient_tokens(self, tokens_wanted):
105 return
106
107 def _sufficient_tokens(self, tokens_wanted):
108 return
109
110 def consume(self, tokens=1):
111 """Consume a number of tokens from the bucket
112
113 May return a boolean indicating that sufficient tokens were available
114 for consumption. Implementations may have different behaviour when the
115 bucket is empty. This method may block or throw.
116 """
117 self._replentish()
118
119 if self._available_tokens >= tokens:
120 self._available_tokens -= tokens
121
122 if self._available_tokens <= self.depletion_level:
123 self.callbacks.near_depletion(self._available_tokens)
124
125 self.callbacks.consumed(tokens, self._available_tokens)
126 self._sufficient_tokens(tokens)
127
128 return True
129 else:
130 if self.callbacks.depleted(tokens, self._available_tokens):
131 self._insufficient_tokens(tokens)
132 return False
133
134
135class BlockingTokenBucket(TokenBucket):
136 """Token bucket that blocks on exhaustion
137 """
138
139 def _insufficient_tokens(self, tokens_wanted):
140 tokens_per_second = self.refill_rate / self.refill_tokens
141 excess_tokens = tokens_wanted - self._available_tokens
142 time.sleep(tokens_per_second * excess_tokens)
143
144
145class ThrowingTokenBucket(TokenBucket):
146 """Token bucket that throws on exhaustion
147 """
148
149 def _insufficient_tokens(self, tokens_wanted):
150 raise RateLimitExceeded("Unable to acquire enough tokens")
151
152
153class WarningTokenBucket(TokenBucket):
154 """Token bucket that warns on exhaustion
155
156 This token bucket doesn't enforce the rate limit and is designed to be a
157 softer policy for backwards compatability with code that was written before
158 rate limiting was added.
159 """
160
161 def _insufficient_tokens(self, tokens_wanted):
162 warnings.warn(
163 "Pandora API rate limit exceeded!", warnings.ResourceWarning
164 )
diff --git a/pydora/player.py b/pydora/player.py
index c4ce2e0..d2b1388 100644
--- a/pydora/player.py
+++ b/pydora/player.py
@@ -10,7 +10,9 @@ import os
10import sys 10import sys
11import logging 11import logging
12import argparse 12import argparse
13from pandora import clientbuilder 13from pandora.clientbuilder import PydoraConfigFileBuilder
14from pandora.clientbuilder import PianobarConfigFileBuilder
15from pandora.ratelimit import BlockingTokenBucket, TokenBucketCallbacks
14 16
15from .utils import Colors, Screen 17from .utils import Colors, Screen
16from .audio_backend import RemoteVLC 18from .audio_backend import RemoteVLC
@@ -46,6 +48,18 @@ class PlayerCallbacks:
46 pass 48 pass
47 49
48 50
51class PlayerTokenBucketCallbacks(TokenBucketCallbacks):
52 def __init__(self, screen):
53 self.screen = screen
54
55 def near_depletion(self, tokens_left):
56 self.screen.print_error("Near Pandora API call rate limit. Slow down!")
57
58 def depleted(self, tokens_wanted, tokens_left):
59 self.screen.print_error("Pandora API call rate exceeded. Please wait!")
60 return True
61
62
49class PlayerApp: 63class PlayerApp:
50 64
51 CMD_MAP = { 65 CMD_MAP = {
@@ -75,10 +89,10 @@ class PlayerApp:
75 try: 89 try:
76 host, port = vlc_net.split(":") 90 host, port = vlc_net.split(":")
77 player = RemoteVLC(host, port, self, sys.stdin) 91 player = RemoteVLC(host, port, self, sys.stdin)
78 Screen.print_success("Using Remote VLC") 92 self.screen.print_success("Using Remote VLC")
79 return player 93 return player
80 except PlayerUnusable: 94 except PlayerUnusable:
81 Screen.print_error("Unable to connect to vlc") 95 self.screen.print_error("Unable to connect to vlc")
82 raise 96 raise
83 97
84 try: 98 try:
@@ -99,12 +113,16 @@ class PlayerApp:
99 sys.exit(1) 113 sys.exit(1)
100 114
101 def get_client(self): 115 def get_client(self):
116 tb_maker = BlockingTokenBucket.creator(
117 PlayerTokenBucketCallbacks(self.screen)
118 )
119
102 cfg_file = os.environ.get("PYDORA_CFG", "") 120 cfg_file = os.environ.get("PYDORA_CFG", "")
103 builder = clientbuilder.PydoraConfigFileBuilder(cfg_file) 121 builder = PydoraConfigFileBuilder(cfg_file, rate_limiter=tb_maker)
104 if builder.file_exists: 122 if builder.file_exists:
105 return builder.build() 123 return builder.build()
106 124
107 builder = clientbuilder.PianobarConfigFileBuilder() 125 builder = PianobarConfigFileBuilder(rate_limiter=tb_maker)
108 if builder.file_exists: 126 if builder.file_exists:
109 return builder.build() 127 return builder.build()
110 128