update_manager: manage GitHub rate limits
Track rate limit attributes and reject requests when the user IP has reached their limit. Use conditional API requests to reduce the number of requests that count against the limit. Signed-off-by: Eric Callahan <arksine.code@gmail.com>
This commit is contained in:
parent
34af0b4bfb
commit
b0fda218f1
|
@ -12,9 +12,10 @@ import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
import io
|
import io
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import tornado.gen
|
import tornado.gen
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.locks import Event
|
from tornado.locks import Event
|
||||||
|
|
||||||
MOONRAKER_PATH = os.path.normpath(os.path.join(
|
MOONRAKER_PATH = os.path.normpath(os.path.join(
|
||||||
|
@ -67,6 +68,12 @@ class UpdateManager:
|
||||||
self.updaters['client'] = ClientUpdater(
|
self.updaters['client'] = ClientUpdater(
|
||||||
self, client_repo, client_path)
|
self, client_repo, client_path)
|
||||||
|
|
||||||
|
# GitHub API Rate Limit Tracking
|
||||||
|
self.gh_rate_limit = None
|
||||||
|
self.gh_limit_remaining = None
|
||||||
|
self.gh_limit_reset_time = None
|
||||||
|
self.gh_init_evt = Event()
|
||||||
|
|
||||||
self.server.register_endpoint(
|
self.server.register_endpoint(
|
||||||
"/machine/update/moonraker", ["POST"],
|
"/machine/update/moonraker", ["POST"],
|
||||||
self._handle_update_request)
|
self._handle_update_request)
|
||||||
|
@ -86,6 +93,8 @@ class UpdateManager:
|
||||||
# Register Ready Event
|
# Register Ready Event
|
||||||
self.server.register_event_handler(
|
self.server.register_event_handler(
|
||||||
"server:klippy_ready", self._set_klipper_repo)
|
"server:klippy_ready", self._set_klipper_repo)
|
||||||
|
# Initialize GitHub API Rate Limits
|
||||||
|
IOLoop.current().spawn_callback(self._init_api_rate_limit)
|
||||||
|
|
||||||
async def _set_klipper_repo(self):
|
async def _set_klipper_repo(self):
|
||||||
kinfo = self.server.get_klippy_info()
|
kinfo = self.server.get_klippy_info()
|
||||||
|
@ -126,6 +135,9 @@ class UpdateManager:
|
||||||
vinfo[name] = updater.get_update_status()
|
vinfo[name] = updater.get_update_status()
|
||||||
return {
|
return {
|
||||||
'version_info': vinfo,
|
'version_info': vinfo,
|
||||||
|
'github_rate_limit': self.gh_rate_limit,
|
||||||
|
'github_requests_remaining': self.gh_limit_remaining,
|
||||||
|
'github_limit_reset_time': self.gh_limit_reset_time,
|
||||||
'busy': self.current_update is not None}
|
'busy': self.current_update is not None}
|
||||||
|
|
||||||
async def execute_cmd(self, cmd, timeout=10., notify=False, retries=1):
|
async def execute_cmd(self, cmd, timeout=10., notify=False, retries=1):
|
||||||
|
@ -144,29 +156,113 @@ class UpdateManager:
|
||||||
scmd = shell_command.build_shell_command(cmd, None)
|
scmd = shell_command.build_shell_command(cmd, None)
|
||||||
return await scmd.run_with_response(timeout)
|
return await scmd.run_with_response(timeout)
|
||||||
|
|
||||||
async def github_request(self, url, is_download=False):
|
async def _init_api_rate_limit(self):
|
||||||
cto = rto = 5.
|
url = "https://api.github.com/rate_limit"
|
||||||
content_type = "application/vnd.github.v3+json"
|
while 1:
|
||||||
if is_download:
|
|
||||||
content_type = "application/zip"
|
|
||||||
rto = 120.
|
|
||||||
request = HTTPRequest(url, headers={"Accept": content_type},
|
|
||||||
connect_timeout=cto, request_timeout=rto)
|
|
||||||
retries = 5
|
|
||||||
while True:
|
|
||||||
try:
|
try:
|
||||||
resp = await self.http_client.fetch(request)
|
resp = await self.github_api_request(url, is_init=True)
|
||||||
except Exception as e:
|
core = resp['resources']['core']
|
||||||
|
self.gh_rate_limit = core['limit']
|
||||||
|
self.gh_limit_remaining = core['remaining']
|
||||||
|
self.gh_limit_reset_time = core['reset']
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Error Initializing GitHub API Rate Limit")
|
||||||
|
await tornado.gen.sleep(30.)
|
||||||
|
else:
|
||||||
|
reset_time = time.ctime(self.gh_limit_reset_time)
|
||||||
|
logging.info(
|
||||||
|
"GitHub API Rate Limit Initialized\n"
|
||||||
|
f"Rate Limit: {self.gh_rate_limit}\n"
|
||||||
|
f"Rate Limit Remaining: {self.gh_limit_remaining}\n"
|
||||||
|
f"Rate Limit Reset Time: {reset_time}, "
|
||||||
|
f"Seconds Since Epoch: {self.gh_limit_reset_time}")
|
||||||
|
break
|
||||||
|
self.gh_init_evt.set()
|
||||||
|
|
||||||
|
async def github_api_request(self, url, etag=None, is_init=False):
|
||||||
|
if not is_init:
|
||||||
|
timeout = time.time() + 30.
|
||||||
|
try:
|
||||||
|
await self.gh_init_evt.wait(timeout)
|
||||||
|
except Exception:
|
||||||
|
raise self.server.error(
|
||||||
|
"Timeout while waiting for GitHub "
|
||||||
|
"API Rate Limit initialization")
|
||||||
|
if self.gh_limit_remaining == 0:
|
||||||
|
curtime = time.time()
|
||||||
|
if curtime < self.gh_limit_reset_time:
|
||||||
|
raise self.server.error(
|
||||||
|
f"GitHub Rate Limit Reached\nRequest: {url}\n"
|
||||||
|
f"Limit Reset Time: {time.ctime(self.gh_limit_remaining)}")
|
||||||
|
headers = {"Accept": "application/vnd.github.v3+json"}
|
||||||
|
if etag is not None:
|
||||||
|
headers['If-None-Match'] = etag
|
||||||
|
retries = 5
|
||||||
|
while retries:
|
||||||
|
try:
|
||||||
|
resp = await self.http_client.fetch(
|
||||||
|
url, headers=headers, connect_timeout=5.,
|
||||||
|
request_timeout=5., raise_error=False)
|
||||||
|
except Exception:
|
||||||
|
retries -= 1
|
||||||
|
msg = f"Error Processing GitHub API request: {url}"
|
||||||
|
if not retries:
|
||||||
|
raise self.server.error(msg)
|
||||||
|
logging.exception(msg)
|
||||||
|
await tornado.gen.sleep(1.)
|
||||||
|
continue
|
||||||
|
etag = resp.headers.get('etag', None)
|
||||||
|
if etag is not None:
|
||||||
|
if etag[:2] == "W/":
|
||||||
|
etag = etag[2:]
|
||||||
|
logging.info(
|
||||||
|
"GitHub API Request Processed\n"
|
||||||
|
f"URL: {url}\n"
|
||||||
|
f"Response Code: {resp.code}\n"
|
||||||
|
f"Response Reason: {resp.reason}\n"
|
||||||
|
f"ETag: {etag}")
|
||||||
|
if resp.code == 403:
|
||||||
|
raise self.server.error(
|
||||||
|
f"Forbidden GitHub Request: {resp.reason}")
|
||||||
|
elif resp.code == 304:
|
||||||
|
logging.info(f"Github Request not Modified: {url}")
|
||||||
|
return None
|
||||||
|
if resp.code != 200:
|
||||||
retries -= 1
|
retries -= 1
|
||||||
if not retries:
|
if not retries:
|
||||||
raise
|
raise self.server.error(
|
||||||
logging.exception(f"Github request error, retrying: {e}")
|
f"Github Request failed: {resp.code} {resp.reason}")
|
||||||
|
logging.info(
|
||||||
|
f"Github request error, {retries} retries remaining")
|
||||||
|
await tornado.gen.sleep(1.)
|
||||||
continue
|
continue
|
||||||
if is_download:
|
# Update rate limit on return success
|
||||||
return resp.body
|
if 'X-Ratelimit-Limit' in resp.headers and not is_init:
|
||||||
|
self.gh_rate_limit = int(resp.headers['X-Ratelimit-Limit'])
|
||||||
|
self.gh_limit_remaining = int(
|
||||||
|
resp.headers['X-Ratelimit-Remaining'])
|
||||||
|
self.gh_limit_reset_time = float(
|
||||||
|
resp.headers['X-Ratelimit-Reset'])
|
||||||
decoded = json.loads(resp.body)
|
decoded = json.loads(resp.body)
|
||||||
|
decoded['etag'] = etag
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
async def http_download_request(self, url):
|
||||||
|
retries = 5
|
||||||
|
while retries:
|
||||||
|
try:
|
||||||
|
resp = await self.http_client.fetch(
|
||||||
|
url, headers={"Accept": "application/zip"},
|
||||||
|
connect_timeout=5., request_timeout=120.)
|
||||||
|
except Exception:
|
||||||
|
retries -= 1
|
||||||
|
logging.exception("Error Processing Download")
|
||||||
|
if not retries:
|
||||||
|
raise
|
||||||
|
await tornado.gen.sleep(1.)
|
||||||
|
continue
|
||||||
|
return resp.body
|
||||||
|
|
||||||
def notify_update_response(self, resp, is_complete=False):
|
def notify_update_response(self, resp, is_complete=False):
|
||||||
resp = resp.strip()
|
resp = resp.strip()
|
||||||
if isinstance(resp, bytes):
|
if isinstance(resp, bytes):
|
||||||
|
@ -504,13 +600,14 @@ class PackageUpdater:
|
||||||
|
|
||||||
class ClientUpdater:
|
class ClientUpdater:
|
||||||
def __init__(self, umgr, repo, path):
|
def __init__(self, umgr, repo, path):
|
||||||
|
self.umgr = umgr
|
||||||
self.server = umgr.server
|
self.server = umgr.server
|
||||||
self.github_request = umgr.github_request
|
|
||||||
self.notify_update_response = umgr.notify_update_response
|
self.notify_update_response = umgr.notify_update_response
|
||||||
self.repo = repo.strip().strip("/")
|
self.repo = repo.strip().strip("/")
|
||||||
self.name = self.repo.split("/")[-1]
|
self.name = self.repo.split("/")[-1]
|
||||||
self.path = path
|
self.path = path
|
||||||
self.version = self.remote_version = self.dl_url = "?"
|
self.version = self.remote_version = self.dl_url = "?"
|
||||||
|
self.etag = None
|
||||||
self.init_evt = Event()
|
self.init_evt = Event()
|
||||||
self._get_local_version()
|
self._get_local_version()
|
||||||
logging.info(f"\nInitializing Client Updater: '{self.name}',"
|
logging.info(f"\nInitializing Client Updater: '{self.name}',"
|
||||||
|
@ -539,16 +636,21 @@ class ClientUpdater:
|
||||||
# Remote state
|
# Remote state
|
||||||
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
|
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
|
||||||
try:
|
try:
|
||||||
result = await self.github_request(url)
|
result = await self.umgr.github_api_request(url, etag=self.etag)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(f"Client {self.repo}: Github Request Error")
|
logging.exception(f"Client {self.repo}: Github Request Error")
|
||||||
result = {}
|
result = {}
|
||||||
|
if result is None:
|
||||||
|
# No change, update not necessary
|
||||||
|
return None
|
||||||
|
self.etag = result.get('etag', None)
|
||||||
self.remote_version = result.get('name', "?")
|
self.remote_version = result.get('name', "?")
|
||||||
release_assets = result.get('assets', [{}])[0]
|
release_assets = result.get('assets', [{}])[0]
|
||||||
self.dl_url = release_assets.get('browser_download_url', "?")
|
self.dl_url = release_assets.get('browser_download_url', "?")
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Github client Info Received: {self.name}, "
|
f"Github client Info Received:\nRepo: {self.name}\n"
|
||||||
f"version: {self.remote_version} "
|
f"Local Version: {self.version}\n"
|
||||||
|
f"Remote Version: {self.remote_version}\n"
|
||||||
f"url: {self.dl_url}")
|
f"url: {self.dl_url}")
|
||||||
self.init_evt.set()
|
self.init_evt.set()
|
||||||
|
|
||||||
|
@ -568,7 +670,7 @@ class ClientUpdater:
|
||||||
shutil.rmtree(self.path)
|
shutil.rmtree(self.path)
|
||||||
os.mkdir(self.path)
|
os.mkdir(self.path)
|
||||||
self.notify_update_response(f"Downloading Client: {self.name}")
|
self.notify_update_response(f"Downloading Client: {self.name}")
|
||||||
archive = await self.github_request(self.dl_url, is_download=True)
|
archive = await self.umgr.http_download_request(self.dl_url)
|
||||||
with zipfile.ZipFile(io.BytesIO(archive)) as zf:
|
with zipfile.ZipFile(io.BytesIO(archive)) as zf:
|
||||||
zf.extractall(self.path)
|
zf.extractall(self.path)
|
||||||
self.version = self.remote_version
|
self.version = self.remote_version
|
||||||
|
@ -576,7 +678,7 @@ class ClientUpdater:
|
||||||
if not os.path.exists(version_path):
|
if not os.path.exists(version_path):
|
||||||
with open(version_path, "w") as f:
|
with open(version_path, "w") as f:
|
||||||
f.write(self.version)
|
f.write(self.version)
|
||||||
self.notify_update_response(f"Client Updated Finished: {self.name}",
|
self.notify_update_response(f"Client Update Finished: {self.name}",
|
||||||
is_complete=True)
|
is_complete=True)
|
||||||
|
|
||||||
def get_update_status(self):
|
def get_update_status(self):
|
||||||
|
|
Loading…
Reference in New Issue