Skip to content

Commit 1f10cc4

Browse files
authored
Merge pull request #134 from labthings/expose-InvocationCancelledError
Add docstring to InvocationCancelledError and expose to new api via exceptions.
2 parents aa3b545 + 0c8cc73 commit 1f10cc4

File tree

3 files changed

+195
-23
lines changed

3 files changed

+195
-23
lines changed

src/labthings_fastapi/dependencies/invocation.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,12 @@ def invocation_logger(id: InvocationID) -> logging.Logger:
3333
InvocationLogger = Annotated[logging.Logger, Depends(invocation_logger)]
3434

3535

36-
class InvocationCancelledError(SystemExit):
37-
pass
36+
class InvocationCancelledError(BaseException):
37+
"""An invocation was cancelled by the user.
38+
39+
Note that this inherits from BaseException so won't be caught by
40+
`except Exception`, it must be handled specifically.
41+
"""
3842

3943

4044
class CancelEvent(threading.Event):

src/labthings_fastapi/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""A submodule for custom LabThings-FastAPI Exceptions"""
22

3+
from .dependencies.invocation import InvocationCancelledError
4+
35

46
class NotConnectedToServerError(RuntimeError):
57
"""The Thing is not connected to a server
@@ -9,3 +11,6 @@ class NotConnectedToServerError(RuntimeError):
911
connected to a ThingServer. A server connection is needed
1012
to manage asynchronous behaviour.
1113
"""
14+
15+
16+
__all__ = ["NotConnectedToServerError", "InvocationCancelledError"]

tests/test_action_cancel.py

Lines changed: 184 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,201 @@
66
from fastapi.testclient import TestClient
77
from temp_client import poll_task, task_href
88
import labthings_fastapi as lt
9+
import time
910

1011

11-
class ThingOne(lt.Thing):
12+
class CancellableCountingThing(lt.Thing):
1213
counter = lt.ThingProperty(int, 0, observable=False)
14+
check = lt.ThingProperty(
15+
bool,
16+
False,
17+
observable=False,
18+
description=(
19+
"This variable is used to check that the action can detect a cancel event "
20+
"and react by performing another task, in this case, setting this variable."
21+
),
22+
)
1323

1424
@lt.thing_action
1525
def count_slowly(self, cancel: lt.deps.CancelHook, n: int = 10):
1626
for i in range(n):
17-
cancel.sleep(0.1)
27+
try:
28+
cancel.sleep(0.1)
29+
except lt.exceptions.InvocationCancelledError as e:
30+
# Set check to true to show that cancel was called.
31+
self.check = True
32+
raise (e)
1833
self.counter += 1
1934

35+
@lt.thing_action
36+
def count_slowly_but_ignore_cancel(self, cancel: lt.deps.CancelHook, n: int = 10):
37+
"""
38+
Used to check that cancellation alter task behaviour
39+
"""
40+
counting_increment = 1
41+
for i in range(n):
42+
try:
43+
cancel.sleep(0.1)
44+
except lt.exceptions.InvocationCancelledError:
45+
# Rather than cancel, this disobedient task just counts faster
46+
counting_increment = 3
47+
self.counter += counting_increment
48+
49+
@lt.thing_action
50+
def count_and_only_cancel_if_asked_twice(
51+
self, cancel: lt.deps.CancelHook, n: int = 10
52+
):
53+
"""
54+
A task that changes behaviour on cancel, but if asked a second time will cancel
55+
"""
56+
cancelled_once = False
57+
counting_increment = 1
58+
for i in range(n):
59+
try:
60+
cancel.sleep(0.1)
61+
except lt.exceptions.InvocationCancelledError as e:
62+
# If this is the second time, this is called actually cancel.
63+
if cancelled_once:
64+
raise (e)
65+
# If not, remember that this cancel event happened.
66+
cancelled_once = True
67+
# Reset the CancelHook
68+
cancel.clear()
69+
# Count backwards instead!
70+
counting_increment = -1
71+
self.counter += counting_increment
72+
2073

2174
def test_invocation_cancel():
75+
"""
76+
Test that an invocation can be cancelled and the associated
77+
exception handled correctly.
78+
"""
79+
server = lt.ThingServer()
80+
counting_thing = CancellableCountingThing()
81+
server.add_thing(counting_thing, "/counting_thing")
82+
with TestClient(server.app) as client:
83+
assert counting_thing.counter == 0
84+
assert not counting_thing.check
85+
response = client.post("/counting_thing/count_slowly", json={})
86+
response.raise_for_status()
87+
# Use `client.delete` to cancel the task!
88+
cancel_response = client.delete(task_href(response.json()))
89+
# Raise an exception is this isn't a 2xx response
90+
cancel_response.raise_for_status()
91+
invocation = poll_task(client, response.json())
92+
assert invocation["status"] == "cancelled"
93+
assert counting_thing.counter < 9
94+
# Check that error handling worked
95+
assert counting_thing.check
96+
97+
98+
def test_invocation_that_refuses_to_cancel():
99+
"""
100+
Test that an invocation can detect a cancel request but choose
101+
to modify behaviour.
102+
"""
103+
server = lt.ThingServer()
104+
counting_thing = CancellableCountingThing()
105+
server.add_thing(counting_thing, "/counting_thing")
106+
with TestClient(server.app) as client:
107+
assert counting_thing.counter == 0
108+
response = client.post(
109+
"/counting_thing/count_slowly_but_ignore_cancel", json={"n": 5}
110+
)
111+
response.raise_for_status()
112+
# Use `client.delete` to try to cancel the task!
113+
cancel_response = client.delete(task_href(response.json()))
114+
# Raise an exception is this isn't a 2xx response
115+
cancel_response.raise_for_status()
116+
invocation = poll_task(client, response.json())
117+
# As the task ignored the cancel. It should return completed
118+
assert invocation["status"] == "completed"
119+
# Counter should be greater than 5 as it counts faster if cancelled!
120+
assert counting_thing.counter > 5
121+
122+
123+
def test_invocation_that_needs_cancel_twice():
124+
"""
125+
Test that an invocation can interpret cancel to change behaviour, but
126+
can really cancel if requested a second time
127+
"""
22128
server = lt.ThingServer()
23-
thing_one = ThingOne()
24-
server.add_thing(thing_one, "/thing_one")
129+
counting_thing = CancellableCountingThing()
130+
server.add_thing(counting_thing, "/counting_thing")
25131
with TestClient(server.app) as client:
26-
r = client.post("/thing_one/count_slowly", json={})
27-
r.raise_for_status()
28-
dr = client.delete(task_href(r.json()))
29-
dr.raise_for_status()
30-
invocation = poll_task(client, r.json())
132+
# First cancel only once:
133+
assert counting_thing.counter == 0
134+
response = client.post(
135+
"/counting_thing/count_and_only_cancel_if_asked_twice", json={"n": 5}
136+
)
137+
response.raise_for_status()
138+
# Use `client.delete` to try to cancel the task!
139+
cancel_response = client.delete(task_href(response.json()))
140+
# Raise an exception is this isn't a 2xx response
141+
cancel_response.raise_for_status()
142+
invocation = poll_task(client, response.json())
143+
# As the task ignored the cancel. It should return completed
144+
assert invocation["status"] == "completed"
145+
# Counter should be less than 0 as it should started counting backwards
146+
# almost immediately.
147+
assert counting_thing.counter < 0
148+
149+
# Next cancel twice.
150+
counting_thing.counter = 0
151+
assert counting_thing.counter == 0
152+
response = client.post(
153+
"/counting_thing/count_and_only_cancel_if_asked_twice", json={"n": 5}
154+
)
155+
response.raise_for_status()
156+
# Use `client.delete` to try to cancel the task!
157+
cancel_response = client.delete(task_href(response.json()))
158+
# Raise an exception is this isn't a 2xx response
159+
cancel_response.raise_for_status()
160+
# Cancel again
161+
cancel_response2 = client.delete(task_href(response.json()))
162+
# Raise an exception is this isn't a 2xx response
163+
cancel_response2.raise_for_status()
164+
invocation = poll_task(client, response.json())
165+
# As the task ignored the cancel. It should return completed
31166
assert invocation["status"] == "cancelled"
32-
assert thing_one.counter < 9
33-
34-
# Try again, but cancel too late - should get a 503.
35-
thing_one.counter = 0
36-
r = client.post("/thing_one/count_slowly", json={"n": 0})
37-
r.raise_for_status()
38-
invocation = poll_task(client, r.json())
39-
dr = client.delete(task_href(r.json()))
40-
assert dr.status_code == 503
41-
42-
dr = client.delete(f"/invocations/{uuid.uuid4()}")
43-
assert dr.status_code == 404
167+
# Counter should be less than 0 as it should started counting backwards
168+
# almost immediately.
169+
assert counting_thing.counter < 0
170+
171+
172+
def test_late_invocation_cancel_responds_503():
173+
"""
174+
Test that cancelling an invocation after it completes returns a 503 response.
175+
"""
176+
server = lt.ThingServer()
177+
counting_thing = CancellableCountingThing()
178+
server.add_thing(counting_thing, "/counting_thing")
179+
with TestClient(server.app) as client:
180+
assert counting_thing.counter == 0
181+
assert not counting_thing.check
182+
response = client.post("/counting_thing/count_slowly", json={"n": 1})
183+
response.raise_for_status()
184+
# Sleep long enough that task completes.
185+
time.sleep(0.3)
186+
poll_task(client, response.json())
187+
# Use `client.delete` to cancel the task!
188+
cancel_response = client.delete(task_href(response.json()))
189+
# Check a 503 code is returned
190+
assert cancel_response.status_code == 503
191+
# Check counter reached it's target
192+
assert counting_thing.counter == 1
193+
# Check that error handling wasn't called
194+
assert not counting_thing.check
195+
196+
197+
def test_cancel_unknown_task():
198+
"""
199+
Test that cancelling an unknown invocation returns a 404 response
200+
"""
201+
server = lt.ThingServer()
202+
counting_thing = CancellableCountingThing()
203+
server.add_thing(counting_thing, "/counting_thing")
204+
with TestClient(server.app) as client:
205+
cancel_response = client.delete(f"/invocations/{uuid.uuid4()}")
206+
assert cancel_response.status_code == 404

0 commit comments

Comments
 (0)