Skip to content

Commit e59696b

Browse files
authored
Merge pull request #320 from drdoctr/403
Better messaging on 401 and 403 errors from GitHub
2 parents 314fe1c + cc7ece0 commit e59696b

File tree

2 files changed

+74
-7
lines changed

2 files changed

+74
-7
lines changed

doctr/__main__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
from .local import (generate_GitHub_token, encrypt_variable, encrypt_to_file,
3838
upload_GitHub_deploy_key, generate_ssh_key, check_repo_exists,
39-
GitHub_login, guess_github_repo, AuthenticationFailed)
39+
GitHub_login, guess_github_repo, AuthenticationFailed, GitHubError)
4040
from .travis import (setup_GitHub_push, commit_docs, push_docs,
4141
get_current_repo, sync_from_log, find_sphinx_build_dir, run,
4242
get_travis_branch, copy_to_tmp, checkout_deploy_branch)
@@ -248,7 +248,9 @@ def process_args(parser):
248248
try:
249249
return args.func(args, parser)
250250
except RuntimeError as e:
251-
sys.exit("Error: " + e.args[0])
251+
sys.exit(red("Error: " + e.args[0]))
252+
except KeyboardInterrupt:
253+
sys.exit(red("Interrupted by user"))
252254

253255
def on_travis():
254256
return os.environ.get("TRAVIS_JOB_NUMBER", '')
@@ -399,6 +401,8 @@ def configure(args, parser):
399401
is_private = check_repo_exists(build_repo, service='github', **login_kwargs)
400402
check_repo_exists(build_repo, service='travis')
401403
get_build_repo = True
404+
except GitHubError:
405+
raise
402406
except RuntimeError as e:
403407
print(red('\n{!s:-^{}}\n'.format(e, 70)))
404408

@@ -413,6 +417,8 @@ def configure(args, parser):
413417
check_repo_exists(deploy_repo, service='github', **login_kwargs)
414418

415419
get_deploy_repo = True
420+
except GitHubError:
421+
raise
416422
except RuntimeError as e:
417423
print(red('\n{!s:-^{}}\n'.format(e, 70)))
418424

doctr/local.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import re
1010
from getpass import getpass
1111
import urllib
12+
import datetime
1213

1314
import requests
1415
from requests.auth import HTTPBasicAuth
@@ -130,22 +131,78 @@ def GitHub_login(*, username=None, password=None, OTP=None, headers=None):
130131
if two_factor:
131132
if OTP:
132133
print(red("Invalid authentication code"))
134+
# For SMS, we have to make a fake request (that will fail without
135+
# the OTP) to get GitHub to send it. See https://github.com/drdoctr/doctr/pull/203
133136
auth_header = base64.urlsafe_b64encode(bytes(username + ':' + password, 'utf8')).decode()
134137
login_kwargs = {'auth': None, 'headers': {'Authorization': 'Basic {}'.format(auth_header)}}
135138
try:
136139
generate_GitHub_token(**login_kwargs)
137-
except requests.exceptions.HTTPError:
140+
except (requests.exceptions.HTTPError, GitHubError):
138141
pass
139142
print("A two-factor authentication code is required:", two_factor.split(';')[1].strip())
140143
OTP = input("Authentication code: ")
141144
return GitHub_login(username=username, password=password, OTP=OTP, headers=headers)
142145

143146
raise AuthenticationFailed("invalid username or password")
144147

145-
r.raise_for_status()
148+
GitHub_raise_for_status(r)
146149
return {'auth': auth, 'headers': headers}
147150

148151

152+
class GitHubError(RuntimeError):
153+
pass
154+
155+
def GitHub_raise_for_status(r):
156+
"""
157+
Call instead of r.raise_for_status() for GitHub requests
158+
159+
Checks for common GitHub response issues and prints messages for them.
160+
"""
161+
# This will happen if the doctr session has been running too long and the
162+
# OTP code gathered from GitHub_login has expired.
163+
164+
# TODO: Refactor the code to re-request the OTP without exiting.
165+
if r.status_code == 401 and r.headers.get('X-GitHub-OTP'):
166+
raise GitHubError("The two-factor authentication code has expired. Please run doctr configure again.")
167+
if r.status_code == 403 and r.headers.get('X-RateLimit-Remaining') == '0':
168+
reset = int(r.headers['X-RateLimit-Reset'])
169+
limit = int(r.headers['X-RateLimit-Limit'])
170+
reset_datetime = datetime.datetime.fromtimestamp(reset, datetime.timezone.utc)
171+
relative_reset_datetime = reset_datetime - datetime.datetime.now(datetime.timezone.utc)
172+
# Based on datetime.timedelta.__str__
173+
mm, ss = divmod(relative_reset_datetime.seconds, 60)
174+
hh, mm = divmod(mm, 60)
175+
def plural(n):
176+
return n, abs(n) != 1 and "s" or ""
177+
178+
s = "%d minute%s" % plural(mm)
179+
if hh:
180+
s = "%d hour%s, " % plural(hh) + s
181+
if relative_reset_datetime.days:
182+
s = ("%d day%s, " % plural(relative_reset_datetime.days)) + s
183+
authenticated = limit >= 100
184+
message = """\
185+
Your GitHub API rate limit has been hit. GitHub allows {limit} {un}authenticated
186+
requests per hour. See {documentation_url}
187+
for more information.
188+
""".format(limit=limit, un="" if authenticated else "un", documentation_url=r.json()["documentation_url"])
189+
if authenticated:
190+
message += """
191+
Note that GitHub's API limits are shared across all oauth applications. A
192+
common cause of hitting the rate limit is the Travis "sync account" button.
193+
"""
194+
else:
195+
message += """
196+
You can get a higher API limit by authenticating. Try running doctr configure
197+
again without the --no-upload-key flag.
198+
"""
199+
message += """
200+
Your rate limits will reset in {s}.\
201+
""".format(s=s)
202+
raise GitHubError(message)
203+
r.raise_for_status()
204+
205+
149206
def GitHub_post(data, url, *, auth, headers):
150207
"""
151208
POST the data ``data`` to GitHub.
@@ -154,7 +211,7 @@ def GitHub_post(data, url, *, auth, headers):
154211
155212
"""
156213
r = requests.post(url, auth=auth, headers=headers, data=json.dumps(data))
157-
r.raise_for_status()
214+
GitHub_raise_for_status(r)
158215
return r.json()
159216

160217

@@ -185,7 +242,7 @@ def generate_GitHub_token(*, note="Doctr token for pushing to gh-pages from Trav
185242
def delete_GitHub_token(token_id, *, auth, headers):
186243
"""Delete a temporary GitHub token"""
187244
r = requests.delete('https://api.github.com/authorizations/{id}'.format(id=token_id), auth=auth, headers=headers)
188-
r.raise_for_status()
245+
GitHub_raise_for_status(r)
189246

190247

191248
def upload_GitHub_deploy_key(deploy_repo, ssh_key, *, read_only=False,
@@ -265,7 +322,11 @@ def check_repo_exists(deploy_repo, service='github', *, auth=None, headers=None)
265322
repo=repo,
266323
service=service))
267324

268-
r.raise_for_status()
325+
if service == 'github':
326+
GitHub_raise_for_status(r)
327+
else:
328+
r.raise_for_status()
329+
269330
private = r.json().get('private', False)
270331

271332
if wiki and not private:

0 commit comments

Comments
 (0)