Skip to content

Add rate limiter#383

Open
henrikwidlund wants to merge 4 commits into
ol-iver:mainfrom
henrikwidlund:feature/rate_limiter
Open

Add rate limiter#383
henrikwidlund wants to merge 4 commits into
ol-iver:mainfrom
henrikwidlund:feature/rate_limiter

Conversation

@henrikwidlund

Copy link
Copy Markdown
Contributor

First of many PRs to come :)

Relates to #375

@ol-iver ol-iver left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rate limiter. It's nice 😄

Please add a switch to enable/disable the rate limiter for a receiver instance in general.

Comment thread denonavr/api.py Outdated
Comment on lines +222 to +238
if skip_confirmation:
task = asyncio.create_task(
self.httpx_async_client.async_get(
endpoint,
self.host,
self.timeout,
self.read_timeout,
cache_id=cache_id,
record_latency=False,
skip_rate_limiter=skip_rate_limiter,
)
)
task.add_done_callback(
self._http_callback_tasks.discard
) # Prevent garbage collection
return httpx.Response(200, text="")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a task for each HTTP request is too much compute overhead and faking the response is no good practice. Please remove the skip_confirmation part for HTTP requests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, I don't think it's actually needed anyway. From what I remember it was a "red herring", and the delay I saw came from something else.

Comment thread denonavr/rate_limiter.py Outdated
Comment on lines +64 to +66
initial_wait_ms: float = 100.0,
min_wait_ms: float = 100.0,
max_wait_ms: float = 200.0,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you call this ..._wait_ms? This gives the impression that even the first request needs to wait 100ms.
Later you even calculate rates from this wait times. Please call it rate from the beginning.

Also the minimum rate (10 req/s) looks too low. It could be 100 req/s.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I landed on 10 rps by sending volume up/down commands with different delays in-between, and landed on 10 because that seemed like a good balance with speed for the command without causing the receiver to buffer the commands and then keep executing them for seconds after I stopped sending commands. My thought was that it is more irritating to not be able to send a different command after you let go of a button than having volume not increase by more than 10 steps per second. I'm open for adjusting the 10rps, but 100 will cause issues.

Comment thread denonavr/api.py
Comment on lines +981 to +982
if record_latency:
self._rate_limiter.record_latency(self.host, start)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could move this from finally to else.
The default timeout is 2s. When the receiver does not send a confirmation within this timeframe, the command might never confirm. This should not affect the rate limit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, also found another place where it applies

Comment thread denonavr/api.py Outdated
Comment on lines +172 to +174
async def aclose(self):
"""Close rate limiter when done."""
await self.rate_limiter.aclose()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method isn't called anywhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think this one slipped through, it is called in my fork in code that has not been included here. Do you want me to delete it or replace it with something else so that the resource is disposed when no longer needed?

Comment thread denonavr/rate_limiter.py Outdated
if latency_tracker := self._latencies.get(destination):
latency_tracker.update(seconds)

async def aclose(self) -> None:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only place where this method is called, is never called 😉
There must be a better way to do this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems my comment disappeared, it is a leftover from my own fork that does things differently. Fixed it (I hope)

Comment thread denonavr/rate_limiter.py Outdated
Comment on lines +126 to +135
while not self._shutdown_event.is_set():
await asyncio.sleep(self._adjust_interval)
if self._shutdown_event.is_set():
break
avg = self._latencies[key].value
new_rate = self._target_rate(avg)
# AsyncLimiter does not expose direct rate mutation;
# recreate it atomically under a lock to avoid races.
async with self._locks[key]:
self._limiters[key] = AsyncLimiter(new_rate, time_period=1)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you recreate the limiter every 2 seconds even when there are no changes? This does not look efficient.

You could start a task adjust the rate limiter in record_latency if the current avg rate and the limiter rate differ by more than 20% and maximum once every 10s.
Then you can also remove the update loop which cannot be stopped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adjusted

Comment thread denonavr/api.py Outdated
Comment on lines +710 to +711
skip_rate_limiter=True,
record_latency=False,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many commands hammer on the receiver in a short period of time. Isn't this the perfect place to use rate limiting?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is yet another case of mismatch between this repo and my fork, in my work I have a delay between each command so the rate limiter is not needed there and I didn't want the automatic adjust of the rate limiter to be affected since I did do my own measurements to make sure it is as quick as possible without stalling the receiver. I will change it to work with your repo.

Comment thread denonavr/rate_limiter.py Outdated
seconds = time.monotonic() - start
# Only record if destination already initialized
if latency_tracker := self._latencies.get(destination):
latency_tracker.update(seconds)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you skip the update for big values like >1s.
The audyssey endpoint of my receiver take like 4 seconds to respond and I don't want the rate limit go down just because I call it a couple of times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think 1s might be too aggressive and might sidestep the intent of the limiter. I'll exclude those that take 2s. Let me know if you disagree and I can change it to 1.

Comment thread denonavr/api.py
)
start = time.monotonic()
if not skip_rate_limiter:
await self._rate_limiter.acquire(self.host)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a different limiter for telnet as HTTP and telnet are also different endpoints.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are different, _rate_limiter is constructed by attr in the DenonAVRTelnetApi and HTTPXAsyncClient, or are you referring to using different settings?

Comment thread denonavr/api.py Outdated
data=data,
cache_id=cache_id,
record_latency=False,
skip_rate_limiter=True,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this method not rate limited?
In my opinion all requests should go though the rate limiter if it is enabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also because of how the code is used in my fork, but you're right, in a general purpose library it makes sense to rate limit everything

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants