diff --git a/CHANGELOG.md b/CHANGELOG.md
index be30307..0bf96a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,23 @@
-
-## 0.8.0 (2024-01-04)
+
+
+## 0.9.0 (2024-03-13)
+
+### New features
+
+- Add formatted errors when a job is not found for the `GET /v1/notebooks/:job_id` endpoint.
-### Backwards-incompatible changes
+- Errors and uncaught exceptions are now sent to Slack via a Slack webhook. The webhook URL is set via the `SLACK_WEBHOOK_URL` environment variable.
+
+### Other changes
--
+- The code base now uses Ruff for linting and formatting, replacing black, isort, and flake8. This change is part of the ongoing effort to standardize SQuaRE code bases and improve the developer experience.
+
+
+
+## 0.8.0 (2024-01-04)
### New features
@@ -17,10 +28,6 @@
- The user guide includes a new tutorial for using the Noteburst web API.
-### Bug fixes
-
--
-
### Other changes
- Update to Pydantic 2
diff --git a/changelog.d/20240305_122219_jsick_DM_43173.md b/changelog.d/20240305_122219_jsick_DM_43173.md
deleted file mode 100644
index e9b2c6c..0000000
--- a/changelog.d/20240305_122219_jsick_DM_43173.md
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-### Backwards-incompatible changes
-
--
-
-### New features
-
--
-
-### Bug fixes
-
--
-
-### Other changes
-
-- The code base now uses Ruff for linting and formatting, replacing black, isort, and flake8. This change is part of the ongoing effort to standardize SQuaRE code bases and improve the developer experience.
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 2df5a91..0e2d423 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -302,9 +302,9 @@ jsonschema-specifications==2023.12.1 \
--hash=sha256:48a76787b3e70f5ed53f1160d2b81f586e4ca6d1548c5de7085d1682674764cc \
--hash=sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c
# via jsonschema
-latexcodec==2.0.1 \
- --hash=sha256:2aa2551c373261cefe2ad3a8953a6d6533e68238d180eb4bb91d7964adb3fe9a \
- --hash=sha256:c277a193638dc7683c4c30f6684e3db728a06efb0dc9cf346db8bd0aa6c5d271
+latexcodec==3.0.0 \
+ --hash=sha256:6f3477ad5e61a0a99bd31a6a370c34e88733a6bad9c921a3ffcfacada12f41a7 \
+ --hash=sha256:917dc5fe242762cc19d963e6548b42d63a118028cdd3361d62397e3b638b6bc5
# via pybtex
linkify-it-py==2.0.3 \
--hash=sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048 \
@@ -388,34 +388,34 @@ mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
-mypy==1.8.0 \
- --hash=sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6 \
- --hash=sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d \
- --hash=sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02 \
- --hash=sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d \
- --hash=sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3 \
- --hash=sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3 \
- --hash=sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3 \
- --hash=sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66 \
- --hash=sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259 \
- --hash=sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835 \
- --hash=sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd \
- --hash=sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d \
- --hash=sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8 \
- --hash=sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07 \
- --hash=sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b \
- --hash=sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e \
- --hash=sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6 \
- --hash=sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae \
- --hash=sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9 \
- --hash=sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d \
- --hash=sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a \
- --hash=sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592 \
- --hash=sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218 \
- --hash=sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817 \
- --hash=sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4 \
- --hash=sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410 \
- --hash=sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55
+mypy==1.9.0 \
+ --hash=sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6 \
+ --hash=sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913 \
+ --hash=sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129 \
+ --hash=sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc \
+ --hash=sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974 \
+ --hash=sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374 \
+ --hash=sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150 \
+ --hash=sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03 \
+ --hash=sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9 \
+ --hash=sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02 \
+ --hash=sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89 \
+ --hash=sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2 \
+ --hash=sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d \
+ --hash=sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3 \
+ --hash=sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612 \
+ --hash=sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e \
+ --hash=sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3 \
+ --hash=sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e \
+ --hash=sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd \
+ --hash=sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04 \
+ --hash=sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed \
+ --hash=sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185 \
+ --hash=sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf \
+ --hash=sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b \
+ --hash=sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4 \
+ --hash=sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f \
+ --hash=sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6
# via -r requirements/dev.in
mypy-extensions==1.0.0 \
--hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \
@@ -429,9 +429,9 @@ nodeenv==1.8.0 \
--hash=sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2 \
--hash=sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec
# via pre-commit
-packaging==23.2 \
- --hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \
- --hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7
+packaging==24.0 \
+ --hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \
+ --hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9
# via
# -c requirements/main.txt
# pydata-sphinx-theme
@@ -562,16 +562,16 @@ pygments==2.17.2 \
pylatexenc==2.10 \
--hash=sha256:3dd8fd84eb46dc30bee1e23eaab8d8fb5a7f507347b23e5f38ad9675c84f40d3
# via documenteer
-pytest==8.0.2 \
- --hash=sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd \
- --hash=sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096
+pytest==8.1.1 \
+ --hash=sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7 \
+ --hash=sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044
# via
# -r requirements/dev.in
# pytest-asyncio
# pytest-cov
-pytest-asyncio==0.23.5 \
- --hash=sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675 \
- --hash=sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac
+pytest-asyncio==0.23.5.post1 \
+ --hash=sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e \
+ --hash=sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813
# via -r requirements/dev.in
pytest-cov==4.1.0 \
--hash=sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6 \
@@ -756,24 +756,24 @@ rpds-py==0.18.0 \
# via
# jsonschema
# referencing
-ruff==0.3.0 \
- --hash=sha256:0886184ba2618d815067cf43e005388967b67ab9c80df52b32ec1152ab49f53a \
- --hash=sha256:128265876c1d703e5f5e5a4543bd8be47c73a9ba223fd3989d4aa87dd06f312f \
- --hash=sha256:19eacceb4c9406f6c41af806418a26fdb23120dfe53583df76d1401c92b7c14b \
- --hash=sha256:23dbb808e2f1d68eeadd5f655485e235c102ac6f12ad31505804edced2a5ae77 \
- --hash=sha256:2f7dbba46e2827dfcb0f0cc55fba8e96ba7c8700e0a866eb8cef7d1d66c25dcb \
- --hash=sha256:3ef655c51f41d5fa879f98e40c90072b567c666a7114fa2d9fe004dffba00932 \
- --hash=sha256:5da894a29ec018a8293d3d17c797e73b374773943e8369cfc50495573d396933 \
- --hash=sha256:755c22536d7f1889be25f2baf6fedd019d0c51d079e8417d4441159f3bcd30c2 \
- --hash=sha256:7deb528029bacf845bdbb3dbb2927d8ef9b4356a5e731b10eef171e3f0a85944 \
- --hash=sha256:9343690f95710f8cf251bee1013bf43030072b9f8d012fbed6ad702ef70d360a \
- --hash=sha256:a1f3ed501a42f60f4dedb7805fa8d4534e78b4e196f536bac926f805f0743d49 \
- --hash=sha256:b08b356d06a792e49a12074b62222f9d4ea2a11dca9da9f68163b28c71bf1dd4 \
- --hash=sha256:cc30a9053ff2f1ffb505a585797c23434d5f6c838bacfe206c0e6cf38c921a1e \
- --hash=sha256:d0d3d7ef3d4f06433d592e5f7d813314a34601e6c5be8481cccb7fa760aa243e \
- --hash=sha256:dd73fe7f4c28d317855da6a7bc4aa29a1500320818dd8f27df95f70a01b8171f \
- --hash=sha256:e1e0d4381ca88fb2b73ea0766008e703f33f460295de658f5467f6f229658c19 \
- --hash=sha256:e3a4a6d46aef0a84b74fcd201a4401ea9a6cd85614f6a9435f2d33dd8cefbf83
+ruff==0.3.2 \
+ --hash=sha256:0ac06a3759c3ab9ef86bbeca665d31ad3aa9a4b1c17684aadb7e61c10baa0df4 \
+ --hash=sha256:0c1bdd9920cab5707c26c8b3bf33a064a4ca7842d91a99ec0634fec68f9f4037 \
+ --hash=sha256:1231eacd4510f73222940727ac927bc5d07667a86b0cbe822024dd00343e77e9 \
+ --hash=sha256:2c6d613b19e9a8021be2ee1d0e27710208d1603b56f47203d0abbde906929a9b \
+ --hash=sha256:5f65103b1d76e0d600cabd577b04179ff592064eaa451a70a81085930e907d0b \
+ --hash=sha256:77f2612752e25f730da7421ca5e3147b213dca4f9a0f7e0b534e9562c5441f01 \
+ --hash=sha256:967978ac2d4506255e2f52afe70dda023fc602b283e97685c8447d036863a302 \
+ --hash=sha256:9966b964b2dd1107797be9ca7195002b874424d1d5472097701ae8f43eadef5d \
+ --hash=sha256:9bd640a8f7dd07a0b6901fcebccedadeb1a705a50350fb86b4003b805c81385a \
+ --hash=sha256:b74c3de9103bd35df2bb05d8b2899bf2dbe4efda6474ea9681280648ec4d237d \
+ --hash=sha256:b83d17ff166aa0659d1e1deaf9f2f14cbe387293a906de09bc4860717eb2e2da \
+ --hash=sha256:bb875c6cc87b3703aeda85f01c9aebdce3d217aeaca3c2e52e38077383f7268a \
+ --hash=sha256:be75e468a6a86426430373d81c041b7605137a28f7014a72d2fc749e47f572aa \
+ --hash=sha256:c8439338a6303585d27b66b4626cbde89bb3e50fa3cae86ce52c1db7449330a7 \
+ --hash=sha256:de8b480d8379620cbb5ea466a9e53bb467d2fb07c7eca54a4aa8576483c35d36 \
+ --hash=sha256:f380be9fc15a99765c9cf316b40b9da1f6ad2ab9639e551703e581a5e6da6745 \
+ --hash=sha256:fa78ec9418eb1ca3db392811df3376b46471ae93792a81af2d1cbb0e5dcb5142
# via -r requirements/dev.in
scriv==1.5.1 \
--hash=sha256:30ae9ff8d144f8e0cf394c4e1d379542f1b3823767642955b54ec40dc00b32b6 \
@@ -783,7 +783,6 @@ six==1.16.0 \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
# via
- # latexcodec
# pybtex
# sphinxcontrib-redoc
smmap==5.0.1 \
@@ -899,9 +898,9 @@ tomlkit==0.12.4 \
--hash=sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b \
--hash=sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3
# via documenteer
-types-pyyaml==6.0.12.12 \
- --hash=sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062 \
- --hash=sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24
+types-pyyaml==6.0.12.20240311 \
+ --hash=sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342 \
+ --hash=sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6
# via -r requirements/dev.in
typing-extensions==4.10.0 \
--hash=sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475 \
@@ -921,9 +920,9 @@ urllib3==2.2.1 \
# via
# documenteer
# requests
-uvicorn==0.27.1 \
- --hash=sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a \
- --hash=sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4
+uvicorn==0.28.0 \
+ --hash=sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1 \
+ --hash=sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067
# via
# -c requirements/main.txt
# -r requirements/dev.in
diff --git a/requirements/main.in b/requirements/main.in
index 1cac71b..75fd586 100644
--- a/requirements/main.in
+++ b/requirements/main.in
@@ -19,3 +19,4 @@ pydantic_settings
PyYAML
httpx
websockets
+humanize
diff --git a/requirements/main.txt b/requirements/main.txt
index ed50546..d4a5cd1 100644
--- a/requirements/main.txt
+++ b/requirements/main.txt
@@ -30,9 +30,7 @@ arq==0.25.0 \
async-timeout==4.0.3 \
--hash=sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f \
--hash=sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028
- # via
- # aioredis
- # redis
+ # via aioredis
attrs==23.2.0 \
--hash=sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30 \
--hash=sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1
@@ -321,15 +319,19 @@ httpx==0.27.0 \
# via
# -r requirements/main.in
# safir
+humanize==4.9.0 \
+ --hash=sha256:582a265c931c683a7e9b8ed9559089dea7edcf6cc95be39a3cbc2c5d5ac2bcfa \
+ --hash=sha256:ce284a76d5b1377fd8836733b983bfb0b76f1aa1c090de2566fcf008d7f6ab16
+ # via -r requirements/main.in
idna==3.6 \
--hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \
--hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f
# via
# anyio
# httpx
-packaging==23.2 \
- --hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \
- --hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7
+packaging==24.0 \
+ --hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \
+ --hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9
# via gunicorn
pycparser==2.21 \
--hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \
@@ -495,9 +497,9 @@ pyyaml==6.0.1 \
# via
# -r requirements/main.in
# uvicorn
-redis[hiredis]==5.0.2 \
- --hash=sha256:3f82cc80d350e93042c8e6e7a5d0596e4dd68715babffba79492733e1f367037 \
- --hash=sha256:4caa8e1fcb6f3c0ef28dba99535101d80934b7d4cd541bbb47f4a3826ee472d1
+redis[hiredis]==5.0.3 \
+ --hash=sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580 \
+ --hash=sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d
# via arq
safir[arq]==5.2.1 \
--hash=sha256:1b61cc72881ddfb66e1f84b6c34ca7e062f27b5669b9d1d07377ebd117ce3ebf \
@@ -532,9 +534,9 @@ uritemplate==4.1.1 \
--hash=sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0 \
--hash=sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e
# via gidgethub
-uvicorn[standard]==0.27.1 \
- --hash=sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a \
- --hash=sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4
+uvicorn[standard]==0.28.0 \
+ --hash=sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1 \
+ --hash=sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067
# via -r requirements/main.in
uvloop==0.19.0 \
--hash=sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd \
diff --git a/src/noteburst/config.py b/src/noteburst/config.py
index 3345e50..d7a5fee 100644
--- a/src/noteburst/config.py
+++ b/src/noteburst/config.py
@@ -51,13 +51,13 @@ class Config(BaseSettings):
name: Annotated[str, Field(alias="SAFIR_NAME")] = "Noteburst"
- profile: Annotated[
- Profile, Field(alias="SAFIR_PROFILE")
- ] = Profile.production
+ profile: Annotated[Profile, Field(alias="SAFIR_PROFILE")] = (
+ Profile.production
+ )
- log_level: Annotated[
- LogLevel, Field(alias="SAFIR_LOG_LEVEL")
- ] = LogLevel.INFO
+ log_level: Annotated[LogLevel, Field(alias="SAFIR_LOG_LEVEL")] = (
+ LogLevel.INFO
+ )
logger_name: Annotated[
str,
@@ -138,6 +138,16 @@ class Config(BaseSettings):
),
] = ArqMode.production
+ slack_webhook_url: Annotated[
+ HttpUrl | None,
+ Field(
+ alias="NOTEBURST_SLACK_WEBHOOK_URL",
+ description=(
+ "Webhook URL for sending error messages to a Slack channel."
+ ),
+ ),
+ ] = None
+
@property
def arq_redis_settings(self) -> RedisSettings:
"""Create a Redis settings instance for arq."""
diff --git a/src/noteburst/exceptions.py b/src/noteburst/exceptions.py
index d7d6716..10a788d 100644
--- a/src/noteburst/exceptions.py
+++ b/src/noteburst/exceptions.py
@@ -4,7 +4,16 @@
from typing import Self
-__all__ = ["TaskError", "NbexecTaskError"]
+from fastapi import status
+from safir.fastapi import ClientRequestError
+from safir.slack.blockkit import SlackException, SlackMessage, SlackTextField
+
+__all__ = [
+ "TaskError",
+ "NbexecTaskError",
+ "NoteburstClientRequestError",
+ "NoteburstError",
+]
class TaskError(Exception):
@@ -27,3 +36,37 @@ class NbexecTaskError(TaskError):
"""Error related to a notebook execution task (nbexec)."""
task_name = "nbexec"
+
+
+class NoteburstClientRequestError(ClientRequestError):
+ """Error related to the API client."""
+
+
+class JobNotFoundError(NoteburstClientRequestError):
+ """Error raised when a notebook execution job is not found."""
+
+ error = "unknown_job"
+ status_code = status.HTTP_404_NOT_FOUND
+
+
+class NoteburstError(SlackException):
+ """Base class for internal Noteburst exceptions on the FastAPI side.
+
+ This exception derives from SlackException so that uncaught internal
+ exceptions are reported to Slack.
+ """
+
+
+class NoteburstJobError(NoteburstError):
+ """Error related to a notebook execution job."""
+
+ def __init__(self, msg: str, *, user: str | None, job_id: str) -> None:
+ super().__init__(msg, user=user)
+ self.job_id = job_id
+
+ def to_slack(self) -> SlackMessage:
+ message = super().to_slack()
+ message.fields.append(
+ SlackTextField(heading="Job ID", text=self.job_id)
+ )
+ return message
diff --git a/src/noteburst/handlers/v1/handlers.py b/src/noteburst/handlers/v1/handlers.py
index 3f97f76..8760e89 100644
--- a/src/noteburst/handlers/v1/handlers.py
+++ b/src/noteburst/handlers/v1/handlers.py
@@ -5,13 +5,20 @@
import structlog
from arq.jobs import JobStatus
from fastapi import APIRouter, Depends, Query, Request, Response
-from safir.arq import ArqQueue
+from safir.arq import ArqQueue, JobNotFound
from safir.dependencies.arq import arq_dependency
-from safir.dependencies.gafaelfawr import auth_logger_dependency
+from safir.dependencies.gafaelfawr import (
+ auth_dependency,
+ auth_logger_dependency,
+)
+from safir.models import ErrorLocation, ErrorModel
+from safir.slack.webhook import SlackRouteErrorHandler
+
+from noteburst.exceptions import JobNotFoundError, NoteburstJobError
from .models import NotebookResponse, PostNotebookRequest
-v1_router = APIRouter(tags=["v1"])
+v1_router = APIRouter(tags=["v1"], route_class=SlackRouteErrorHandler)
"""FastAPI router for the /v1/ REST API"""
@@ -75,6 +82,7 @@ async def post_nbexec(
summary="Get information about a notebook execution job",
response_model=NotebookResponse,
response_model_exclude_none=True,
+ responses={404: {"description": "Not found", "model": ErrorModel}},
)
async def get_nbexec_job(
*,
@@ -97,6 +105,7 @@ async def get_nbexec_job(
),
),
logger: Annotated[structlog.BoundLogger, Depends(auth_logger_dependency)],
+ user: Annotated[str, Depends(auth_dependency)],
arq_queue: Annotated[ArqQueue, Depends(arq_dependency)],
) -> NotebookResponse:
"""Provides information about a notebook execution job, and the result
@@ -123,12 +132,16 @@ async def get_nbexec_job(
"""
try:
job_metadata = await arq_queue.get_job_metadata(job_id)
- except Exception:
- logger.exception(
- "Error getting nbexec job metadata",
+ except JobNotFound:
+ raise JobNotFoundError(
+ "Job not found", location=ErrorLocation.path, field_path=["job_id"]
+ ) from None
+ except Exception as e:
+ raise NoteburstJobError(
+ "Error getting job metadata",
+ user=user,
job_id=job_id,
- )
- raise
+ ) from e
logger.debug(
"Got nbexec job metadata",
job_id=job_id,
@@ -139,12 +152,18 @@ async def get_nbexec_job(
if result and job_metadata.status == JobStatus.complete:
try:
job_result = await arq_queue.get_job_result(job_id)
- except Exception:
- logger.exception(
+ except JobNotFound:
+ raise JobNotFoundError(
+ "Job not found",
+ location=ErrorLocation.path,
+ field_path=["job_id"],
+ ) from None
+ except Exception as e:
+ raise NoteburstJobError(
"Error getting nbexec job result",
+ user=user,
job_id=job_id,
- )
- raise
+ ) from e
logger.debug(
"Got nbexec job result",
job_id=job_id,
diff --git a/src/noteburst/jupyterclient/jupyterlab.py b/src/noteburst/jupyterclient/jupyterlab.py
index fa9ea83..1bbbfed 100644
--- a/src/noteburst/jupyterclient/jupyterlab.py
+++ b/src/noteburst/jupyterclient/jupyterlab.py
@@ -747,10 +747,22 @@ async def execute_notebook(
Notebook execution extension.
"""
exec_url = self.url_for(f"user/{self.user.username}/rubin/execution")
- r = await self.http_client.post(
- exec_url,
- content=json.dumps(notebook).encode("utf-8"),
- )
+ try:
+ r = await self.http_client.post(
+ exec_url,
+ content=json.dumps(notebook).encode("utf-8"),
+ )
+ except httpx.HTTPError as e:
+ # This often occurs from timeouts, so we want to convert the
+ # generic HTTPError to a JupyterError.
+ raise JupyterError(
+ url=exec_url,
+ username=self.user.username,
+ status=500,
+ reason="Internal Server Error",
+ method="POST",
+ body=str(e),
+ ) from e
if r.status_code != 200:
raise JupyterError.from_response(self.user.username, r)
self.logger.debug("Got response from /rubin/execution", text=r.text)
diff --git a/src/noteburst/main.py b/src/noteburst/main.py
index d562c5e..a92ec82 100644
--- a/src/noteburst/main.py
+++ b/src/noteburst/main.py
@@ -13,12 +13,15 @@
from importlib.metadata import version
from pathlib import Path
+import structlog
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from safir.dependencies.arq import arq_dependency
from safir.dependencies.http_client import http_client_dependency
+from safir.fastapi import ClientRequestError, client_request_error_handler
from safir.logging import configure_logging, configure_uvicorn_logging
from safir.middleware.x_forwarded import XForwardedMiddleware
+from safir.slack.webhook import SlackRouteErrorHandler
from .config import config
from .handlers.external import external_router
@@ -35,6 +38,8 @@
)
configure_uvicorn_logging(config.log_level)
+logger = structlog.get_logger(__name__)
+
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -70,6 +75,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Add middleware
app.add_middleware(XForwardedMiddleware)
+if config.slack_webhook_url:
+ SlackRouteErrorHandler.initialize(
+ str(config.slack_webhook_url), "Noteburst", logger
+ )
+
+app.exception_handler(ClientRequestError)(client_request_error_handler)
+
def create_openapi() -> str:
"""Create the OpenAPI spec for static documentation."""
diff --git a/src/noteburst/worker/functions/nbexec.py b/src/noteburst/worker/functions/nbexec.py
index b4a38fd..c53c536 100644
--- a/src/noteburst/worker/functions/nbexec.py
+++ b/src/noteburst/worker/functions/nbexec.py
@@ -9,6 +9,7 @@
from typing import Any, cast
from arq import Retry
+from safir.slack.blockkit import SlackCodeBlock, SlackTextField
from noteburst.exceptions import NbexecTaskError
from noteburst.jupyterclient.jupyterlab import JupyterClient, JupyterError
@@ -59,11 +60,62 @@ async def nbexec(
logger.info("nbexec finished", error=execution_result.error)
except JupyterError as e:
logger.exception("nbexec error", jupyter_status=e.status)
+ if "slack" in ctx and "slack_message_factory" in ctx:
+ slack_client = ctx["slack"]
+ message = ctx["slack_message_factory"]("Nbexec failed.")
+ message.blocks.append(
+ SlackCodeBlock(heading="Exception", code=str(e))
+ )
+ message.fields.append(
+ SlackTextField(heading="Jupyter response", text=str(e.status))
+ )
+ message.fields.append(
+ SlackTextField(
+ heading="Job ID", text=ctx.get("job_id", "unknown")
+ )
+ )
+ message.fields.append(
+ SlackTextField(
+ heading="Attempt", text=ctx.get("job_try", "unknown")
+ )
+ )
+ message.blocks.append(
+ SlackCodeBlock(heading="Notebook", code=ipynb)
+ )
+ await slack_client.post(message)
+
if e.status >= 400 and e.status < 500:
logger.exception(
"Authentication error to Jupyter. Forcing worker shutdown",
jupyter_status=e.status,
)
+
+ if "slack" in ctx and "slack_message_factory" in ctx:
+ slack_client = ctx["slack"]
+ message = ctx["slack_message_factory"](
+ "Noteburst worker shutting down due to Jupyter "
+ "authentication error during nbexec."
+ )
+ message.blocks.append(
+ SlackCodeBlock(heading="Exception", code=str(e))
+ )
+ message.fields.append(
+ SlackTextField(
+ heading="Jupyter response", text=str(e.status)
+ )
+ )
+ message.fields.append(
+ SlackTextField(
+ heading="Job ID", text=ctx.get("job_id", "unknown")
+ )
+ )
+ message.fields.append(
+ SlackTextField(
+ heading="Attempt", text=ctx.get("job_try", "unknown")
+ )
+ )
+ await slack_client.post(message)
+
sys.exit("400 class error from Jupyter")
elif enable_retry:
logger.warning("nbexec triggering retry")
diff --git a/src/noteburst/worker/main.py b/src/noteburst/worker/main.py
index 163da59..5a4ccf4 100644
--- a/src/noteburst/worker/main.py
+++ b/src/noteburst/worker/main.py
@@ -2,12 +2,16 @@
from __future__ import annotations
+from datetime import UTC, datetime
from typing import Any, ClassVar
import httpx
+import humanize
import structlog
from arq import cron
from safir.logging import configure_logging
+from safir.slack.blockkit import SlackMessage, SlackTextField
+from safir.slack.webhook import SlackWebhookClient
from noteburst.config import WorkerConfig, WorkerKeepAliveSetting
from noteburst.jupyterclient.jupyterlab import (
@@ -47,6 +51,14 @@ async def startup(ctx: dict[Any, Any]) -> None:
http_client = httpx.AsyncClient()
ctx["http_client"] = http_client
+ if config.slack_webhook_url:
+ slack_client = SlackWebhookClient(
+ str(config.slack_webhook_url),
+ "Noteburst worker",
+ logger=logger,
+ )
+ ctx["slack"] = slack_client
+
jupyter_config = JupyterConfig(
url_prefix=config.jupyterhub_path_prefix,
image_selector=config.image_selector,
@@ -85,10 +97,48 @@ async def startup(ctx: dict[Any, Any]) -> None:
ctx["jupyter_client"] = jupyter_client
ctx["logger"] = logger
- logger.info("Start up complete")
+ logger.info(
+ "Noteburst worker startup complete.",
+ image_selector=config.image_selector,
+ image_reference=config.image_reference,
+ )
+
+ if "slack" in ctx:
+ slack_client = ctx["slack"]
+
+ date_created = datetime.now(tz=UTC)
+
+ def create_message(message: str) -> SlackMessage:
+ now = datetime.now(tz=UTC)
+ age = now - date_created
+
+ return SlackMessage(
+ message=message,
+ fields=[
+ SlackTextField(
+ heading="Username",
+ text=identity.username,
+ ),
+ SlackTextField(
+ heading="Image Selector",
+ text=config.image_selector,
+ ),
+ SlackTextField(heading="Image", text=image_info.name),
+ SlackTextField(
+ heading="Age", text=humanize.naturaldelta(age)
+ ),
+ ],
+ )
+
+ ctx["slack_message_factory"] = create_message
+
+ # Make a start-up message
+ await slack_client.post(
+ ctx["slack_message_factory"]("Noteburst worker started")
+ )
-async def shutdown(ctx: dict[Any, Any]) -> None:
+async def shutdown(ctx: dict[Any, Any]) -> None: # noqa: PLR0912
"""Clean up the worker context on shutdown."""
if "logger" in ctx:
logger = ctx["logger"]
@@ -138,6 +188,14 @@ async def shutdown(ctx: dict[Any, Any]) -> None:
logger.info("Worker shutdown complete.")
+ if "slack" in ctx and "slack_message_factory" in ctx:
+ slack_client = ctx["slack"]
+ await slack_client.post(
+ ctx["slack_message_factory"](
+ "Noteburst worker shut down complete."
+ )
+ )
+
# For info on ignoring the type checking here, see
# https://github.com/samuelcolvin/arq/issues/249
diff --git a/tests/handlers/v1_test.py b/tests/handlers/v1_test.py
index 3b21bb3..ef3dbdb 100644
--- a/tests/handlers/v1_test.py
+++ b/tests/handlers/v1_test.py
@@ -81,3 +81,12 @@ async def test_post_nbexec(
assert data["status"] == "complete"
assert data["success"] is True
assert data["ipynb"] == sample_ipynb_executed
+
+ # Request a job that doesn't exist
+ response = await client.get("/noteburst/v1/notebooks/unknown")
+ assert response.status_code == 404
+ data = response.json()
+ print(data)
+ assert data["detail"][0]["type"] == "unknown_job"
+ assert data["detail"][0]["loc"] == ["path", "job_id"]
+ assert data["detail"][0]["msg"] == "Job not found"