Skip to content

More logic changes to error reporting, cleanup #3310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Dec 7, 2017
16 changes: 11 additions & 5 deletions readthedocs/doc_builder/backends/sphinx.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from readthedocs.builds import utils as version_utils
from readthedocs.projects.utils import safe_write
from readthedocs.projects.exceptions import ProjectImportError
from readthedocs.projects.exceptions import ProjectConfigurationError
from readthedocs.restapi.client import api

from ..base import BaseBuilder, restoring_chdir
Expand All @@ -40,7 +40,7 @@ def __init__(self, *args, **kwargs):
self.old_artifact_path = os.path.join(
self.project.conf_dir(self.version.slug),
self.sphinx_build_dir)
except ProjectImportError:
except ProjectConfigurationError:
docs_dir = self.docs_dir()
self.old_artifact_path = os.path.join(docs_dir, self.sphinx_build_dir)

Expand Down Expand Up @@ -121,16 +121,22 @@ def append_conf(self, **__):
"""Modify given ``conf.py`` file from a whitelisted user's project."""
try:
self.version.get_conf_py_path()
except ProjectImportError:
except ProjectConfigurationError:
master_doc = self.create_index(extension='rst')
self._write_config(master_doc=master_doc)

try:
outfile_path = self.project.conf_file(self.version.slug)
outfile = codecs.open(outfile_path, encoding='utf-8', mode='a')
except (ProjectImportError, IOError):
except (ProjectConfigurationError, IOError):
trace = sys.exc_info()[2]
six.reraise(ProjectImportError('Conf file not found'), None, trace)
six.reraise(
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need this six.reraise? It's not the same than just raise ProjectImportError(...)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure either, the docs aren't clear to me:
https://pythonhosted.org/six/#six.reraise

I'll leave it, assuming someone with more knowledge of py2/3 compat did this :)

ProjectConfigurationError(
ProjectConfigurationError.NOT_FOUND
),
None,
trace
)

# Append config to project conf file
tmpl = template_loader.get_template('doc_builder/conf.py.tmpl')
Expand Down
195 changes: 133 additions & 62 deletions readthedocs/doc_builder/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from readthedocs.builds.models import BuildCommandResultMixin
from readthedocs.projects.constants import LOG_TEMPLATE
from readthedocs.restapi.client import api as api_v2
from requests.exceptions import ConnectionError

from .exceptions import (BuildEnvironmentException, BuildEnvironmentError,
BuildEnvironmentWarning)
Expand Down Expand Up @@ -267,23 +268,39 @@ class BuildEnvironment(object):

Base class for wrapping command execution for build steps. This provides a
context for command execution and reporting, and eventually performs updates
on the build object itself, reporting success/failure, as well as top-level
failures.
on the build object itself, reporting success/failure, as well as failures
during the context manager enter and exit.

Any exceptions raised inside this context and handled by the eventual
:py:meth:`__exit__` method, specifically, inside :py:meth:`handle_exception`
and :py:meth:`update_build`. If the exception is a subclass of
:py:cls:`BuildEnvironmentError`, then this error message is added to the
build object and is shown to the user as the top-level failure reason for
why the build failed. Other exceptions raise a general failure warning on
the build.

We only update the build through the API in one of three cases:

* The build is not done and needs incremental builds
Copy link
Member

Choose a reason for hiding this comment

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

What is an incremental build?

* The build failed and we should always report this change
* The build was successful and ``commit`` is ``True``

:param project: Project that is being built
:param version: Project version that is being built
:param build: Build instance
:param record: Record status of build object
:param environment: shell environment variables
:param commit: update the build object via API if the build was successful
Copy link
Member

Choose a reason for hiding this comment

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

I think this is still confusing.

When commit=True (the default) and we are in the middle of the build, the build won't be updated. So, I think this should have a better name (if that is possible) like last_command, since I think it's more representative of what it measn.

Anyway, I don't see the best solution very clear.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

probably the most explicit we can is update_on_success

"""

def __init__(self, project=None, version=None, build=None, record=True,
environment=None):
environment=None, commit=True):
self.project = project
self.version = version
self.build = build
self.record = record
self.environment = environment or {}
self.commit = commit

self.commands = []
self.failure = None
Expand All @@ -294,7 +311,7 @@ def __enter__(self):

def __exit__(self, exc_type, exc_value, tb):
ret = self.handle_exception(exc_type, exc_value, tb)
self.build['state'] = BUILD_STATE_FINISHED
self.update_build(BUILD_STATE_FINISHED)
Copy link
Member

Choose a reason for hiding this comment

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

Won't this post that status to the API? Seems like we don't want to publish here, but only below if finalize is False.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The finalize check is in update_build, so it should only post if finalize is True

Copy link
Member

Choose a reason for hiding this comment

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

Gah ok. Lost that in the review.

log.info(LOG_TEMPLATE
.format(project=self.project.slug,
version=self.version.slug,
Expand Down Expand Up @@ -386,9 +403,12 @@ def done(self):
def update_build(self, state=None):
"""Record a build by hitting the API

This step is skipped if we aren't recording the build, or if we don't
want to record successful builds yet (if we are running setup commands
for the build)
This step is skipped if we aren't recording the build. To avoid
recording successful builds yet (for instance, running setup commands for
the build), set the ``commit`` argument on environment instantiation.
Copy link
Member

Choose a reason for hiding this comment

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

set the commit argument to False


If there was an error on the build, update the build regardless of
whether ``commit`` is ``True`` or not.
"""
if not self.record:
return None
Expand Down Expand Up @@ -417,37 +437,47 @@ def update_build(self, state=None):
self.build['length'] = int(build_length.total_seconds())

if self.failure is not None:
# Only surface the error message if it was a
# BuildEnvironmentException or BuildEnvironmentWarning
if isinstance(self.failure,
(BuildEnvironmentException, BuildEnvironmentWarning)):
self.build['error'] = str(self.failure)
else:
self.build['error'] = ugettext_noop(
"There was a problem with Read the Docs while building your documentation. "
"Please report this to us with your build id ({build_id}).".format(
build_id=self.build['id']
)
)
# Surface a generic error if the class is not a
# BuildEnvironmentError
if not isinstance(self.failure,
(BuildEnvironmentException,
BuildEnvironmentWarning)):
log.error(
'Build failed with unhandled exception: %s',
str(self.failure),
extra={'stack': True,
'tags': {'build': self.build['id']},
}
extra={
'stack': True,
'tags': {'build': self.build['id']},
}
)
self.failure = BuildEnvironmentError(
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
build_id=self.build['id'],
)
)
self.build['error'] = str(self.failure)

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

try:
api_v2.build(self.build['id']).put(self.build)
except HttpClientError as e:
log.error("Unable to post a new build: %s", e.content)
except Exception:
log.exception("Unknown build exception")
# We are selective about when we update the build object here
update_build = (
Copy link
Member

Choose a reason for hiding this comment

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

I think the use of any is clearer here, instead of ors

# Build isn't done yet, we unconditionally update in this state
not self.done
# Build is done, but isn't successful, always update
or (self.done and not self.successful)
# Otherwise, are we explicitly to not update?
or self.commit
)
if update_build:
try:
api_v2.build(self.build['id']).put(self.build)
except HttpClientError as e:
log.error("Unable to post a new build: %s", e.content)
Copy link
Member

Choose a reason for hiding this comment

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

Unable to update the build: and maybe add the build id also to the log

except Exception:
log.exception("Unknown build exception")


class LocalEnvironment(BuildEnvironment):
Expand Down Expand Up @@ -521,8 +551,15 @@ def __enter__(self):
.format(self.container_id))))
client = self.get_client()
client.remove_container(self.container_id)
except DockerAPIError:
except (DockerAPIError, ConnectionError):
# If there is an exception here, we swallow the exception as this
# was just during a sanity check anyways.
pass
except BuildEnvironmentError:
# There may have been a problem connecting to Docker altogether, or
# some other handled exception here.
self.__exit__(*sys.exc_info())
Copy link
Member

Choose a reason for hiding this comment

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

Will this not automatically call __exit__?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It won't, because this is raised in __enter__. Errors raised in __enter__ raise to the parent context, so we have to explicitly clean up here.

raise

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

def __exit__(self, exc_type, exc_value, tb):
"""End of environment context"""
ret = self.handle_exception(exc_type, exc_value, tb)
try:
# Update buildenv state given any container error states first
self.update_build_from_container_state()

# Update buildenv state given any container error states first
self.update_build_from_container_state()
client = self.get_client()
try:
client.kill(self.container_id)
except DockerAPIError:
log.exception(
'Unable to remove container: id=%s',
Copy link
Member

Choose a reason for hiding this comment

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

Unable to kill the container

self.container_id,
)
try:
log.info('Removing container: id=%s', self.container_id)
client.remove_container(self.container_id)
# Catch direct failures from Docker API or with a requests HTTP
# request. These errors should not surface to the user.
except (DockerAPIError, ConnectionError):
log.exception(
LOG_TEMPLATE
.format(
project=self.project.slug,
version=self.version.slug,
msg="Couldn't remove container",
),
)
self.container = None
except BuildEnvironmentError:
Copy link
Member

Choose a reason for hiding this comment

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

I'm not following this. I think it's not possible to BuildEnvironmentError be raised in the try: block here. What could be the case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

get_client raises a BuildEnvironmentError on failure here. I'll rethink refactoring this as well though, perhaps a more specific exception, or allowing the docker exception to bubble up makes more sense.

# Several interactions with Docker can result in a top level failure
# here. We'll catch this and report if there were no reported errors
# already. These errors are not as important as a failure at deeper
# code
if not all([exc_type, exc_value, tb]):
exc_type, exc_value, tb = sys.exc_info()

client = self.get_client()
try:
client.kill(self.container_id)
except DockerAPIError:
pass
try:
log.info('Removing container %s', self.container_id)
client.remove_container(self.container_id)
except DockerAPIError:
log.error(LOG_TEMPLATE
.format(
project=self.project.slug,
version=self.version.slug,
msg="Couldn't remove container"),
exc_info=True)
self.container = None
self.build['state'] = BUILD_STATE_FINISHED
ret = self.handle_exception(exc_type, exc_value, tb)
self.update_build(BUILD_STATE_FINISHED)
log.info(LOG_TEMPLATE
.format(project=self.project.slug,
version=self.version.slug,
Expand All @@ -576,13 +628,21 @@ def get_client(self):
)
return self.client
except DockerException as e:
log.error(LOG_TEMPLATE
.format(
project=self.project.slug,
version=self.version.slug,
msg=e),
exc_info=True)
raise BuildEnvironmentError('Problem creating build environment')
log.exception(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg='Could not connection to Docker API',
Copy link
Member

Choose a reason for hiding this comment

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

Could not connect

),
)
# We don't raise an error here mentioning Docker, that is a
# technical detail that the user can't resolve on their own.
# Instead, give the user a generic failure
raise BuildEnvironmentError(
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
build_id=self.build['id'],
)
)

@property
def container_id(self):
Expand Down Expand Up @@ -655,11 +715,22 @@ def create_container(self):
mem_limit=self.container_mem_limit,
)
client.start(container=self.container_id)
except ConnectionError as e:
log.exception(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg=e,
),
)
raise BuildEnvironmentError('There was a problem connecting to Docker')
Copy link
Member

Choose a reason for hiding this comment

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

Here, the problem is similar than https://github.com/rtfd/readthedocs.org/pull/3310/files#diff-ca52b098301dd315a834b3556ab9a7d5R638 but in this case we are communicating the Docker error to the user.

except DockerAPIError as e:
log.error(LOG_TEMPLATE
.format(
project=self.project.slug,
version=self.version.slug,
msg=e.explanation),
exc_info=True)
log.exception(
LOG_TEMPLATE
.format(
project=self.project.slug,
version=self.version.slug,
msg=e.explanation,
),
)
raise BuildEnvironmentError('Build environment creation failed')
18 changes: 15 additions & 3 deletions readthedocs/doc_builder/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
"""Exceptions raised when building documentation."""

from django.utils.translation import ugettext_noop


class BuildEnvironmentException(Exception):

def __init__(self, *args, **kwargs):
message = None

def __init__(self, message=None, **kwargs):
self.status_code = kwargs.pop('status_code', 1)
super(BuildEnvironmentException, self).__init__(*args, **kwargs)
message = message or self.get_default_message()
super(BuildEnvironmentException, self).__init__(message, **kwargs)

def get_default_message(self):
return self.message


class BuildEnvironmentError(BuildEnvironmentException):
pass

GENERIC_WITH_BUILD_ID = ugettext_noop(
"There was a problem with Read the Docs while building your documentation. "
"Please report this to us with your build id ({build_id})."
)


class BuildEnvironmentWarning(BuildEnvironmentException):
Expand Down
Loading