Skip to content

Commit 2f6b59f

Browse files
committed
Build: communicate uploading errors to users
Running `store_build_artifacts` from inside `.execute()` method instead of from `.on_sucess()` allow us to raise any exception and handle it properly. We can raise a `BuildUserError` to clearly communicate the specific error to the user, or any other exception to show the generic message: "Try again or contact us". Besides, this allows us to *fail the build* instead of continuing silently without the user noticing this: - there more than 1 file of formats that don't support multiple files - the output path is not a directory - there were errors uploading the artifacts to the storage
1 parent e05470a commit 2f6b59f

File tree

2 files changed

+59
-47
lines changed

2 files changed

+59
-47
lines changed

readthedocs/doc_builder/exceptions.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ class BuildUserError(BuildBaseException):
4141
f'No "{BUILD_COMMANDS_OUTPUT_PATH_HTML}" folder was created during this build.'
4242
)
4343
BUILD_OUTPUT_IS_NOT_A_DIRECTORY = gettext_noop(
44-
'Build output directory for format "{format}" is not a directory.',
44+
'Build output directory for format "{artifact_type}" is not a directory.'
45+
)
46+
BUILD_OUTPUT_HAS_MULTIPLE_FILES = gettext_noop(
47+
'Build output directory for format "{artifact_type}" contains multiple files '
48+
"and it is not currently supported. "
49+
'Please, remove all the files but the "{artifact_type}" your want to upload.'
4550
)
4651

4752

readthedocs/projects/tasks/builds.py

+53-46
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,6 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
494494
if self.data.version:
495495
version_type = self.data.version.type
496496

497-
# NOTE: autoflake gets confused here. We need the NOQA for now.
498497
status = BUILD_STATUS_FAILURE
499498
if isinstance(exc, BuildUserSkip):
500499
# The build was skipped by returning the magic exit code,
@@ -537,56 +536,41 @@ def get_valid_artifact_types(self):
537536
continue
538537

539538
if not os.path.isdir(artifact_directory):
540-
log.exception(
539+
log.error(
541540
"The output path is not a directory.",
542541
output_format=artifact_type,
543542
)
544-
# TODO: we should raise an exception here, fail the build,
545-
# and communicate the error to the user.
546-
# We are just skipping this output format for now.
547-
#
548-
# raise BuildUserError(BuildUserError.BUILD_OUTPUT_IS_NOT_A_DIRECTORY.format(artifact_type))
549-
continue
543+
raise BuildUserError(
544+
BuildUserError.BUILD_OUTPUT_IS_NOT_A_DIRECTORY.format(
545+
artifact_type=artifact_type
546+
)
547+
)
550548

551549
# Check if there are multiple files on artifact directories.
552550
# These output format does not support multiple files yet.
553551
# In case multiple files are found, the upload for this format is not performed.
554-
#
555-
# TODO: we should fail the build for these cases and clearly communicate this.
556-
# to the user. To do this, we need to call this method (``store_build_artifacts``)
557-
# since the ``execute`` method.
558-
# It will allow us to just `raise BuildUserError` and handle it at
559-
# Celery `on_failure` handler.
560552
if artifact_type in ("htmlzip", "epub", "pdf"):
561553
if len(os.listdir(artifact_directory)) > 1:
562-
log.exception(
554+
log.error(
563555
"Multiple files are not supported for this format. "
564556
"Skipping this output format.",
565557
output_format=artifact_type,
566558
)
567-
continue
559+
raise BuildUserError(
560+
BuildUserError.BUILD_OUTPUT_HAS_MULTIPLE_FILES.format(
561+
artifact_type=artifact_type
562+
)
563+
)
568564

569565
# If all the conditions were met, the artifact is valid
570566
valid_artifacts.append(artifact_type)
571567

572568
return valid_artifacts
573569

574570
def on_success(self, retval, task_id, args, kwargs):
575-
576-
valid_artifacts = self.get_valid_artifact_types()
577-
time_before_store_build_artifacts = timezone.now()
578-
# Store build artifacts to storage (local or cloud storage)
579-
#
580-
# TODO: move ``store_build_artifacts`` inside ``execute`` so we can
581-
# handle exceptions properly on ``on_failure``
582-
self.store_build_artifacts(valid_artifacts)
583-
log.info(
584-
"Store build artifacts finished.",
585-
time=(timezone.now() - time_before_store_build_artifacts).seconds,
586-
)
587-
588571
# NOTE: we are updating the db version instance *only* when
589-
if "html" in valid_artifacts:
572+
# TODO: remove this condition and *always* update the DB Version instance
573+
if "html" in self.get_valid_artifact_types():
590574
try:
591575
api_v2.version(self.data.version.pk).patch(
592576
{
@@ -601,7 +585,8 @@ def on_success(self, retval, task_id, args, kwargs):
601585
# NOTE: I think we should fail the build if we cannot update
602586
# the version at this point. Otherwise, we will have inconsistent data
603587
log.exception(
604-
'Updating version failed, skipping file sync.',
588+
"Updating version db object failed. "
589+
'Files are synced in the storage, but "Version" object is not updated',
605590
)
606591

607592
# Index search data
@@ -698,10 +683,13 @@ def update_build(self, state=None):
698683
try:
699684
api_v2.build(self.data.build['id']).patch(self.data.build)
700685
except Exception:
701-
# NOTE: I think we should fail the build if we cannot update it
702-
# at this point otherwise, the data will be inconsistent and we
703-
# may be serving "new docs" but saying the "build have failed"
704-
log.exception('Unable to update build')
686+
# NOTE: we are updating the "Build" object on each `state`.
687+
# Only if the last update fails, there may be some inconsistency
688+
# between the "Build" object in our db and the reality.
689+
#
690+
# The `state` argument will help us to track this more and understand
691+
# at what state our updates are failing and decide what to do.
692+
log.exception("Error while updating the build object.", state=state)
705693

706694
def execute(self):
707695
self.data.build_director = BuildDirector(
@@ -749,6 +737,12 @@ def execute(self):
749737
finally:
750738
self.data.build_data = self.collect_build_data()
751739

740+
# At this point, the user's build already succeeded.
741+
# However, we cannot use `.on_success()` because we still have to upload the artifacts;
742+
# which could fail, and we want to detect that and handle it properly at `.on_failure()`
743+
# Store build artifacts to storage (local or cloud storage)
744+
self.store_build_artifacts()
745+
752746
def collect_build_data(self):
753747
"""
754748
Collect data from the current build.
@@ -817,7 +811,7 @@ def set_valid_clone(self):
817811
self.data.project.has_valid_clone = True
818812
self.data.version.project.has_valid_clone = True
819813

820-
def store_build_artifacts(self, artifacts):
814+
def store_build_artifacts(self):
821815
"""
822816
Save build artifacts to "storage" using Django's storage API.
823817
@@ -826,17 +820,16 @@ def store_build_artifacts(self, artifacts):
826820
827821
Remove build artifacts of types not included in this build (PDF, ePub, zip only).
828822
"""
823+
time_before_store_build_artifacts = timezone.now()
829824
log.info('Writing build artifacts to media storage')
830-
# NOTE: I don't remember why we removed this state from the Build
831-
# object. I'm re-adding it because I think it's useful, but we can
832-
# remove it if we want
833825
self.update_build(state=BUILD_STATE_UPLOADING)
834826

827+
valid_artifacts = self.get_valid_artifact_types()
835828
types_to_copy = []
836829
types_to_delete = []
837830

838831
for artifact_type in ARTIFACT_TYPES:
839-
if artifact_type in artifacts:
832+
if artifact_type in valid_artifacts:
840833
types_to_copy.append(artifact_type)
841834
# Never delete HTML nor JSON (search index)
842835
elif artifact_type not in UNDELETABLE_ARTIFACT_TYPES:
@@ -857,14 +850,20 @@ def store_build_artifacts(self, artifacts):
857850
try:
858851
build_media_storage.sync_directory(from_path, to_path)
859852
except Exception:
860-
# Ideally this should just be an IOError
861-
# but some storage backends unfortunately throw other errors
853+
# NOTE: the exceptions reported so far are:
854+
# - botocore.exceptions:HTTPClientError
855+
# - botocore.exceptions:ClientError
856+
# - readthedocs.doc_builder.exceptions:BuildCancelled
862857
log.exception(
863-
'Error copying to storage (not failing build)',
858+
"Error copying to storage",
864859
media_type=media_type,
865860
from_path=from_path,
866861
to_path=to_path,
867862
)
863+
# Re-raise the exception to fail the build and handle it
864+
# automatically at `on_failure`.
865+
# It will clearly communicate the error to the user.
866+
raise BuildAppError("Error uploading files to the storage.")
868867

869868
# Delete formats
870869
for media_type in types_to_delete:
@@ -877,13 +876,21 @@ def store_build_artifacts(self, artifacts):
877876
try:
878877
build_media_storage.delete_directory(media_path)
879878
except Exception:
880-
# Ideally this should just be an IOError
881-
# but some storage backends unfortunately throw other errors
879+
# NOTE: I didn't find any log line for this case yet
882880
log.exception(
883-
'Error deleting from storage (not failing build)',
881+
"Error deleting files from storage",
884882
media_type=media_type,
885883
media_path=media_path,
886884
)
885+
# Re-raise the exception to fail the build and handle it
886+
# automatically at `on_failure`.
887+
# It will clearly communicate the error to the user.
888+
raise BuildAppError("Error deleting files from storage.")
889+
890+
log.info(
891+
"Store build artifacts finished.",
892+
time=(timezone.now() - time_before_store_build_artifacts).seconds,
893+
)
887894

888895
def send_notifications(self, version_pk, build_pk, event):
889896
"""Send notifications to all subscribers of `event`."""

0 commit comments

Comments
 (0)