Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/labthings_fastapi/dependencies/invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@
InvocationLogger = Annotated[logging.Logger, Depends(invocation_logger)]


class InvocationCancelledError(SystemExit):
pass
class InvocationCancelledError(BaseException):
"""An invocation was cancelled by the user.

Note that this inherits from BaseException so won't be caught by
`except Exception`, it must be handled specifically.
"""


class CancelEvent(threading.Event):
Expand All @@ -44,8 +48,8 @@

def raise_if_set(self):
"""Raise a CancelledError if the event is set"""
if self.is_set():
raise InvocationCancelledError("The action was cancelled.")

Check warning on line 52 in src/labthings_fastapi/dependencies/invocation.py

View workflow job for this annotation

GitHub Actions / coverage

51-52 lines are not covered with tests

def sleep(self, timeout: float):
"""Sleep for a given time in seconds, but raise an exception if cancelled"""
Expand Down
5 changes: 5 additions & 0 deletions src/labthings_fastapi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""A submodule for custom LabThings-FastAPI Exceptions"""

from .dependencies.invocation import InvocationCancelledError


class NotConnectedToServerError(RuntimeError):
"""The Thing is not connected to a server
Expand All @@ -9,3 +11,6 @@ class NotConnectedToServerError(RuntimeError):
connected to a ThingServer. A server connection is needed
to manage asynchronous behaviour.
"""


__all__ = ["NotConnectedToServerError", "InvocationCancelledError"]
205 changes: 184 additions & 21 deletions tests/test_action_cancel.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,201 @@
from fastapi.testclient import TestClient
from temp_client import poll_task, task_href
import labthings_fastapi as lt
import time


class ThingOne(lt.Thing):
class CancellableCountingThing(lt.Thing):
counter = lt.ThingProperty(int, 0, observable=False)
check = lt.ThingProperty(
bool,
False,
observable=False,
description=(
"This variable is used to check that the action can detect a cancel event "
"and react by performing another task, in this case, setting this variable."
),
)

@lt.thing_action
def count_slowly(self, cancel: lt.deps.CancelHook, n: int = 10):
for i in range(n):
cancel.sleep(0.1)
try:
cancel.sleep(0.1)
except lt.exceptions.InvocationCancelledError as e:
# Set check to true to show that cancel was called.
self.check = True
raise (e)
self.counter += 1

@lt.thing_action
def count_slowly_but_ignore_cancel(self, cancel: lt.deps.CancelHook, n: int = 10):
"""
Used to check that cancellation alter task behaviour
"""
counting_increment = 1
for i in range(n):
try:
cancel.sleep(0.1)
except lt.exceptions.InvocationCancelledError:
# Rather than cancel, this disobedient task just counts faster
counting_increment = 3
self.counter += counting_increment

@lt.thing_action
def count_and_only_cancel_if_asked_twice(
self, cancel: lt.deps.CancelHook, n: int = 10
):
"""
A task that changes behaviour on cancel, but if asked a second time will cancel
"""
cancelled_once = False
counting_increment = 1
for i in range(n):
try:
cancel.sleep(0.1)
except lt.exceptions.InvocationCancelledError as e:
# If this is the second time, this is called actually cancel.
if cancelled_once:
raise (e)
# If not, remember that this cancel event happened.
cancelled_once = True
# Reset the CancelHook
cancel.clear()
# Count backwards instead!
counting_increment = -1
self.counter += counting_increment


def test_invocation_cancel():
"""
Test that an invocation can be cancelled and the associated
exception handled correctly.
"""
server = lt.ThingServer()
counting_thing = CancellableCountingThing()
server.add_thing(counting_thing, "/counting_thing")
with TestClient(server.app) as client:
assert counting_thing.counter == 0
assert not counting_thing.check
response = client.post("/counting_thing/count_slowly", json={})
response.raise_for_status()
# Use `client.delete` to cancel the task!
cancel_response = client.delete(task_href(response.json()))
# Raise an exception is this isn't a 2xx response
cancel_response.raise_for_status()
invocation = poll_task(client, response.json())
assert invocation["status"] == "cancelled"
assert counting_thing.counter < 9
# Check that error handling worked
assert counting_thing.check


def test_invocation_that_refuses_to_cancel():
"""
Test that an invocation can detect a cancel request but choose
to modify behaviour.
"""
server = lt.ThingServer()
counting_thing = CancellableCountingThing()
server.add_thing(counting_thing, "/counting_thing")
with TestClient(server.app) as client:
assert counting_thing.counter == 0
response = client.post(
"/counting_thing/count_slowly_but_ignore_cancel", json={"n": 5}
)
response.raise_for_status()
# Use `client.delete` to try to cancel the task!
cancel_response = client.delete(task_href(response.json()))
# Raise an exception is this isn't a 2xx response
cancel_response.raise_for_status()
invocation = poll_task(client, response.json())
# As the task ignored the cancel. It should return completed
assert invocation["status"] == "completed"
# Counter should be greater than 5 as it counts faster if cancelled!
assert counting_thing.counter > 5


def test_invocation_that_needs_cancel_twice():
"""
Test that an invocation can interpret cancel to change behaviour, but
can really cancel if requested a second time
"""
server = lt.ThingServer()
thing_one = ThingOne()
server.add_thing(thing_one, "/thing_one")
counting_thing = CancellableCountingThing()
server.add_thing(counting_thing, "/counting_thing")
with TestClient(server.app) as client:
r = client.post("/thing_one/count_slowly", json={})
r.raise_for_status()
dr = client.delete(task_href(r.json()))
dr.raise_for_status()
invocation = poll_task(client, r.json())
# First cancel only once:
assert counting_thing.counter == 0
response = client.post(
"/counting_thing/count_and_only_cancel_if_asked_twice", json={"n": 5}
)
response.raise_for_status()
# Use `client.delete` to try to cancel the task!
cancel_response = client.delete(task_href(response.json()))
# Raise an exception is this isn't a 2xx response
cancel_response.raise_for_status()
invocation = poll_task(client, response.json())
# As the task ignored the cancel. It should return completed
assert invocation["status"] == "completed"
# Counter should be less than 0 as it should started counting backwards
# almost immediately.
assert counting_thing.counter < 0

# Next cancel twice.
counting_thing.counter = 0
assert counting_thing.counter == 0
response = client.post(
"/counting_thing/count_and_only_cancel_if_asked_twice", json={"n": 5}
)
response.raise_for_status()
# Use `client.delete` to try to cancel the task!
cancel_response = client.delete(task_href(response.json()))
# Raise an exception is this isn't a 2xx response
cancel_response.raise_for_status()
# Cancel again
cancel_response2 = client.delete(task_href(response.json()))
# Raise an exception is this isn't a 2xx response
cancel_response2.raise_for_status()
invocation = poll_task(client, response.json())
# As the task ignored the cancel. It should return completed
assert invocation["status"] == "cancelled"
assert thing_one.counter < 9

# Try again, but cancel too late - should get a 503.
thing_one.counter = 0
r = client.post("/thing_one/count_slowly", json={"n": 0})
r.raise_for_status()
invocation = poll_task(client, r.json())
dr = client.delete(task_href(r.json()))
assert dr.status_code == 503

dr = client.delete(f"/invocations/{uuid.uuid4()}")
assert dr.status_code == 404
# Counter should be less than 0 as it should started counting backwards
# almost immediately.
assert counting_thing.counter < 0


def test_late_invocation_cancel_responds_503():
"""
Test that cancelling an invocation after it completes returns a 503 response.
"""
server = lt.ThingServer()
counting_thing = CancellableCountingThing()
server.add_thing(counting_thing, "/counting_thing")
with TestClient(server.app) as client:
assert counting_thing.counter == 0
assert not counting_thing.check
response = client.post("/counting_thing/count_slowly", json={"n": 1})
response.raise_for_status()
# Sleep long enough that task completes.
time.sleep(0.3)
poll_task(client, response.json())
# Use `client.delete` to cancel the task!
cancel_response = client.delete(task_href(response.json()))
# Check a 503 code is returned
assert cancel_response.status_code == 503
# Check counter reached it's target
assert counting_thing.counter == 1
# Check that error handling wasn't called
assert not counting_thing.check


def test_cancel_unknown_task():
"""
Test that cancelling an unknown invocation returns a 404 response
"""
server = lt.ThingServer()
counting_thing = CancellableCountingThing()
server.add_thing(counting_thing, "/counting_thing")
with TestClient(server.app) as client:
cancel_response = client.delete(f"/invocations/{uuid.uuid4()}")
assert cancel_response.status_code == 404