Skip to content

Commit ba20191

Browse files
authored
More logic changes to error reporting, cleanup (#3310)
* More logic changes to error reporting, cleanup * Alter project.task logic and include multiple points of error reporting during build task * Build task returns true or false, not project data, which is unused anyways * New pattern for error messages * Localize error messages? * Make more specific error messages for repository error and project error * Remove unnecessary git/hg/bzr/svn exit codes and superfluous command data from user errors * Add handling of git prompt for git 2.3+ * Add errors for docker connection * Handle reporting on not able to connect to docker * Add finalize argument to environments * Fix up unittests Our old tests weren't correctly testing the api calls at all. This expands our environment tests to actually test for build state updates. * Lint cleanup on test file * Feedback, more testing, logic changes * Removes some redundant calls to update build objects * Updates some docs * Logic behind update_build is improved to update in more cases * Tests for incremental updates * Fix hostname in tests * More docs * Update some logging strings, alter docker error, change name of attribute Fixes the last of review feedback * Fix test arguments
1 parent 01717c0 commit ba20191

File tree

12 files changed

+886
-324
lines changed

12 files changed

+886
-324
lines changed

readthedocs/doc_builder/backends/sphinx.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from django.template.loader import render_to_string
2121

2222
from readthedocs.builds import utils as version_utils
23-
from readthedocs.projects.exceptions import ProjectImportError
23+
from readthedocs.projects.exceptions import ProjectConfigurationError
2424
from readthedocs.projects.utils import safe_write
2525
from readthedocs.restapi.client import api
2626

@@ -42,7 +42,7 @@ def __init__(self, *args, **kwargs):
4242
try:
4343
self.old_artifact_path = os.path.join(
4444
self.project.conf_dir(self.version.slug), self.sphinx_build_dir)
45-
except ProjectImportError:
45+
except ProjectConfigurationError:
4646
docs_dir = self.docs_dir()
4747
self.old_artifact_path = os.path.join(
4848
docs_dir,
@@ -145,16 +145,22 @@ def append_conf(self, **__):
145145
"""Modify given ``conf.py`` file from a whitelisted user's project."""
146146
try:
147147
self.version.get_conf_py_path()
148-
except ProjectImportError:
148+
except ProjectConfigurationError:
149149
master_doc = self.create_index(extension='rst')
150150
self._write_config(master_doc=master_doc)
151151

152152
try:
153153
outfile_path = self.project.conf_file(self.version.slug)
154154
outfile = codecs.open(outfile_path, encoding='utf-8', mode='a')
155-
except (ProjectImportError, IOError):
155+
except (ProjectConfigurationError, IOError):
156156
trace = sys.exc_info()[2]
157-
six.reraise(ProjectImportError('Conf file not found'), None, trace)
157+
six.reraise(
158+
ProjectConfigurationError(
159+
ProjectConfigurationError.NOT_FOUND
160+
),
161+
None,
162+
trace
163+
)
158164

159165
# Append config to project conf file
160166
tmpl = template_loader.get_template('doc_builder/conf.py.tmpl')

readthedocs/doc_builder/environments.py

+151-64
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@
2323
from readthedocs.builds.models import BuildCommandResultMixin
2424
from readthedocs.projects.constants import LOG_TEMPLATE
2525
from readthedocs.restapi.client import api as api_v2
26+
from requests.exceptions import ConnectionError
2627

2728
from .exceptions import (BuildEnvironmentException, BuildEnvironmentError,
28-
BuildEnvironmentWarning)
29+
BuildEnvironmentWarning, BuildEnvironmentCreationFailed)
2930
from .constants import (DOCKER_SOCKET, DOCKER_VERSION, DOCKER_IMAGE,
3031
DOCKER_LIMITS, DOCKER_TIMEOUT_EXIT_CODE,
3132
DOCKER_OOM_EXIT_CODE, SPHINX_TEMPLATE_DIR,
@@ -267,23 +268,40 @@ class BuildEnvironment(object):
267268
268269
Base class for wrapping command execution for build steps. This provides a
269270
context for command execution and reporting, and eventually performs updates
270-
on the build object itself, reporting success/failure, as well as top-level
271-
failures.
271+
on the build object itself, reporting success/failure, as well as failures
272+
during the context manager enter and exit.
273+
274+
Any exceptions raised inside this context and handled by the eventual
275+
:py:meth:`__exit__` method, specifically, inside :py:meth:`handle_exception`
276+
and :py:meth:`update_build`. If the exception is a subclass of
277+
:py:cls:`BuildEnvironmentError`, then this error message is added to the
278+
build object and is shown to the user as the top-level failure reason for
279+
why the build failed. Other exceptions raise a general failure warning on
280+
the build.
281+
282+
We only update the build through the API in one of three cases:
283+
284+
* The build is not done and needs an additional build step to follow
285+
* The build failed and we should always report this change
286+
* The build was successful and ``update_on_success`` is ``True``
272287
273288
:param project: Project that is being built
274289
:param version: Project version that is being built
275290
:param build: Build instance
276291
:param record: Record status of build object
277292
:param environment: shell environment variables
293+
:param update_on_success: update the build object via API if the build was
294+
successful
278295
"""
279296

280297
def __init__(self, project=None, version=None, build=None, record=True,
281-
environment=None):
298+
environment=None, update_on_success=True):
282299
self.project = project
283300
self.version = version
284301
self.build = build
285302
self.record = record
286303
self.environment = environment or {}
304+
self.update_on_success = update_on_success
287305

288306
self.commands = []
289307
self.failure = None
@@ -294,7 +312,7 @@ def __enter__(self):
294312

295313
def __exit__(self, exc_type, exc_value, tb):
296314
ret = self.handle_exception(exc_type, exc_value, tb)
297-
self.build['state'] = BUILD_STATE_FINISHED
315+
self.update_build(BUILD_STATE_FINISHED)
298316
log.info(LOG_TEMPLATE
299317
.format(project=self.project.slug,
300318
version=self.version.slug,
@@ -386,9 +404,13 @@ def done(self):
386404
def update_build(self, state=None):
387405
"""Record a build by hitting the API
388406
389-
This step is skipped if we aren't recording the build, or if we don't
390-
want to record successful builds yet (if we are running setup commands
391-
for the build)
407+
This step is skipped if we aren't recording the build. To avoid
408+
recording successful builds yet (for instance, running setup commands
409+
for the build), set the ``update_on_success`` argument to False on
410+
environment instantiation.
411+
412+
If there was an error on the build, update the build regardless of
413+
whether ``update_on_success`` is ``True`` or not.
392414
"""
393415
if not self.record:
394416
return None
@@ -417,37 +439,51 @@ def update_build(self, state=None):
417439
self.build['length'] = int(build_length.total_seconds())
418440

419441
if self.failure is not None:
420-
# Only surface the error message if it was a
421-
# BuildEnvironmentException or BuildEnvironmentWarning
422-
if isinstance(self.failure,
423-
(BuildEnvironmentException, BuildEnvironmentWarning)):
424-
self.build['error'] = str(self.failure)
425-
else:
426-
self.build['error'] = ugettext_noop(
427-
"There was a problem with Read the Docs while building your documentation. "
428-
"Please report this to us with your build id ({build_id}).".format(
429-
build_id=self.build['id']
430-
)
431-
)
442+
# Surface a generic error if the class is not a
443+
# BuildEnvironmentError
444+
if not isinstance(self.failure,
445+
(BuildEnvironmentException,
446+
BuildEnvironmentWarning)):
432447
log.error(
433448
'Build failed with unhandled exception: %s',
434449
str(self.failure),
435-
extra={'stack': True,
436-
'tags': {'build': self.build['id']},
437-
}
450+
extra={
451+
'stack': True,
452+
'tags': {'build': self.build['id']},
453+
}
454+
)
455+
self.failure = BuildEnvironmentError(
456+
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
457+
build_id=self.build['id'],
458+
)
438459
)
460+
self.build['error'] = str(self.failure)
439461

440462
# Attempt to stop unicode errors on build reporting
441463
for key, val in list(self.build.items()):
442464
if isinstance(val, six.binary_type):
443465
self.build[key] = val.decode('utf-8', 'ignore')
444466

445-
try:
446-
api_v2.build(self.build['id']).put(self.build)
447-
except HttpClientError as e:
448-
log.error("Unable to post a new build: %s", e.content)
449-
except Exception:
450-
log.exception("Unknown build exception")
467+
# We are selective about when we update the build object here
468+
update_build = (
469+
# Build isn't done yet, we unconditionally update in this state
470+
not self.done
471+
# Build is done, but isn't successful, always update
472+
or (self.done and not self.successful)
473+
# Otherwise, are we explicitly to not update?
474+
or self.update_on_success
475+
)
476+
if update_build:
477+
try:
478+
api_v2.build(self.build['id']).put(self.build)
479+
except HttpClientError as e:
480+
log.error(
481+
"Unable to update build: id=%d error=%s",
482+
self.build['id'],
483+
e.content,
484+
)
485+
except Exception:
486+
log.exception("Unknown build exception")
451487

452488

453489
class LocalEnvironment(BuildEnvironment):
@@ -521,8 +557,15 @@ def __enter__(self):
521557
.format(self.container_id))))
522558
client = self.get_client()
523559
client.remove_container(self.container_id)
524-
except DockerAPIError:
560+
except (DockerAPIError, ConnectionError):
561+
# If there is an exception here, we swallow the exception as this
562+
# was just during a sanity check anyways.
525563
pass
564+
except BuildEnvironmentError:
565+
# There may have been a problem connecting to Docker altogether, or
566+
# some other handled exception here.
567+
self.__exit__(*sys.exc_info())
568+
raise
526569

527570
# Create the checkout path if it doesn't exist to avoid Docker creation
528571
if not os.path.exists(self.project.doc_path):
@@ -537,28 +580,43 @@ def __enter__(self):
537580

538581
def __exit__(self, exc_type, exc_value, tb):
539582
"""End of environment context"""
540-
ret = self.handle_exception(exc_type, exc_value, tb)
583+
try:
584+
# Update buildenv state given any container error states first
585+
self.update_build_from_container_state()
541586

542-
# Update buildenv state given any container error states first
543-
self.update_build_from_container_state()
587+
client = self.get_client()
588+
try:
589+
client.kill(self.container_id)
590+
except DockerAPIError:
591+
log.exception(
592+
'Unable to kill container: id=%s',
593+
self.container_id,
594+
)
595+
try:
596+
log.info('Removing container: id=%s', self.container_id)
597+
client.remove_container(self.container_id)
598+
# Catch direct failures from Docker API or with a requests HTTP
599+
# request. These errors should not surface to the user.
600+
except (DockerAPIError, ConnectionError):
601+
log.exception(
602+
LOG_TEMPLATE
603+
.format(
604+
project=self.project.slug,
605+
version=self.version.slug,
606+
msg="Couldn't remove container",
607+
),
608+
)
609+
self.container = None
610+
except BuildEnvironmentError:
611+
# Several interactions with Docker can result in a top level failure
612+
# here. We'll catch this and report if there were no reported errors
613+
# already. These errors are not as important as a failure at deeper
614+
# code
615+
if not all([exc_type, exc_value, tb]):
616+
exc_type, exc_value, tb = sys.exc_info()
544617

545-
client = self.get_client()
546-
try:
547-
client.kill(self.container_id)
548-
except DockerAPIError:
549-
pass
550-
try:
551-
log.info('Removing container %s', self.container_id)
552-
client.remove_container(self.container_id)
553-
except DockerAPIError:
554-
log.error(LOG_TEMPLATE
555-
.format(
556-
project=self.project.slug,
557-
version=self.version.slug,
558-
msg="Couldn't remove container"),
559-
exc_info=True)
560-
self.container = None
561-
self.build['state'] = BUILD_STATE_FINISHED
618+
ret = self.handle_exception(exc_type, exc_value, tb)
619+
self.update_build(BUILD_STATE_FINISHED)
562620
log.info(LOG_TEMPLATE
563621
.format(project=self.project.slug,
564622
version=self.version.slug,
@@ -576,13 +634,21 @@ def get_client(self):
576634
)
577635
return self.client
578636
except DockerException as e:
579-
log.error(LOG_TEMPLATE
580-
.format(
581-
project=self.project.slug,
582-
version=self.version.slug,
583-
msg=e),
584-
exc_info=True)
585-
raise BuildEnvironmentError('Problem creating build environment')
637+
log.exception(
638+
LOG_TEMPLATE.format(
639+
project=self.project.slug,
640+
version=self.version.slug,
641+
msg='Could not connect to Docker API',
642+
),
643+
)
644+
# We don't raise an error here mentioning Docker, that is a
645+
# technical detail that the user can't resolve on their own.
646+
# Instead, give the user a generic failure
647+
raise BuildEnvironmentError(
648+
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
649+
build_id=self.build['id'],
650+
)
651+
)
586652

587653
@property
588654
def container_id(self):
@@ -655,11 +721,32 @@ def create_container(self):
655721
mem_limit=self.container_mem_limit,
656722
)
657723
client.start(container=self.container_id)
724+
except ConnectionError as e:
725+
log.exception(
726+
LOG_TEMPLATE.format(
727+
project=self.project.slug,
728+
version=self.version.slug,
729+
msg=(
730+
'Could not connect to the Docker API, '
731+
'make sure Docker is running'
732+
),
733+
),
734+
)
735+
# We don't raise an error here mentioning Docker, that is a
736+
# technical detail that the user can't resolve on their own.
737+
# Instead, give the user a generic failure
738+
raise BuildEnvironmentError(
739+
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
740+
build_id=self.build['id'],
741+
)
742+
)
658743
except DockerAPIError as e:
659-
log.error(LOG_TEMPLATE
660-
.format(
661-
project=self.project.slug,
662-
version=self.version.slug,
663-
msg=e.explanation),
664-
exc_info=True)
665-
raise BuildEnvironmentError('Build environment creation failed')
744+
log.exception(
745+
LOG_TEMPLATE
746+
.format(
747+
project=self.project.slug,
748+
version=self.version.slug,
749+
msg=e.explanation,
750+
),
751+
)
752+
raise BuildEnvironmentCreationFailed

readthedocs/doc_builder/exceptions.py

+22-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
"""Exceptions raised when building documentation."""
22

3+
from django.utils.translation import ugettext_noop
4+
35

46
class BuildEnvironmentException(Exception):
57

6-
def __init__(self, *args, **kwargs):
8+
message = None
9+
10+
def __init__(self, message=None, **kwargs):
711
self.status_code = kwargs.pop('status_code', 1)
8-
super(BuildEnvironmentException, self).__init__(*args, **kwargs)
12+
message = message or self.get_default_message()
13+
super(BuildEnvironmentException, self).__init__(message, **kwargs)
14+
15+
def get_default_message(self):
16+
return self.message
917

1018

1119
class BuildEnvironmentError(BuildEnvironmentException):
12-
pass
20+
21+
GENERIC_WITH_BUILD_ID = ugettext_noop(
22+
"There was a problem with Read the Docs while building your documentation. "
23+
"Please report this to us with your build id ({build_id})."
24+
)
25+
26+
27+
class BuildEnvironmentCreationFailed(BuildEnvironmentError):
28+
29+
message = ugettext_noop(
30+
"Build environment creation failed"
31+
)
1332

1433

1534
class BuildEnvironmentWarning(BuildEnvironmentException):

0 commit comments

Comments
 (0)