Skip to content

Commit bf8cb44

Browse files
yt-msMidnighter
authored andcommitted
fix(api): ignore workspace locking errors on free plans
Creds to @maximveksler
1 parent e8ec59b commit bf8cb44

File tree

2 files changed

+117
-15
lines changed

2 files changed

+117
-15
lines changed

src/structurizr/api/structurizr_client.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from contextlib import contextmanager
2626
from datetime import datetime, timedelta, timezone
2727
from pathlib import Path
28-
from typing import Dict
28+
from typing import Dict, Tuple
2929
from urllib.parse import unquote_plus
3030

3131
import httpx
@@ -58,7 +58,9 @@ class StructurizrClient:
5858
agent (str): A string identifying the agent (e.g. 'structurizr-java/1.2.0').
5959
workspace_archive_location (pathlib.Path): A directory for archiving downloaded
6060
workspaces, or None to suppress archiving.
61-
61+
ignore_free_plan_locking_errors (bool): When True (the default), attempts to
62+
lock a workspace on a free plan licence will succeed even though free plans
63+
don't allow locking.
6264
"""
6365

6466
def __init__(self, *, settings: StructurizrClientSettings, **kwargs):
@@ -82,6 +84,7 @@ def __init__(self, *, settings: StructurizrClientSettings, **kwargs):
8284
self.agent = settings.agent
8385
self.workspace_archive_location = settings.workspace_archive_location
8486
self.merge_from_remote = True
87+
self.ignore_free_plan_locking_errors = True
8588
self._workspace_url = f"/workspace/{self.workspace_id}"
8689
self._lock_url = f"{self._workspace_url}/lock"
8790
self._params = {
@@ -134,15 +137,16 @@ def __exit__(self, exc_type, exc_val, exc_tb):
134137
@contextmanager
135138
def lock(self):
136139
"""Provide a context manager for locking and unlocking a workspace."""
137-
is_successful = self.lock_workspace()
138-
if not is_successful:
139-
raise StructurizrClientException(
140-
f"Failed to lock the Structurizr workspace {self.workspace_id}."
141-
)
140+
locked, paid_plan = self._lock_workspace()
141+
if not locked:
142+
if paid_plan or not self.ignore_free_plan_locking_errors:
143+
raise StructurizrClientException(
144+
f"Failed to lock the Structurizr workspace {self.workspace_id}."
145+
)
142146
try:
143147
yield self
144148
finally:
145-
is_successful = self.unlock_workspace()
149+
is_successful = self.unlock_workspace() if locked else True
146150
self._client.close()
147151
if not is_successful:
148152
raise StructurizrClientException(
@@ -224,13 +228,13 @@ def put_workspace(self, workspace: Workspace) -> None:
224228
f"Error message: {body.get('message', '')}"
225229
)
226230

227-
def lock_workspace(self) -> bool:
231+
def _lock_workspace(self) -> Tuple[bool, bool]:
228232
"""
229233
Lock the Structurizr workspace.
230234
231235
Returns:
232-
bool: `True` if the workspace could be locked, `False` otherwise.
233-
236+
bool: `True` if lock succeeeded
237+
bool: `True` if on paid plan
234238
"""
235239
request = self._client.build_request("PUT", self._lock_url, params=self._params)
236240
request.headers.update(self._add_headers(request))
@@ -239,10 +243,26 @@ def lock_workspace(self) -> bool:
239243
logger.debug("%r", response.json())
240244
response = APIResponse.parse_raw(response.text)
241245
if not response.success:
242-
logger.error(
246+
logger.warning(
243247
f"Failed to lock workspace {self.workspace_id}. {response.message}"
244248
)
245-
return response.success
249+
return response.success, self._paid_plan(response)
250+
251+
def lock_workspace(self) -> bool:
252+
"""Lock the Structurizr workspace.
253+
254+
Returns:
255+
bool: `True` if the workspace could be locked, `False` otherwise.
256+
257+
Note that free plan Structurizr licences do not support locking. By
258+
default this failure will be ignored, however if you do want such locks
259+
to fail then set ignore_free_plan_locking_errors to False.
260+
"""
261+
success, paid_plan = self._lock_workspace()
262+
if not success and not paid_plan and self.ignore_free_plan_locking_errors:
263+
logger.debug("Ignoring lock failure on free plan")
264+
success = True
265+
return success
246266

247267
def unlock_workspace(self) -> bool:
248268
"""
@@ -259,11 +279,15 @@ def unlock_workspace(self) -> bool:
259279
response = self._client.send(request)
260280
response.raise_for_status()
261281
response = APIResponse.parse_raw(response.text)
282+
success = response.success
262283
if not response.success:
263-
logger.error(
284+
logger.warning(
264285
f"Failed to unlock workspace {self.workspace_id}. {response.message}"
265286
)
266-
return response.success
287+
if self.ignore_free_plan_locking_errors and not self._paid_plan(response):
288+
logger.debug("Ignoring unlock failure on free plan")
289+
success = True
290+
return success
267291

268292
def _add_headers(
269293
self,
@@ -359,3 +383,7 @@ def _message_digest(
359383
) -> str:
360384
"""Assemble the complete message digest."""
361385
return f"{http_verb}\n{uri_path}\n{definition_md5}\n{content_type}\n{nonce}\n"
386+
387+
@staticmethod
388+
def _paid_plan(response: APIResponse) -> bool:
389+
return "free plan" not in response.message.lower()

tests/unit/api/test_structurizr_client.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,34 @@ def fake_send(request: Request):
213213
assert requests[1].url.path == "/workspace/19/lock"
214214

215215

216+
def test_locking_and_unlocking_on_free_plan(
217+
client: StructurizrClient, mocker: MockerFixture
218+
):
219+
"""Ensure that lock failures on free plans are handled correctly."""
220+
requests: List[Request] = []
221+
222+
def fake_send(request: Request):
223+
nonlocal requests
224+
requests.append(request)
225+
return Response(
226+
200,
227+
content='{"success": false, "message": "Free plans cannot lock"}'.encode(
228+
"ascii"
229+
),
230+
request=request,
231+
)
232+
233+
mocker.patch.object(client._client, "send", new=fake_send)
234+
with client:
235+
pass
236+
237+
assert len(requests) == 2
238+
assert requests[0].method == "PUT"
239+
assert requests[0].url.path == "/workspace/19/lock"
240+
assert requests[1].method == "DELETE"
241+
assert requests[1].url.path == "/workspace/19/lock"
242+
243+
216244
def test_locking_and_unlocking_with_context_manager(
217245
client: StructurizrClient, mocker: MockerFixture
218246
):
@@ -307,3 +335,49 @@ def fake_send(request: Request):
307335
with pytest.raises(httpx.HTTPStatusError):
308336
with client.lock():
309337
pass
338+
339+
340+
def test_failed_lock_on_free_plan_doesnt_attempt_unlock(
341+
client: StructurizrClient, mocker: MockerFixture
342+
):
343+
"""Check that if lock failed because on free plan then unlock isn't called."""
344+
requests: List[Request] = []
345+
346+
def fake_send(request: Request):
347+
nonlocal requests
348+
requests.append(request)
349+
return Response(
350+
200,
351+
content='{"success": false, "message": "Cannot lock on free plan"}'.encode(
352+
"ascii"
353+
),
354+
request=request,
355+
)
356+
357+
mocker.patch.object(client._client, "send", new=fake_send)
358+
with client.lock():
359+
pass
360+
361+
assert len(requests) == 1
362+
363+
364+
def test_failed_lock_on_free_plan_with_ignore_off(
365+
client: StructurizrClient, mocker: MockerFixture
366+
):
367+
"""Check that if ignoring free plan lock failures is disabled then it does fail."""
368+
369+
def fake_send(request: Request):
370+
return Response(
371+
200,
372+
content='{"success": false, "message": "Cannot lock on free plan"}'.encode(
373+
"ascii"
374+
),
375+
request=request,
376+
)
377+
378+
mocker.patch.object(client._client, "send", new=fake_send)
379+
380+
client.ignore_free_plan_locking_errors = False
381+
with pytest.raises(StructurizrClientException, match="Failed to lock"):
382+
with client.lock():
383+
pass

0 commit comments

Comments
 (0)