Kalyan Yalamanchi
03/10/2026, 4:54 PMfrom prefect import Task, flow
from prefect.blocks.notifications import SlackWebhook
from prefect.client.schemas.objects import State, TaskRun
from prefect.runtime import flow_run
from datetime import datetime
from zoneinfo import ZoneInfo
from functools import partial
def data_loader_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, data_source_name: str
):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = (
SlackWebhook.load("slack-prod")
if env == "prod"
else SlackWebhook.load("slack-staging")
)
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {datetime.now().astimezone(tz=ZoneInfo("America/New_York"))}"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject="Data loader failure",
)
print(f"Sent Slack message for data loader '{data_source_name}' failure")
@flow(
name="Data loader split",
flow_run_name="{data_sources_chunk}",
)
def chunk_subflow(data_sources_chunk: list[str], env: str):
results = []
for x in data_sources_chunk:
if x != "":
try:
extract_and_load_task_with_failure_hook = (
extract_and_load_task.with_options(
on_failure=[
partial(
data_loader_failure_notify_slack,
**dict(env=env, data_source_name=x),
)
]
)
)
# to use concurrency, call as .._hook.submit(x) and handle futures
result = extract_and_load_task_with_failure_hook(x)
results.append(result)
except Exception as e:
print(f"Data loader '{x}' failed: {e}, continuing run")
return resultsMarvin
03/10/2026, 4:54 PMMarvin
03/10/2026, 5:01 PMprefect.blocks.notifications.SlackWebhook block uses Apprise under the hood. By default, Apprise returns False on HTTP failures and does not raise. Prefect’s notify() mirrors that behavior unless you explicitly opt-in to raising.
- Additionally, Prefect 2.x task/flow hooks catch and log exceptions (so even if you do raise, the task run won’t fail due to the hook).
- This combo can make failures “look successful” in logs unless you capture the Apprise debug output.
Quick fix to surface the error
Wrap the call in raise_on_failure() and optionally set a standard notify_type to avoid edge cases with Prefect’s custom type in 2.x:
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
def data_loader_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, data_source_name: str
):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = (
SlackWebhook.load("slack-prod") if env == "prod" else SlackWebhook.load("slack-staging")
)
try:
# Optional: use a standard Apprise notify type in Prefect 2.x
slack_webhook_block.notify_type = "info"
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at "
f"{datetime.now().astimezone(tz=ZoneInfo('America/New_York'))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject="Data loader failure",
)
print(f"Sent Slack message for data loader '{data_source_name}' failure")
except NotificationError as e:
# This prints Apprise’s debug logs including HTTP status/connection errors
print("Slack notification failed. Apprise logs:")
print(e.log)
Why Automations work but this hook doesn’t
- Automations run in Prefect Cloud/Server infrastructure and send the webhook from there.
- Your hook runs inside your worker’s runtime (container/VM). If that environment lacks internet egress/DNS/CA certs or is behind a proxy, Slack calls can fail there while Automations still succeed.
Debug checklist
- Use the raise_on_failure() wrapper above and share the NotificationError.log output — it’ll include the HTTP status or connection error.
- Verify egress from the worker runtime:
- Can it resolve and reach <https://hooks.slack.com>?
- Corporate firewall/proxy? If so, set `HTTP_PROXY`/`HTTPS_PROXY` or allowlist Slack.
- Ensure CA certificates are installed in the image (e.g., ca-certificates on Debian/Alpine).
- Double-check you’re loading the intended block per env (slack-prod vs slack-staging) in the same workspace your worker is connected to.
- Optional: explicitly set notify_type = "info" as above.
Minimal in-environment test
Run this as a simple flow in the same image/pod as your worker:
from prefect import flow
from prefect.blocks.notifications import SlackWebhook
@flow
def test_slack(env: str = "staging"):
block = SlackWebhook.load("slack-prod" if env == "prod" else "slack-staging")
block.notify_type = "info"
with block.raise_on_failure():
block.notify("Test message from worker runtime", subject="Prefect Slack test")
if __name__ == "__main__":
test_slack()
Alternative (sometimes clearer errors)
If you prefer using the Slack SDK (bot token) or an incoming webhook helper via the collection:
- Incoming webhook helper:
- prefect-slack: messages
- Bot-token chat messages:
- prefect-slack integration
Docs linksMarvin
03/10/2026, 5:01 PMraise_on_failure(), what do you see in NotificationError.log?Kalyan Yalamanchi
03/10/2026, 6:37 PMnotify_type previously and updated to newest version of v2 that fixes that issue. Using raise_on_failure() resulted in a Exception without an actual log message.
Code used:
def data_loader_failure_notify_slack(
task: Task, task_run: TaskRun, state: State, env: str, data_source_name: str
):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
with slack_webhook_block.raise_on_failure():
slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at {datetime.now(ZoneInfo("America/New_York"))}"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject="Data loader failure",
)
print(f"Sent Slack message for data loader '{data_source_name}' failure")
except Exception as e:
print(f"Error sending Slack notification: {e}")
if isinstance(e, NotificationError):
print(f"Apprise NotificationError: {e.log}")
Both print statements under except printed out but had no text/str set on the Exception.Marvin
03/10/2026, 6:37 PMMarvin
03/10/2026, 6:40 PMNotificationError.log was empty, that typically means Apprise returned False without emitting any logs. This can happen if Apprise didn’t add any service from the URL (e.g., URL not recognized/empty) or short-circuited before logging.
- Since you’re on the latest 2.x (with notify_type='info'), the older notify_type issue shouldn’t be the cause.
Targeted checks to pinpoint the issue
Please try these from the same Docker container/image where your worker runs:
1) Verify the block actually has a usable URL at runtime (redact it!)
from prefect.blocks.notifications import SlackWebhook
b = SlackWebhook.load("slack-integration") # or slack-staging in your test
u = b.url.get_secret_value()
print("Webhook URL present:", bool(u))
print("Ends with:", u[-12:]) # redact most of it
print("Contains <http://hooks.slack.com|hooks.slack.com>:", "<http://hooks.slack.com|hooks.slack.com>" in u)
2) Bypass Apprise and hit Slack directly (isolates Apprise vs. network/webhook)
import json, requests
from prefect.blocks.notifications import SlackWebhook
b = SlackWebhook.load("slack-integration") # or your staging name
u = b.url.get_secret_value()
r = <http://requests.post|requests.post>(
u,
data=json.dumps({"text": "Prefect test from worker runtime"}),
headers={"Content-Type": "application/json"},
timeout=10,
)
print("Status:", r.status_code, "Body:", r.text)
- If this returns 200, network + webhook are fine and the issue is specifically with Apprise’s send path.
- If this is non-200, Slack is rejecting the payload or the webhook is invalid/disabled.
3) Try Apprise directly (to see its return and logs outside Prefect’s wrapper)
import logging
from apprise import Apprise
logging.getLogger("apprise").setLevel(logging.DEBUG)
ap = Apprise()
ap.add(u) # from above
ok = ap.notify(body="Test via Apprise", title="Prefect")
print("Apprise notify returned:", ok)
4) Add a bit more logging inside your hook to confirm the URL and Apprise version (redacted)
from apprise import __version__ as apprise_ver
print("Apprise version:", apprise_ver)
print("Webhook tail:", slack_webhook_block.url.get_secret_value()[-12:])
A minimal drop-in workaround (keeps using your existing block)
If step (2) works (200 OK), you can switch your hook to a direct POST so you’re not blocked by Apprise behavior:
import json, requests
from prefect.blocks.notifications import SlackWebhook
def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
b = SlackWebhook.load("slack-integration" if env == "prod" else "slack-staging")
u = b.url.get_secret_value()
payload = {
"text": (
f"Data loader `{data_source_name}` failed at "
f"{datetime.now(ZoneInfo('America/New_York'))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
)
}
try:
r = <http://requests.post|requests.post>(u, data=json.dumps(payload), headers={"Content-Type": "application/json"}, timeout=10)
r.raise_for_status()
print(f"Sent Slack message for data loader '{data_source_name}' failure")
except Exception as e:
print(f"Error sending Slack notification: {e}")
Optional alternative: prefect-slack collection
If you’d rather not hand-roll the POST and want clearer error surfaces:
- Incoming webhook helper: prefect-slack: messages
- You’d create a prefect-slack SlackWebhook block (different type than core) and call send_incoming_webhook_message(...) in the hook.
A couple of clarifications/questions to help me zero in:
- What exact versions are you on? Please share prefect.__version__ and apprise.__version__.
- What are the results of steps (1)–(3) above?Marvin
03/10/2026, 6:40 PMKalyan Yalamanchi
03/10/2026, 6:53 PMMarvin
03/10/2026, 6:53 PMMarvin
03/10/2026, 6:57 PMimport anyio
from datetime import datetime
from zoneinfo import ZoneInfo
from prefect.blocks.notifications import SlackWebhook
from prefect.blocks.abstract import NotificationError
from prefect.runtime import flow_run
async def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
slack_webhook_block: SlackWebhook = SlackWebhook.load(
"slack-integration" if env == "prod" else "slack-staging"
)
try:
# Shield the async send from engine cancellation
with anyio.CancelScope(shield=True):
with slack_webhook_block.raise_on_failure():
await slack_webhook_block.notify(
body=(
f"Data loader `{data_source_name}` failed at "
f"{datetime.now(ZoneInfo('America/New_York'))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject="Data loader failure",
)
print(f"Sent Slack message for data loader '{data_source_name}' failure")
except Exception as e:
import traceback
print(f"Error sending Slack notification: {e!r} ({type(e)})")
traceback.print_exc()
if isinstance(e, NotificationError):
print(f"Apprise NotificationError: {e.log}")
- Prefect 2.x supports async hooks; the engine will await them.
- The shield prevents cancellation from interrupting the notify call.
Option B: Use a synchronous send inside the hook (not cancellable by anyio)
Since both your direct POST and Apprise’s sync notify() work, you can keep the block but post synchronously to bypass any async cancellation:
A1) Using requests.post directly (you already validated this works):
import json, requests
from datetime import datetime
from zoneinfo import ZoneInfo
from prefect.blocks.notifications import SlackWebhook
from prefect.runtime import flow_run
def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):
print(f"Sending Slack message for data loader '{data_source_name}' failure")
b = SlackWebhook.load("slack-integration" if env == "prod" else "slack-staging")
u = b.url.get_secret_value()
payload = {
"text": (
f"Data loader `{data_source_name}` failed at "
f"{datetime.now(ZoneInfo('America/New_York'))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
)
}
try:
r = <http://requests.post|requests.post>(u, data=json.dumps(payload), headers={"Content-Type": "application/json"}, timeout=10)
r.raise_for_status()
print(f"Sent Slack message for data loader '{data_source_name}' failure")
except Exception as e:
print(f"Error sending Slack notification: {e!r}")
A2) Or use Apprise’s sync client directly:
```
from apprise import Apprise
from datetime import datetime
from zoneinfo import ZoneInfo
from prefect.blocks.notifications import SlackWebhook
from prefect.runtime import flow_run
def data_loader_failure_notify_slack(task, task_run, state, env: str, data_source_name: str):Marvin
03/10/2026, 6:57 PM{data_source_name} failed at "
f"{datetime.now(ZoneInfo('America/New_York'))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
title="Data loader failure",
)
print("Apprise sync notify returned:", ok)
Quick confirmation step
To verify cancellation is what you’re seeing, add this in your current except:
import traceback
print("Exception type:", type(e))
traceback.print_exc()
```
If it shows a CancelledError traceback, that confirms it.
If you’re open to it, I recommend Option A (async hook + shield) to keep using the Prefect block API and still be resilient to engine cancellation. Option B is the pragmatic “just send it” approach.
Want me to help turn this into a minimal reproducible example and open a GitHub issue for 2.x hooks cancelling notification sends?Kalyan Yalamanchi
03/10/2026, 6:59 PMMarvin
03/10/2026, 6:59 PMMarvin
03/10/2026, 7:01 PM{data_source_name} failed at "
f"{datetime.now(ZoneInfo('America/New_York'))}\n"
f"Flow run URL: {flow_run.get_flow_run_ui_url()}"
),
subject="Data loader failure",
)
print(f"[async hook] Sent Slack message for data loader '{data_source_name}' failure")
except Exception as e:
import traceback
print(f"[async hook] Error sending Slack notification: {e!r} ({type(e)})")
traceback.print_exc()
if isinstance(e, NotificationError):
print(f"[async hook] Apprise NotificationError logs:\n{e.log}")
@flow(name="demo-async-block-with-shield")
def demo_async_block_with_shield(env: str = "staging", block_name: str = "slack-test"):
# Ensure the block exists (or create it from SLACK_WEBHOOK_URL)
setup_slack_block(block_name)
# Attach the async, shielded hook
h = partial(
slack_failure_hook_async,
env=env,
data_source_name="example-dataloader",
block_name=block_name,
)
failing_task = will_fail.with_options(on_failure=[h])
# Trigger the failure and swallow so the flow completes
try:
failing_task()
except Exception as e:
print(f"[flow] Task failed as expected: {e!r}")
# ------------- Example B: Sync fallback using direct POST (bypasses Apprise) -------------
def slack_failure_hook_sync_direct_post(task, task_run, state, env: str, data_source_name: str, block_name: str):
print(f"[sync hook] Sending Slack message for data loader '{data_source_name}' failure (direct POST)")
slack_block: SlackWebhook = SlackWebhook.load(block_name)
url = slack_block.url.get_secret_value()
payload = {
"text": (
f"Data loader {data_source_name} failed at "Marvin
03/10/2026, 7:01 PMdata_source_name per item like your original, keep using functools.partial like shown.
If you hit any snags running this in your container, paste the stdout from the hook section — especially the exception type/traceback — and I’ll help tweak it.