Skip to content

Commit 00007ed

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 00007ed

File tree

2 files changed

+59
-44
lines changed

2 files changed

+59
-44
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-43
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,
@@ -526,6 +525,7 @@ def get_valid_artifact_types(self):
526525
Add support for multiple PDF files in the output directory and
527526
grab them by using glob syntaxt between other files that could be garbage.
528527
"""
528+
valid_artifacts = []
529529
for artifact_type in ARTIFACT_TYPES:
530530
artifact_directory = self.data.project.artifact_path(
531531
version=self.data.version.slug,
@@ -537,55 +537,42 @@ def get_valid_artifact_types(self):
537537
continue
538538

539539
if not os.path.isdir(artifact_directory):
540-
log.exception(
540+
log.error(
541541
"The output path is not a directory.",
542542
output_format=artifact_type,
543543
)
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
544+
raise BuildUserError(
545+
BuildUserError.BUILD_OUTPUT_IS_NOT_A_DIRECTORY.format(
546+
artifact_type=artifact_type
547+
)
548+
)
550549

551550
# Check if there are multiple files on artifact directories.
552551
# These output format does not support multiple files yet.
553552
# 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.
560553
if artifact_type in ("htmlzip", "epub", "pdf"):
561554
if len(os.listdir(artifact_directory)) > 1:
562-
log.exception(
555+
log.error(
563556
"Multiple files are not supported for this format. "
564557
"Skipping this output format.",
565558
output_format=artifact_type,
566559
)
567-
continue
560+
raise BuildUserError(
561+
BuildUserError.BUILD_OUTPUT_HAS_MULTIPLE_FILES.format(
562+
artifact_type=artifact_type
563+
)
564+
)
568565

569566
# If all the conditions were met, the artifact is valid
570567
valid_artifacts.append(artifact_type)
571568

572569
return valid_artifacts
573570

574571
def on_success(self, retval, task_id, args, kwargs):
575-
576572
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-
)
587573

588574
# NOTE: we are updating the db version instance *only* when
575+
# TODO: remove this condition and *always* update the DB Version instance
589576
if "html" in valid_artifacts:
590577
try:
591578
api_v2.version(self.data.version.pk).patch(
@@ -601,7 +588,8 @@ def on_success(self, retval, task_id, args, kwargs):
601588
# NOTE: I think we should fail the build if we cannot update
602589
# the version at this point. Otherwise, we will have inconsistent data
603590
log.exception(
604-
'Updating version failed, skipping file sync.',
591+
"Updating version db object failed. "
592+
'Files are synced in the storage, but "Version" object is not updated',
605593
)
606594

607595
# Index search data
@@ -698,10 +686,13 @@ def update_build(self, state=None):
698686
try:
699687
api_v2.build(self.data.build['id']).patch(self.data.build)
700688
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')
689+
# NOTE: we are updating the "Build" object on each `state`.
690+
# Only if the last update fails, there may be some inconsistency
691+
# between the "Build" object in our db and the reality.
692+
#
693+
# The `state` argument will help us to track this more and understand
694+
# at what state our updates are failing and decide what to do.
695+
log.exception("Error while updating the build object.", state=state)
705696

706697
def execute(self):
707698
self.data.build_director = BuildDirector(
@@ -749,6 +740,12 @@ def execute(self):
749740
finally:
750741
self.data.build_data = self.collect_build_data()
751742

743+
# At this point, the user's build already succeeded.
744+
# However, we cannot use `.on_success()` because we still have to upload the artifacts;
745+
# which could fail, and we want to detect that and handle it properly at `.on_failure()`
746+
# Store build artifacts to storage (local or cloud storage)
747+
self.store_build_artifacts()
748+
752749
def collect_build_data(self):
753750
"""
754751
Collect data from the current build.
@@ -817,7 +814,7 @@ def set_valid_clone(self):
817814
self.data.project.has_valid_clone = True
818815
self.data.version.project.has_valid_clone = True
819816

820-
def store_build_artifacts(self, artifacts):
817+
def store_build_artifacts(self):
821818
"""
822819
Save build artifacts to "storage" using Django's storage API.
823820
@@ -826,17 +823,16 @@ def store_build_artifacts(self, artifacts):
826823
827824
Remove build artifacts of types not included in this build (PDF, ePub, zip only).
828825
"""
826+
time_before_store_build_artifacts = timezone.now()
829827
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
833828
self.update_build(state=BUILD_STATE_UPLOADING)
834829

830+
valid_artifacts = self.get_valid_artifact_types()
835831
types_to_copy = []
836832
types_to_delete = []
837833

838834
for artifact_type in ARTIFACT_TYPES:
839-
if artifact_type in artifacts:
835+
if artifact_type in valid_artifacts:
840836
types_to_copy.append(artifact_type)
841837
# Never delete HTML nor JSON (search index)
842838
elif artifact_type not in UNDELETABLE_ARTIFACT_TYPES:
@@ -857,14 +853,20 @@ def store_build_artifacts(self, artifacts):
857853
try:
858854
build_media_storage.sync_directory(from_path, to_path)
859855
except Exception:
860-
# Ideally this should just be an IOError
861-
# but some storage backends unfortunately throw other errors
856+
# NOTE: the exceptions reported so far are:
857+
# - botocore.exceptions:HTTPClientError
858+
# - botocore.exceptions:ClientError
859+
# - readthedocs.doc_builder.exceptions:BuildCancelled
862860
log.exception(
863-
'Error copying to storage (not failing build)',
861+
"Error copying to storage",
864862
media_type=media_type,
865863
from_path=from_path,
866864
to_path=to_path,
867865
)
866+
# Re-raise the exception to fail the build and handle it
867+
# automatically at `on_failure`.
868+
# It will clearly communicate the error to the user.
869+
raise BuildAppError("Error uploading files to the storage.")
868870

869871
# Delete formats
870872
for media_type in types_to_delete:
@@ -877,13 +879,21 @@ def store_build_artifacts(self, artifacts):
877879
try:
878880
build_media_storage.delete_directory(media_path)
879881
except Exception:
880-
# Ideally this should just be an IOError
881-
# but some storage backends unfortunately throw other errors
882+
# NOTE: I didn't find any log line for this case yet
882883
log.exception(
883-
'Error deleting from storage (not failing build)',
884+
"Error deleting files from storage",
884885
media_type=media_type,
885886
media_path=media_path,
886887
)
888+
# Re-raise the exception to fail the build and handle it
889+
# automatically at `on_failure`.
890+
# It will clearly communicate the error to the user.
891+
raise BuildAppError("Error deleting files from storage.")
892+
893+
log.info(
894+
"Store build artifacts finished.",
895+
time=(timezone.now() - time_before_store_build_artifacts).seconds,
896+
)
887897

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

0 commit comments

Comments
 (0)