|
6 | 6 | from fastapi.testclient import TestClient |
7 | 7 | from temp_client import poll_task, task_href |
8 | 8 | import labthings_fastapi as lt |
| 9 | +import time |
9 | 10 |
|
10 | 11 |
|
11 | | -class ThingOne(lt.Thing): |
| 12 | +class CancellableCountingThing(lt.Thing): |
12 | 13 | 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 | + ) |
13 | 23 |
|
14 | 24 | @lt.thing_action |
15 | 25 | def count_slowly(self, cancel: lt.deps.CancelHook, n: int = 10): |
16 | 26 | 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) |
18 | 33 | self.counter += 1 |
19 | 34 |
|
| 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 | + |
20 | 73 |
|
21 | 74 | 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 | + """ |
22 | 128 | 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") |
25 | 131 | 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 |
31 | 166 | 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