Skip to content

Commit 327d83a

Browse files
authored
Handle download failures (#43)
- adds handling to ensure that error responses from Bynder aren't stored as though they're asset files - surfaces a message to editors in chooser modals if this happens - logs any sync issue when running the management commands
1 parent 331526e commit 327d83a

12 files changed

Lines changed: 448 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
- Optimisation: Only initialise view classes in `PatchWagtailURLsMiddleware` when we know they are being used as a replacement ([#36](https://github.com/torchbox/wagtail-bynder/pull/36)) @ababic
1818

19+
### Fixed
20+
21+
- Improved handling for unexpected server error responses when downloading Bynder assets ([#40](https://github.com/torchbox/wagtail-bynder/issues/40))
22+
1923
## [0.6] - 2024-07-29
2024

2125
### Added

src/wagtail_bynder/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ class BynderAssetFileTooLarge(Exception):
1010
Raised when an asset file being downloaded from Bynder is found to be
1111
larger than specified in ``settings.BYNDER_MAX_DOWNLOAD_FILE_SIZE``
1212
"""
13+
14+
15+
class BynderAssetDownloadError(Exception):
16+
"""
17+
Raised when a server error occurs while downloading an asset from Bynder.
18+
"""

src/wagtail_bynder/management/commands/base.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.utils.translation import gettext_lazy as _
1010
from requests import HTTPError
1111

12+
from wagtail_bynder.exceptions import BynderAssetDownloadError
1213
from wagtail_bynder.models import BynderAssetMixin
1314
from wagtail_bynder.utils import get_bynder_client
1415

@@ -164,8 +165,20 @@ def update_object(self, obj: BynderAssetMixin, asset_data: dict[str, Any]) -> No
164165
self.stdout.write(f" {key}: {value}")
165166
self.stdout.write("-" * 80)
166167

167-
obj.update_from_asset_data(asset_data)
168-
obj.save()
168+
try:
169+
obj.update_from_asset_data(asset_data)
170+
obj.save()
171+
except BynderAssetDownloadError as e:
172+
self.stdout.write(
173+
self.style.ERROR(
174+
f"ERROR: Failed to download asset '{asset_data['id']}': {e}\n"
175+
)
176+
)
177+
self.stdout.write(
178+
self.style.WARNING(
179+
f"Skipping update for {repr(obj)}. The asset will be retried on the next sync.\n"
180+
)
181+
)
169182

170183

171184
class BaseBynderRefreshCommand(BaseModelCommand):
@@ -244,5 +257,17 @@ def update_object(self, obj: BynderAssetMixin, asset_data: dict[str, Any]) -> No
244257
self.stdout.write(
245258
f"Updating <{self.model._meta.label}: pk='{obj.pk}' title='{obj.title}'>" # type: ignore[attr-defined]
246259
)
247-
obj.update_from_asset_data(asset_data, force_download=self.force_download)
248-
obj.save()
260+
try:
261+
obj.update_from_asset_data(asset_data, force_download=self.force_download)
262+
obj.save()
263+
except BynderAssetDownloadError as e:
264+
self.stdout.write(
265+
self.style.ERROR(
266+
f"ERROR: Failed to download asset '{asset_data['id']}': {e}\n"
267+
)
268+
)
269+
self.stdout.write(
270+
self.style.WARNING(
271+
f"Skipping update for {repr(obj)}. The asset will be retried on the next sync.\n"
272+
)
273+
)

src/wagtail_bynder/static/wagtailadmin/js/chooser-modal-handler-factory.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ class BynderChooserModalOnloadHandlerFactory {
2121
modal.close();
2222
}
2323

24+
onLoadErrorStep(modal, jsonData) {
25+
// Display error message in the modal
26+
$(modal.body).append(
27+
'<div class="help-block help-critical">' +
28+
'<strong>' +
29+
gettext('Server Error') +
30+
': </strong>' +
31+
jsonData.error_message +
32+
'</div>',
33+
);
34+
// Re-initialize the Bynder view so user can try again
35+
this.initBynderCompactView(modal);
36+
}
37+
2438
initBynderCompactView(modal) {
2539
// NOTE: This div is added to the template:
2640
// wagtailadmin/chooser/choose-bynder.html template
@@ -78,6 +92,9 @@ class BynderChooserModalOnloadHandlerFactory {
7892
[this.chosenStepName]: (modal, jsonData) => {
7993
this.onLoadChosenStep(modal, jsonData);
8094
},
95+
error: (modal, jsonData) => {
96+
this.onLoadErrorStep(modal, jsonData);
97+
},
8198
};
8299
}
83100
}

src/wagtail_bynder/utils.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import mimetypes
22
import os
33

4+
from http import HTTPStatus
45
from io import BytesIO
56

67
import requests
@@ -14,7 +15,7 @@
1415
from wagtail.models import Collection
1516
from willow import Image
1617

17-
from .exceptions import BynderAssetFileTooLarge
18+
from .exceptions import BynderAssetDownloadError, BynderAssetFileTooLarge
1819

1920

2021
_DEFAULT_COLLECTION = Local()
@@ -24,11 +25,21 @@ def download_file(
2425
url: str, max_filesize: int, max_filesize_setting_name: str
2526
) -> InMemoryUploadedFile:
2627
name = os.path.basename(url)
28+
response = requests.get(url, timeout=20, stream=True)
29+
30+
# Make sure we don't store error responses instead of the file requested
31+
if response.status_code != HTTPStatus.OK:
32+
raise BynderAssetDownloadError(
33+
f"Server error downloading '{name}' from Bynder. "
34+
)
2735

28-
# Stream file to memory
2936
file = BytesIO()
30-
for line in requests.get(url, timeout=20, stream=True):
31-
file.write(line)
37+
# Stream the file to memory. We use iter_content() instead of the default iterator for requests.Response,
38+
# as the latter uses iter_lines() which isn't suitable for streaming binary data.
39+
for chunk in response.iter_content():
40+
if not chunk:
41+
continue
42+
file.write(chunk)
3243
if file.tell() > max_filesize:
3344
file.truncate(0)
3445
raise BynderAssetFileTooLarge(

src/wagtail_bynder/views/document.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import TYPE_CHECKING
22

33
from django.conf import settings
4+
from django.utils.html import format_html
45
from django.views.generic import UpdateView
56
from wagtail import VERSION as WAGTAIL_VERSION
7+
from wagtail.admin.modal_workflow import render_modal_workflow
68
from wagtail.documents import get_document_model
79
from wagtail.documents.views import chooser as chooser_views
810

@@ -13,6 +15,8 @@
1315
else:
1416
from wagtail.documents.views.documents import DeleteView, EditView
1517

18+
from wagtail_bynder.exceptions import BynderAssetDownloadError
19+
1620
from .mixins import BynderAssetCopyMixin, RedirectToBynderMixin
1721

1822

@@ -72,10 +76,26 @@ class DocumentChosenView(BynderAssetCopyMixin, chooser_views.DocumentChosenView)
7276

7377
def get(self, request: "HttpRequest", pk: str) -> "JsonResponse":
7478
try:
75-
obj = self.model.objects.get(bynder_id=pk)
76-
except self.model.DoesNotExist:
77-
obj = self.create_object(pk)
78-
else:
79-
if getattr(settings, "BYNDER_SYNC_EXISTING_DOCUMENTS_ON_CHOOSE", False):
80-
self.update_object(pk, obj)
79+
try:
80+
obj = self.model.objects.get(bynder_id=pk)
81+
except self.model.DoesNotExist:
82+
obj = self.create_object(pk)
83+
else:
84+
if getattr(settings, "BYNDER_SYNC_EXISTING_DOCUMENTS_ON_CHOOSE", False):
85+
self.update_object(pk, obj)
86+
except BynderAssetDownloadError as e:
87+
# Return error step to display message in the chooser modal
88+
return render_modal_workflow(
89+
request,
90+
None,
91+
None,
92+
None,
93+
json_data={
94+
"step": "error",
95+
"error_message": format_html(
96+
"<strong>Failed to download document from Bynder:</strong> {error} Please try again later.",
97+
error=e,
98+
),
99+
},
100+
)
81101
return self.get_chosen_response(obj)

src/wagtail_bynder/views/image.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from typing import TYPE_CHECKING
22

33
from django.conf import settings
4+
from django.utils.html import format_html
45
from django.views.generic import UpdateView
56
from wagtail import VERSION as WAGTAIL_VERSION
7+
from wagtail.admin.modal_workflow import render_modal_workflow
68
from wagtail.images import get_image_model
79
from wagtail.images.views import chooser as chooser_views
810

@@ -13,6 +15,8 @@
1315
else:
1416
from wagtail.images.views.images import DeleteView, EditView
1517

18+
from wagtail_bynder.exceptions import BynderAssetDownloadError
19+
1620
from .mixins import BynderAssetCopyMixin, RedirectToBynderMixin
1721

1822

@@ -70,10 +74,26 @@ class ImageChosenView(BynderAssetCopyMixin, chooser_views.ImageChosenView):
7074

7175
def get(self, request: "HttpRequest", pk: str) -> "JsonResponse":
7276
try:
73-
obj = self.model.objects.get(bynder_id=pk)
74-
except self.model.DoesNotExist:
75-
obj = self.create_object(pk)
76-
else:
77-
if getattr(settings, "BYNDER_SYNC_EXISTING_IMAGES_ON_CHOOSE", False):
78-
self.update_object(pk, obj)
77+
try:
78+
obj = self.model.objects.get(bynder_id=pk)
79+
except self.model.DoesNotExist:
80+
obj = self.create_object(pk)
81+
else:
82+
if getattr(settings, "BYNDER_SYNC_EXISTING_IMAGES_ON_CHOOSE", False):
83+
self.update_object(pk, obj)
84+
except BynderAssetDownloadError as e:
85+
# Return error step to display message in the chooser modal
86+
return render_modal_workflow(
87+
request,
88+
None,
89+
None,
90+
None,
91+
json_data={
92+
"step": "error",
93+
"error_message": format_html(
94+
"<strong>Failed to download image from Bynder:</strong> {error} Please try again later.",
95+
error=e,
96+
),
97+
},
98+
)
7999
return self.get_chosen_response(obj)

src/wagtail_bynder/views/video.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
from django.conf import settings
44
from django.utils.functional import cached_property
5+
from django.utils.html import format_html
56
from django.utils.translation import gettext_lazy as _
7+
from wagtail.admin.modal_workflow import render_modal_workflow
68
from wagtail.admin.views.generic.chooser import ChooseView
79
from wagtail.snippets.views.chooser import SnippetChooserViewSet, SnippetChosenView
810
from wagtail.snippets.views.snippets import EditView, SnippetViewSet
911

1012
from wagtail_bynder import get_video_model
13+
from wagtail_bynder.exceptions import BynderAssetDownloadError
1114

1215
from .mixins import BynderAssetCopyMixin, RedirectToBynderMixin
1316

@@ -34,12 +37,28 @@ def page_subtitle(self):
3437
class VideoChosenView(BynderAssetCopyMixin, SnippetChosenView):
3538
def get(self, request: "HttpRequest", pk: str) -> "JsonResponse":
3639
try:
37-
obj = self.model.objects.get(bynder_id=pk)
38-
except self.model.DoesNotExist:
39-
obj = self.create_object(pk)
40-
else:
41-
if getattr(settings, "BYNDER_SYNC_EXISTING_VIDEOS_ON_CHOOSE", False):
42-
self.update_object(pk, obj)
40+
try:
41+
obj = self.model.objects.get(bynder_id=pk)
42+
except self.model.DoesNotExist:
43+
obj = self.create_object(pk)
44+
else:
45+
if getattr(settings, "BYNDER_SYNC_EXISTING_VIDEOS_ON_CHOOSE", False):
46+
self.update_object(pk, obj)
47+
except BynderAssetDownloadError as e:
48+
# Return error step to display message in the chooser modal
49+
return render_modal_workflow(
50+
request,
51+
None,
52+
None,
53+
None,
54+
json_data={
55+
"step": "error",
56+
"error_message": format_html(
57+
"<strong>Failed to fetch video from Bynder:</strong> {error} Please try again later.",
58+
error=e,
59+
),
60+
},
61+
)
4362
return self.get_chosen_response(obj)
4463

4564

tests/test_image_chooser_views.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from testapp.factories import CustomImageFactory
66
from wagtail.test.utils import WagtailTestUtils
77

8+
from wagtail_bynder.exceptions import BynderAssetDownloadError
9+
810
from .utils import TEST_ASSET_ID
911

1012

@@ -106,3 +108,45 @@ def test_uses_existing_image_and_updates_it(self, update_object_mock):
106108
},
107109
},
108110
)
111+
112+
def test_returns_error_step_when_download_fails(self):
113+
"""Test that download errors return an error step instead of crashing"""
114+
with mock.patch(
115+
"wagtail_bynder.views.image.ImageChosenView.create_object",
116+
) as create_object_mock:
117+
# Mock create_object to raise download error
118+
create_object_mock.side_effect = BynderAssetDownloadError(
119+
"Server error downloading 'test.jpg' from Bynder. "
120+
)
121+
122+
response = self.client.get(str(self.url))
123+
124+
# Should return error step, not crash
125+
self.assertEqual(response.status_code, 200)
126+
response_data = response.json()
127+
self.assertEqual(response_data["step"], "error")
128+
self.assertIn("error_message", response_data)
129+
self.assertIn("Server error downloading", response_data["error_message"])
130+
131+
@override_settings(BYNDER_SYNC_EXISTING_IMAGES_ON_CHOOSE=True)
132+
def test_returns_error_step_when_update_fails(self):
133+
"""Test that download errors during update return an error step"""
134+
# Create an image with a matching bynder_id
135+
CustomImageFactory.create(bynder_id=TEST_ASSET_ID)
136+
137+
with mock.patch(
138+
"wagtail_bynder.views.image.ImageChosenView.update_object",
139+
) as update_object_mock:
140+
# Mock update_object to raise download error
141+
update_object_mock.side_effect = BynderAssetDownloadError(
142+
"Server error downloading 'test.jpg' from Bynder. "
143+
)
144+
145+
response = self.client.get(str(self.url))
146+
147+
# Should return error step, not crash
148+
self.assertEqual(response.status_code, 200)
149+
response_data = response.json()
150+
self.assertEqual(response_data["step"], "error")
151+
self.assertIn("error_message", response_data)
152+
self.assertIn("Server error downloading", response_data["error_message"])

0 commit comments

Comments
 (0)