Files
Exiled-Exchange-2/dataParser/data/rateLimiter.py
Kvan7 e19356e97b v0.8.2 (#480)
* Price checking does not work when using a gamepad(Ctrl+D) #452

Uses parts from #454 to fix the issue.

Co-authored-by: lawrsp <7957003+lawrsp@users.noreply.github.com>

* Fix tests :(

* Fix magic rarity item name parse in "cmn-Hant" language (#460)

* Fix magic rarity item name parse in "cmn-Hant" language

* Add by translated when ref is null

---------

Co-authored-by: kvan7 <kvan.valve@gmail.com>

* chore: yarn to npm and add missing step (#461)

* fix: update game config path for linux to poe2

* fix tests

* fix: MacOS crash on startup (#428)

* fix: MacOS crash on startup

* update for windows/linux

* move main app startup into function
Mac calls that in async, other platforms proceed in sync.

* [PoE2] - Relics broken again #444

* test prettier and add npm script

* add format/lint support to main

* Fix defineProps macro

* Run speed v0.8.0 - Russian. #447

* fix negative gold

* Merge branch 'Kvan7:master' into master

* fix: app-ready fixing before we're ready

* Merge branch 'dev' into pr/larssn/428

* should work?

* Merge commit 'ab1c8bfa3a31b06da9cf18db0273f6a92e407bc5' into pr/larssn/428

* fix being lazy on the merge

* fix: add executable bit to compilation script (#465)

* Item Images (#472)

* image data stuff

* ignore lookup file

* update testing

* sort items

* change sort ot be by refname

* more code

* working pulling

* Fixes #456
Create script to request item's images from trade site #456

* Fixes #457
Update ImageFix to use new saved images before poe1 ones #457

* add images to items

* Add Spear as item category

* Add Flail as category

* Add "goodness" from upstream

* Fix #474
Tier # missing from some defense stats #474

* minor oops

* remove error for waystones

* Add a bunch of images to the docs

* Extra widgets docs

* Update chat commands links docs

* add stash search docs

* Item info docs page

* more docs

* Update bug-report.yml

* Version bump

* extra version bump

---------

Co-authored-by: lawrsp <7957003+lawrsp@users.noreply.github.com>
Co-authored-by: Seth Falco <seth@falco.fun>
Co-authored-by: Amir Zarrinkafsh <nightah@me.com>
Co-authored-by: Lars <890725+larssn@users.noreply.github.com>
2025-03-27 09:50:27 -05:00

266 lines
9.3 KiB
Python

import logging
import math
from collections import defaultdict
from datetime import datetime
from time import sleep
from typing import Optional
import cloudscraper
from requests.models import CaseInsensitiveDict, Response
# Initial logging configuration
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def set_log_level(level):
"""Set the logging level."""
logger.setLevel(level)
logging.getLogger().setLevel(level) # Applies to all loggers
logger.info(f"Log level set to: {level}")
class RateLimitError(Exception):
pass
class RateLimit:
def __init__(self, limit: int, window: int, penalty: int):
self.max = limit
self.window = window
self.penalty = penalty
def __str__(self):
return (
f"RateLimiter<max={self.max}:window={self.window}:penalty={self.penalty}>"
)
def __repr__(self):
return (
f"RateLimiter<max={self.max}:window={self.window}:penalty={self.penalty}>"
)
def __eq__(self, other):
if not isinstance(other, RateLimit):
return False
return (
self.max == other.max
and self.window == other.window
and self.penalty == other.penalty
)
class RateLimiter:
def __init__(self, debug=False):
self.limits = defaultdict()
self.session = cloudscraper.create_scraper(interpreter="nodejs", debug=debug)
def wait(self, duration: int | float):
"""Wait for a specified duration.
Parameters
----------
duration : int | float
The duration to wait in seconds.
"""
assert isinstance(duration, int) or isinstance(duration, float)
assert duration > 0
sleep(duration)
def parse_window_limit(self, window: str) -> RateLimit:
"""Takes a string in the format "limit:interval:penalty" and returns a RateLimit object.
Parameters
----------
window : str
The string to parse.
Returns
-------
RateLimit
The parsed RateLimit object.
"""
assert isinstance(window, str)
split_window = window.split(":")
assert len(split_window) == 3
return RateLimit(
int(split_window[0]),
int(split_window[1]),
int(split_window[2]),
)
def parse_rate_limit(self, limit: str) -> list[RateLimit]:
"""Takes a csv string of rate limits and returns a list of RateLimit objects.
Parameters
----------
limit : str
csv string of rate limits
Returns
-------
list[RateLimit]
The parsed list of RateLimit objects.
"""
assert isinstance(limit, str)
split_limit = limit.split(",")
windows = []
for window in split_limit:
windows.append(self.parse_window_limit(window))
return windows
def get_limits(self, limit: str, state: str) -> list[tuple[RateLimit, RateLimit]]:
"""Takes a group of rate limits and the current state of rate limits from the server and returns a paired list of RateLimit objects.
Parameters
----------
limit : str
csv of rate limits
state : str
csv of the limits but with the current state according to the server
Returns
-------
list[tuple[RateLimit, RateLimit]]
The paired list of RateLimit objects. The first element in the tuple is the limit and the second element is the state.
"""
assert isinstance(limit, str)
assert isinstance(state, str)
# limits follow format: "limit:interval:penalty"
limits = self.parse_rate_limit(limit)
assert isinstance(limits, list)
assert len(limits) > 0
assert isinstance(limits[0], RateLimit)
states = self.parse_rate_limit(state)
assert isinstance(states, list)
assert len(states) > 0
assert isinstance(states[0], RateLimit)
limit_groups = []
for limit_group, state_group in zip(limits, states):
assert limit_group.window == state_group.window
limit_groups.append((limit_group, state_group))
return limit_groups
def wait_if_exceeded(self, limit: RateLimit, state: RateLimit):
"""Waits using time.sleep if the server says we are on a penalty currently. Will wait for the FULL penalty, not just what the server says is remaining.
Parameters
----------
limit : RateLimit
A specific period limit
state : RateLimit
The current state of the limit
"""
assert isinstance(limit, RateLimit)
assert isinstance(state, RateLimit)
assert isinstance(state.penalty, int)
assert isinstance(limit.penalty, int)
# If the server says we are on penalty
if state.penalty > 0:
# Wait for the full penalty
self.wait(limit.penalty)
# It should be safe to assume this will never occur, and thus should raise an error
# Since waiting though, we will just log an error
logger.error(
f"Rate limit exceeded. Waiting for full penalty of {limit.penalty} seconds. RateLimit: {limit}, State: {state}"
)
def wait_if_needed(self, limit: RateLimit, state: RateLimit, policy: str):
assert isinstance(limit, RateLimit)
assert isinstance(state, RateLimit)
assert isinstance(policy, str)
# If we are less than 1.2x the average rate since last update, wait for 1.1x the average rate
average_request_rate = limit.window / limit.max * 2
last_update = self.limits.get(policy, {}).get("last_update")
assert isinstance(last_update, datetime)
time_since_last_update = (datetime.now() - last_update).total_seconds()
if time_since_last_update <= average_request_rate * 1.5:
# Should almost always happen for limit with the longest average request rate
logger.debug(f"Waiting for {average_request_rate * 1.4} | {limit}, {state}")
self.wait(average_request_rate * 1.4)
# If we are close to exceeding this limit, wait 3x the average rate
if state.max >= math.floor(limit.max * 0.9):
logger.warning(
f"Close to exceeding limit, waiting for {average_request_rate * 3} | {limit}, {state}"
)
self.wait(average_request_rate * 3)
def wait_limit(self, policy: str, rules: str, limit: str, state: str):
assert isinstance(policy, str)
assert isinstance(rules, str)
assert isinstance(limit, str)
assert isinstance(state, str)
rate_limits = self.get_limits(limit, state)
self.limits[policy] = {
"policy": policy,
"rules": rules,
"limit": limit,
"state": state,
"rate_limits": rate_limits,
"last_update": datetime.now(),
"prev_state": self.limits.get(policy, {}).get("state"),
"prev_rate_limits": self.limits.get(policy, {}).get("rate_limits"),
}
rate_limits.reverse()
for limit, state in rate_limits:
self.wait_if_exceeded(limit, state)
self.wait_if_needed(limit, state, policy)
logger.debug(f"Current rate limits: {self.limits}")
def handle_headers(self, headers: dict[str, str], status_code: int):
"""Handles the headers returned from the server."""
assert isinstance(headers, CaseInsensitiveDict)
assert isinstance(status_code, int)
if status_code == 429:
logger.error("Rate limit exceeded.")
retry_after = headers.get("Retry-After")
if retry_after:
logger.error(f"Retry after: {retry_after}")
self.wait(int(retry_after))
else:
logger.error("No retry after header found.")
ac_headers = headers.get("access-control-expose-headers")
if isinstance(ac_headers, str):
ac_headers = ac_headers.lower()
if (
"x-rate-limit-policy" in ac_headers
and "x-rate-limit-rules" in ac_headers
and "x-rate-limit-ip" in ac_headers
and "x-rate-limit-ip-state" in ac_headers
):
self.wait_limit(
headers.get("x-rate-limit-policy"),
headers.get("x-rate-limit-rules"),
headers.get("x-rate-limit-ip"),
headers.get("x-rate-limit-ip-state"),
)
else:
logger.error("No access-control-expose-headers header found.")
def get(
self,
url: str,
payload: Optional[dict[str, str]] = None,
headers: Optional[dict[str, str]] = None,
) -> Response:
response = self.session.get(url, json=payload, headers=headers)
self.handle_headers(response.headers, response.status_code)
return response
def post(
self,
url: str,
payload: Optional[dict[str, str]] = None,
headers: Optional[dict[str, str]] = None,
) -> Response:
response = self.session.post(url, json=payload, headers=headers)
self.handle_headers(response.headers, response.status_code)
return response