Error handling idioms
Magic UX errors, ActionResult.error, retry semantics, exception types, audit-clean failures
Every @chat.function handler runs inside an ICNLI Worker activity. The web-kernel records the outcome in the audit ledger and delivers the result to the user's chat session — there is no second chance if you handle failures carelessly. The same message that goes to the user also becomes part of the audit trail. This guide covers every layer of the failure path so you can write handlers that fail gracefully, informatively, and without leaking internals.
| Topic | Section |
|---|---|
| Two failure paths | Paths |
ActionResult.error() reference | Reference |
| Magic UX rule | Magic UX |
| Retryable vs non-retryable | Retry semantics |
| Exception classes | Exceptions |
try/except patterns | Patterns |
| Logging | Logging |
| Validation errors | Validation |
| External API failures | HTTP |
| Store / cache failures | Store |
| Cross-extension calls | IPC |
| Webhook handlers | Webhooks |
| Scheduled jobs | Schedules |
| Event handlers | Events |
| Common pitfalls | Pitfalls |
| Cross-references | See also |
Why error handling matters in extensions
Extension handlers touch real user data, charge the billing ledger, and emit audit entries on every invocation. The web-kernel fails closed: if a handler raises an unhandled exception, the call is marked as failed in the ledger and the user receives a generic error message — the failed state is final. There is no automatic retry for business-logic failures.
Three platform rules make error handling a first-class concern:
- Magic UX (I-MAGIC-UX-1 / I-MAGIC-UX-2) — raw exception strings must never reach the user. The web-kernel intercepts unhandled Pydantic validation errors and formats them as structured prose, but for any other failure the extension is responsible.
- Audit ledger — the
errorfield inActionResultis stored verbatim. Write stable, user-safe strings — not stack traces. - Federal observability — structured logs from
ctx.logare collected in the extension dashboard. Unhandled exceptions produce less useful entries than explicit log + return.
The two failure paths
There are exactly two ways to report a failure from a @chat.function handler:
| Path | When to use | What the user sees |
|---|---|---|
return ActionResult.error(...) | Expected failures — validation, not-found, quota, network errors | Your error string, formatted by the web-kernel |
| Raise an unhandled exception | Truly unexpected bugs only | Generic "something went wrong" — no detail |
Prefer ActionResult.error() for every failure you can anticipate. Reserve unhandled exceptions for genuine programming errors that indicate a bug you want surfaced in logs immediately.
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.errors import NotFoundError
class GetNoteParams(BaseModel):
note_id: str
async def fn_get_note(ctx, params: GetNoteParams) -> ActionResult:
# Graceful path — return ActionResult.error for expected failures
if not params.note_id:
return ActionResult.error("note_id is required")
doc = await ctx.store.get("notes", params.note_id)
if doc is None:
return ActionResult.error(f"Note '{params.note_id}' not found")
return ActionResult.success(
data={"note_id": doc.id, "title": doc.data.get("title", "")},
summary="Note retrieved",
)ActionResult.error() reference
from imperal_sdk import ActionResult
# Factory signature (source: imperal_sdk.types.action_result)
result = ActionResult.error("Something went wrong", retryable=False)Parameters:
| Parameter | Type | Default | Purpose |
|---|---|---|---|
error | str | required | User-facing message. Must not contain raw exception strings, class names, or internal paths. |
retryable | bool | False | When True, the platform surfaces a retry affordance in the UI. |
Wire contract:
statusis always"error"datais always{}— do not try to attach payload to an error resultsummaryis always""— theerrorfield is what the user seesretryableis only serialized whenTrue
When to set retryable=True:
Set retryable=True only when the same call with the same parameters has a reasonable chance of succeeding if retried — typically transient network failures or temporary rate limits. Do not set it for permanent failures such as validation errors, not-found responses, or permission denials.
from pydantic import BaseModel
from imperal_sdk import ActionResult
class FetchInboxParams(BaseModel):
limit: int = 25
async def fn_fetch_inbox(ctx, params: FetchInboxParams) -> ActionResult:
try:
resp = await ctx.http.get(
"https://mail-api.example.com/inbox",
params={"limit": params.limit},
)
except Exception:
# Transient — network timeout, DNS failure, etc.
return ActionResult.error(
"Could not reach the mail service. Please try again.",
retryable=True,
)
if not resp.ok:
if resp.status_code == 429:
# Transient — rate limited
return ActionResult.error(
"Mail API rate limit reached. Please try again in a moment.",
retryable=True,
)
# Permanent — bad credentials, server error, etc.
return ActionResult.error(
f"Mail API returned an error ({resp.status_code})."
)
return ActionResult.success(
data={"messages": resp.json().get("messages", [])},
summary="Inbox loaded",
)The Magic UX rule
Never put raw exception strings in user-facing text
The error field in ActionResult is shown directly to users in the chat interface. Raw Python exception strings (str(e)) often contain class names, internal paths, Pydantic URLs, and PII. Always write a stable, user-friendly message instead.
The Magic UX rule was established as part of the federal platform hardening (2026-05-01). It exists because str(pydantic_e) was surfacing schema URLs and field-path details directly in chat, which violated both privacy expectations and UX quality standards.
Wrong:
from pydantic import BaseModel
from imperal_sdk import ActionResult
class CreateNoteParams(BaseModel):
title: str
async def fn_create_note_bad(ctx, params: CreateNoteParams) -> ActionResult:
try:
result = await ctx.http.post("/notes", json={"title": params.title})
except Exception as e:
# ❌ str(e) may contain internal paths, class names, or PII
return ActionResult.error(str(e))
return ActionResult.success(data={}, summary="Done")Correct:
from pydantic import BaseModel
from imperal_sdk import ActionResult
import logging
log = logging.getLogger(__name__)
class CreateNoteParams(BaseModel):
title: str
async def fn_create_note_good(ctx, params: CreateNoteParams) -> ActionResult:
try:
resp = await ctx.http.post("/notes", json={"title": params.title})
except Exception as exc:
# ✅ Log the raw detail internally; show a stable message to the user
log.error("create_note http failed: %s", exc)
return ActionResult.error(
"Could not create the note. Please try again.",
retryable=True,
)
if not resp.ok:
log.warning("create_note backend error %s", resp.status_code)
return ActionResult.error("Note creation failed. Please check your input.")
note_id = resp.json().get("id", "")
return ActionResult.success(
data={"note_id": note_id, "title": params.title},
summary=f"Note '{params.title}' created",
refresh_panels=["sidebar"],
)The two-step pattern — log.error(...) for operator visibility + ActionResult.error(user-safe message) for the user — is the canonical form throughout production extensions.
Retryable vs non-retryable
The retryable flag signals to the platform that retrying the exact same call is meaningful. When retryable=True, the chat UI renders a retry affordance. When retryable=False (the default), the user sees the error message with no retry button.
| Scenario | retryable |
|---|---|
| Network timeout contacting external API | True |
| HTTP 429 rate limit from external API | True |
| HTTP 503 / 504 from external API | True |
Validation failure (title is required) | False |
| Resource not found (404) | False |
| Permission denied | False |
| Quota exceeded | False |
| Permanent configuration error | False |
The web-kernel does not automatically retry on retryable=True. The flag purely controls whether the platform renders a retry affordance in the UI. You are responsible for idempotency if the user retries.
Exception classes
The SDK exports a hierarchy of exception classes from imperal_sdk.errors. These are raised by SDK clients — you catch them in your handlers. They are never raised directly from ActionResult.
ImperalError (base)
from imperal_sdk.errors import ImperalErrorBase class for all SDK errors. Catch ImperalError to handle any SDK-raised error in a single except block. Fields: message: str, code: str.
APIError
Raised by any ctx.* client that makes an HTTP call to the platform gateway. Fields: message, code, status_code: int.
NotFoundError(APIError)
Raised when a platform resource returns 404. Fields: resource: str, id: str. Subclass of APIError.
RateLimitError(APIError)
Raised on HTTP 429 from the platform gateway. Fields: retry_after: int (seconds). Subclass of APIError.
AuthError
Raised when an operation fails authentication or authorization checks. Code: "auth_error".
ValidationError
Raised by ctx.config.require(key) when a required config key is absent. Fields: field: str.
ExtensionError
Raised for errors within extension execution infrastructure. Fields: app_id: str.
QuotaExceededError
Raised when the billing quota for a resource is exceeded. Fields: resource: str, limit: int.
CircularCallError(ExtensionError)
Raised by ctx.extensions.call() when a circular inter-extension dependency is detected. Fields: call_stack: list[str].
SkeletonAccessForbidden
Raised at runtime when ctx.skeleton.get() is called from any handler that is not decorated with @ext.skeleton. This enforces the I-SKELETON-LLM-ONLY invariant — skeleton data is the LLM classifier's input, not a data source for regular handlers.
from imperal_sdk.errors import SkeletonAccessForbiddenIf you receive this error in tests, you are calling ctx.skeleton.get() from a tool, panel, or chat function. Move the read to a dedicated @ext.skeleton handler instead.
TaskCancelled
from imperal_sdk.chat import TaskCancelledRaised by await ctx.progress(...) when the user cancels a long-running task. It is the only exception type you should routinely catch in @chat.function handlers.
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.chat import TaskCancelled
import logging
log = logging.getLogger(__name__)
class ExportParams(BaseModel):
format: str = "csv"
async def fn_export_data(ctx, params: ExportParams) -> ActionResult:
try:
rows = await ctx.store.query("records")
await ctx.progress(25, "Fetched records…")
processed = []
for i, row in enumerate(rows.data):
processed.append(row.data)
if i % 50 == 0:
await ctx.progress(25 + int(50 * i / max(len(rows.data), 1)), "Processing…")
await ctx.progress(90, "Formatting output…")
# ... format and upload
except TaskCancelled:
# ✅ User cancelled — return a clean graceful message
return ActionResult.error("Export cancelled")
except Exception as exc:
log.error("export failed: %s", exc)
return ActionResult.error(
"Export failed. Please try again.",
retryable=True,
)
await ctx.progress(100, "Done")
return ActionResult.success(
data={"row_count": len(processed)},
summary=f"Exported {len(processed)} records",
)Do not re-raise TaskCancelled. Return an ActionResult.error with a neutral message like "Cancelled" or "Export cancelled".
try/except patterns
Pattern A — single external call
The most common pattern: one external call, specific error translation.
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.errors import RateLimitError, APIError
import logging
log = logging.getLogger(__name__)
class CreateTaskParams(BaseModel):
title: str
project_id: int
async def fn_create_task(ctx, params: CreateTaskParams) -> ActionResult:
try:
resp = await ctx.http.post(
"https://tasks.internal/tasks",
json={"title": params.title, "project_id": params.project_id},
)
except RateLimitError as e:
return ActionResult.error(
f"Task service is rate-limited. Retry in {e.retry_after}s.",
retryable=True,
)
except Exception as exc:
log.error("create_task failed: %s", exc)
return ActionResult.error(
"Could not reach the task service. Please try again.",
retryable=True,
)
if not resp.ok:
return ActionResult.error(
_translate_task_error(resp.json().get("detail", "Task creation failed"))
)
task_id = resp.json().get("id")
return ActionResult.success(
data={"task_id": task_id, "title": params.title},
summary=f"Task '{params.title}' created",
refresh_panels=["sidebar"],
)
def _translate_task_error(detail: str) -> str:
"""Translate backend error details into user-friendly messages."""
if "project not found" in detail.lower():
return "Project not found. Please select a valid project."
if "duplicate" in detail.lower():
return "A task with this title already exists in the project."
# Fall back to a generic message — do not surface raw backend detail
return "Task creation failed. Please check your input."The _translate_* helper pattern (seen in the tasks and sql-db extensions) keeps the error translation logic reusable and testable, and ensures you never accidentally forward a raw backend message to the user.
Pattern B — guard clause early exit
For validation that precedes any I/O, check and return before the try block.
from pydantic import BaseModel, Field
from imperal_sdk import ActionResult
class UpdateNoteParams(BaseModel):
note_id: str
title: str = Field(default="")
content: str = Field(default="")
async def fn_update_note(ctx, params: UpdateNoteParams) -> ActionResult:
# Guard clauses — fast exits before any I/O
if not params.note_id:
return ActionResult.error("note_id is required")
if not params.title and not params.content:
return ActionResult.error("At least one of title or content is required")
# Only reach I/O after guards pass
try:
resp = await ctx.http.patch(
f"/notes/{params.note_id}",
json={"title": params.title, "content": params.content},
)
except Exception:
return ActionResult.error("Note update failed. Please try again.", retryable=True)
if not resp.ok:
if resp.status_code == 404:
return ActionResult.error(f"Note '{params.note_id}' not found")
return ActionResult.error("Note update failed")
return ActionResult.success(
data={"note_id": params.note_id},
summary="Note updated",
refresh_panels=["sidebar", "editor"],
)Pattern C — multi-step with partial failure
When a function performs several operations and a later step fails, decide whether to roll back or report partial success explicitly.
from pydantic import BaseModel
from imperal_sdk import ActionResult
import base64
import logging
log = logging.getLogger(__name__)
class UploadAttachmentParams(BaseModel):
note_id: str
filename: str
data_base64: str
async def fn_upload_attachment(ctx, params: UploadAttachmentParams) -> ActionResult:
if not params.note_id:
return ActionResult.error("note_id is required")
if not params.data_base64:
return ActionResult.error("No file data received")
# Step 1: decode — non-retryable, user input error
try:
raw_bytes = base64.b64decode(params.data_base64)
except Exception:
return ActionResult.error("Invalid file data (base64 decode failed)")
# Step 2: upload — potentially transient
try:
file_info = await ctx.storage.upload(
f"attachments/{params.note_id}/{params.filename}",
raw_bytes,
)
except Exception as exc:
log.error("attachment upload failed: %s", exc)
return ActionResult.error(
"File upload failed. Please try again.",
retryable=True,
)
# Step 3: link to note — could fail without affecting the upload
try:
await ctx.store.update("notes", params.note_id, {
"attachments": [{"path": file_info.path, "name": params.filename}],
})
except Exception as exc:
log.warning("attachment link failed (file uploaded): %s", exc)
# File is uploaded but not linked — surface this to the user
return ActionResult.error(
"File uploaded but could not be linked to the note. "
"Please refresh and try attaching again."
)
return ActionResult.success(
data={"path": file_info.path, "filename": params.filename},
summary=f"Attached '{params.filename}'",
refresh_panels=["editor"],
)Logging vs returning
Use both together: log for operator visibility, return for the user.
| Situation | What to do |
|---|---|
| Transient external failure | log.error(...) + return ActionResult.error(..., retryable=True) |
| Expected business failure (not-found, validation) | return ActionResult.error(...) — no log needed unless debugging |
| Unexpected exception | log.error(...) + return ActionResult.error("unexpected error") |
| Slow path or suspicious input | log.warning(...) — no error returned if handled |
ctx.log is the structured logging surface that routes to the extension dashboard:
from pydantic import BaseModel
from imperal_sdk import ActionResult
class RunQueryParams(BaseModel):
query: str
async def fn_run_query(ctx, params: RunQueryParams) -> ActionResult:
await ctx.log(f"run_query called: user={ctx.user.imperal_id}", level="info")
try:
result = await ctx.http.post("/query", json={"q": params.query})
except Exception as exc:
await ctx.log(f"run_query failed: {exc}", level="error")
return ActionResult.error("Query failed. Please try again.", retryable=True)
return ActionResult.success(
data={"rows": result.json().get("rows", [])},
summary="Query completed",
)ctx.log(message, level="info") accepts "debug", "info", "warning", "error", "critical". Standard library logging.getLogger(__name__) also works and is more common in production extensions — both routes are visible in the extension dashboard.
ctx.log is an async def — remember to await it. Standard logging.getLogger(...) calls are synchronous and do not require await.
Validation errors
The SDK's Pydantic feedback loop (SDK v4.1.0+) handles PydanticValidationError automatically when the LLM supplies malformed arguments to a typed @chat.function. The web-kernel retries up to two times with structured prose feedback to the LLM — you do not need to catch PydanticValidationError yourself.
What this means in practice:
- Do not wrap the entire handler body in
try: ... except PydanticValidationError. - Do use Pydantic validators on your params model to encode business rules — they fire before your handler body.
- Do return
ActionResult.error(...)for validation that cannot be expressed in Pydantic (cross-field validation, database-state-dependent validation).
from pydantic import BaseModel, Field, model_validator
from imperal_sdk import ActionResult
from typing import Optional
class CreateNoteParams(BaseModel):
title: str = Field(..., min_length=1, max_length=500)
folder_id: Optional[str] = None
is_pinned: bool = False
@model_validator(mode="after")
def title_not_whitespace(self) -> "CreateNoteParams":
if not self.title.strip():
raise ValueError("title must not be blank")
return self
async def fn_create_note(ctx, params: CreateNoteParams) -> ActionResult:
# By here, Pydantic has already enforced title length and non-blank.
# Only handle runtime / database-state validation manually.
if params.folder_id:
folder = await ctx.store.get("folders", params.folder_id)
if folder is None:
return ActionResult.error(
f"Folder '{params.folder_id}' not found. "
"List your folders to find the correct ID."
)
doc = await ctx.store.create("notes", {
"title": params.title.strip(),
"folder_id": params.folder_id,
"is_pinned": params.is_pinned,
})
return ActionResult.success(
data={"note_id": doc.id, "title": doc.data["title"]},
summary=f"Created note '{doc.data['title']}'",
refresh_panels=["sidebar"],
)External API failures
External API calls via ctx.http produce HTTPResponse objects. Inspect .ok and .status_code before reading .json().
from pydantic import BaseModel
from imperal_sdk import ActionResult
from imperal_sdk.types import HTTPResponse
import logging
log = logging.getLogger(__name__)
class ListCampaignsParams(BaseModel):
account_id: str
async def fn_list_campaigns(ctx, params: ListCampaignsParams) -> ActionResult:
try:
resp: HTTPResponse = await ctx.http.get(
f"/accounts/{params.account_id}/campaigns",
)
except Exception as exc:
log.error("list_campaigns transport error: %s", exc)
return ActionResult.error(
"Could not reach the campaigns service. Please try again.",
retryable=True,
)
if resp.status_code == 404:
return ActionResult.error(f"Account '{params.account_id}' not found")
if resp.status_code == 401:
return ActionResult.error(
"Authentication failed. Please reconnect your account in settings."
)
if resp.status_code == 429:
return ActionResult.error(
"API rate limit reached. Please try again in a few minutes.",
retryable=True,
)
if not resp.ok:
log.warning("list_campaigns non-OK: %s", resp.status_code)
return ActionResult.error(
f"Campaigns API returned an error ({resp.status_code}). "
"Please try again.",
retryable=resp.status_code >= 500,
)
campaigns = resp.json().get("campaigns", [])
return ActionResult.success(
data={"campaigns": campaigns, "total": len(campaigns)},
summary=f"Found {len(campaigns)} campaigns",
)Circuit breaker pattern — for extensions that call the same upstream service from multiple handlers, centralize the health check in a helper rather than duplicating try/except blocks:
from imperal_sdk.types import HTTPResponse
from imperal_sdk import ActionResult
import logging
log = logging.getLogger(__name__)
async def api_call(ctx, method: str, path: str, **kwargs) -> HTTPResponse:
"""Central HTTP wrapper. Raises on transport error; returns HTTPResponse otherwise."""
try:
fn = getattr(ctx.http, method)
return await fn(path, **kwargs)
except Exception as exc:
log.error("api_call %s %s failed: %s", method.upper(), path, exc)
raise
def is_no_connection_error(resp: dict) -> bool:
"""Return True when the bridge indicates the user has no account connected."""
detail = resp.get("detail", "")
return isinstance(detail, str) and "connect" in detail.lower()This pattern (directly from the tasks extension's app.py) keeps transport error handling in one place and lets individual handlers focus on business logic.
Store and cache failures
ctx.store and ctx.cache can fail if the platform backend is temporarily unavailable. Both are async and can raise APIError or generic Exception.
from pydantic import BaseModel, Field
from imperal_sdk import ActionResult
from imperal_sdk.errors import APIError
import logging
log = logging.getLogger(__name__)
class SaveSettingsParams(BaseModel):
theme: str = Field(default="system")
notifications_enabled: bool = True
async def fn_save_settings(ctx, params: SaveSettingsParams) -> ActionResult:
settings_data = {
"theme": params.theme,
"notifications_enabled": params.notifications_enabled,
}
try:
existing = await ctx.store.get("settings", "user_prefs")
if existing:
await ctx.store.update("settings", "user_prefs", settings_data)
else:
await ctx.store.create("settings", settings_data)
except APIError as exc:
log.error("save_settings store error %s: %s", exc.status_code, exc.message)
return ActionResult.error(
"Settings could not be saved. Please try again.",
retryable=True,
)
except Exception as exc:
log.error("save_settings unexpected: %s", exc)
return ActionResult.error("Settings could not be saved.")
return ActionResult.success(
data=settings_data,
summary="Settings saved",
refresh_panels=["settings"],
)For ctx.cache, a miss returns None — that is not an error. Only catch exceptions from transport failures:
from pydantic import BaseModel
from imperal_sdk import ActionResult
import logging
log = logging.getLogger(__name__)
class InboxSummary(BaseModel):
"""Registered via @ext.cache_model("inbox_summary") in app.py."""
items: list = []
class ListItemsParams(BaseModel):
force_refresh: bool = False
async def fn_list_items(ctx, params: ListItemsParams) -> ActionResult:
# Cache miss is expected and normal — not an error
cached = None
if not params.force_refresh:
try:
cached = await ctx.cache.get("inbox_summary", InboxSummary)
except Exception as exc:
# Cache transport error — degrade gracefully, fetch live
log.warning("cache get failed, fetching live: %s", exc)
if cached is None:
try:
resp = await ctx.http.get("/inbox")
except Exception as exc:
log.error("inbox fetch failed: %s", exc)
return ActionResult.error(
"Could not load inbox. Please try again.",
retryable=True,
)
items = resp.json().get("items", [])
else:
items = cached.items
return ActionResult.success(
data={"items": items},
summary=f"Loaded {len(items)} items",
)Cross-extension call failures
ctx.extensions.call(app_id, method, **kwargs) can raise several exceptions:
| Exception | Cause |
|---|---|
CircularCallError | Call chain creates a cycle (A → B → A) |
NotFoundError | Target extension or method does not exist |
AuthError | Caller lacks required scope |
ExtensionError | Call depth exceeds the platform limit (8 levels) |
from pydantic import BaseModel
from imperal_sdk import ActionResult, CircularCallError
from imperal_sdk.errors import NotFoundError, AuthError, ExtensionError
import logging
log = logging.getLogger(__name__)
class SendWithAttachmentParams(BaseModel):
note_id: str
recipient: str
async def fn_send_note_as_email(ctx, params: SendWithAttachmentParams) -> ActionResult:
# Fetch note content via cross-extension call
try:
note = await ctx.extensions.call("notes", "get_note_content", note_id=params.note_id)
except NotFoundError:
return ActionResult.error(
f"Note '{params.note_id}' not found. Cannot send."
)
except AuthError:
return ActionResult.error(
"This extension does not have permission to read notes."
)
except CircularCallError as exc:
log.error("circular extension call: %s", exc)
return ActionResult.error("Internal configuration error. Please contact support.")
except ExtensionError as exc:
log.error("extensions.call failed: %s", exc)
return ActionResult.error("Could not load note content. Please try again.", retryable=True)
# Continue with send logic...
return ActionResult.success(
data={"sent": True},
summary=f"Note sent to {params.recipient}",
)Webhook failures
Webhook handlers receive (ctx, headers, body, query_params) and must return an ActionResult or raise. If your handler returns ActionResult.error(...), the platform responds with HTTP 200 but records the failure in the audit ledger. To signal HTTP-level failure to the caller (e.g., for webhook retry mechanisms), raise an exception — but do so only when you intend the caller to retry.
from pydantic import BaseModel
from imperal_sdk import Extension, ActionResult
import hmac, hashlib, logging
ext = Extension(
"my-ext",
display_name="My Extension",
description="Example webhook handler with HMAC verification.",
actions_explicit=True,
)
log = logging.getLogger(__name__)
WEBHOOK_SECRET = "loaded-from-env"
@ext.webhook("/events", secret_header="X-Webhook-Signature")
async def on_webhook(ctx, headers: dict, body: str, query_params: dict) -> ActionResult:
# Verify HMAC signature — your responsibility; SDK provides no helper
sig = headers.get("x-webhook-signature", "")
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), body.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
# Return ActionResult.error for invalid signature
log.warning("webhook: invalid signature from %s", headers.get("host"))
return ActionResult.error("Invalid webhook signature")
try:
payload = __import__("json").loads(body)
except Exception:
return ActionResult.error("Invalid JSON payload")
event_type = payload.get("type", "unknown")
log.info("webhook received event: %s", event_type)
# Process event...
return ActionResult.success(data={"event_type": event_type}, summary="Webhook processed")HMAC verification is your responsibility
The SDK provides no built-in HMAC helper. You must verify signatures yourself using hmac.compare_digest — never string equality — to avoid timing attacks.
Scheduled job failures
@ext.schedule handlers run with a system context (ctx.user.imperal_id == "__system__"). They fan out to per-user contexts via ctx.store.list_users() + ctx.as_user(uid). Failures in per-user processing must be isolated — one user's failure must not abort the job for all other users.
from imperal_sdk import Extension, ActionResult
import logging
ext = Extension(
"my-ext",
display_name="My Extension",
description="Example scheduled job with per-user isolation.",
actions_explicit=True,
)
log = logging.getLogger(__name__)
@ext.schedule("daily_digest", cron="0 9 * * *")
async def run_daily_digest(ctx) -> None:
"""Send daily digest to all active users."""
errors = []
async for user_id in ctx.store.list_users("preferences"):
user_ctx = ctx.as_user(user_id)
try:
await _send_digest_for_user(user_ctx)
except Exception as exc:
# Isolate — log the failure but continue to next user
log.error("digest failed for user %s: %s", user_id, exc)
errors.append(user_id)
if errors:
log.warning("digest completed with %d failures: %s", len(errors), errors[:5])
else:
log.info("digest completed successfully")
async def _send_digest_for_user(ctx) -> None:
prefs = await ctx.store.get("preferences", "digest")
if prefs is None or not prefs.data.get("enabled"):
return
# ... fetch and send
await ctx.notify("Your daily digest is ready", priority="low")Scheduled handlers do not return ActionResult — they return None. There is no user-facing error surface. All failure reporting goes through logging.
Event handler failures
@ext.on_event handlers are fire-and-forget: the platform dispatches events asynchronously and does not retry on failure. The context available in event handlers is minimal — ctx.http, ctx.store, and ctx.notify are typically available, but ctx.ai may not be, depending on the event routing path.
from imperal_sdk import Extension
import logging
ext = Extension(
"my-ext",
display_name="My Extension",
description="Example event handler with graceful degradation.",
actions_explicit=True,
)
log = logging.getLogger(__name__)
@ext.on_event("notes.created")
async def on_note_created(ctx, event) -> None:
"""React to a note creation event.
Failures here are silent to the user — log everything.
No retry will be issued.
"""
try:
note_id = event.data.get("note_id")
if not note_id:
log.warning("on_note_created: event missing note_id, skipping")
return
# Attempt secondary action — cache invalidation, indexing, etc.
await ctx.store.update("indexes", "notes_count", {"invalidated": True})
except Exception as exc:
# Must not raise — that would silently drop the event
log.error("on_note_created handler error: %s", exc)Never raise from @ext.on_event
Unhandled exceptions in event handlers are swallowed by the platform and produce no user-visible feedback. Always catch broadly and log. Do not attempt rollback or compensating actions from event handlers — use @ext.on_install or write-path handlers for that.
Common pitfalls
Pitfall 1: leaking str(e) to the user
from imperal_sdk import ActionResult
import logging
log = logging.getLogger(__name__)
async def _bad_example(ctx, e: Exception) -> ActionResult:
# ❌ Wrong — exposes internal exception details
return ActionResult.error(str(e))
async def _good_example(ctx, e: Exception) -> ActionResult:
# ✅ Correct — stable user-facing message
log.error("operation failed: %s", e)
return ActionResult.error("Operation failed. Please try again.", retryable=True)The mail extension has several instances of ActionResult.error(str(e), retryable=True) in its handlers. This pattern is acceptable when e is an SDK exception type whose message is already user-friendly — but for third-party library exceptions or raw Exception, always wrap the message.
Pitfall 2: swallowing errors silently
from imperal_sdk import ActionResult
import logging
log = logging.getLogger(__name__)
async def _bad_update(ctx, item_id: str, data: dict) -> None:
# ❌ Wrong — failure is invisible
try:
await ctx.store.update("items", item_id, data)
except Exception:
pass # silently ignored
async def _good_update(ctx, item_id: str, data: dict) -> ActionResult:
# ✅ Correct — log and surface
try:
await ctx.store.update("items", item_id, data)
except Exception as exc:
log.error("update failed for %s: %s", item_id, exc)
return ActionResult.error("Update failed. Please try again.", retryable=True)
return ActionResult.success(data={}, summary="Updated")Pitfall 3: bare except
from imperal_sdk import ActionResult
async def _bad_bare_except(ctx) -> ActionResult:
# ❌ Wrong — catches BaseException including KeyboardInterrupt, SystemExit
try:
pass # your operation here
except: # noqa: E722
return ActionResult.error("Failed")
return ActionResult.success(data={}, summary="Done")
async def _good_except(ctx) -> ActionResult:
# ✅ Correct — catch Exception only
try:
pass # your operation here
except Exception:
return ActionResult.error("Failed")
return ActionResult.success(data={}, summary="Done")Pitfall 4: raising in @ext.on_event
from imperal_sdk import Extension
import logging
ext = Extension(
"mail-ext",
display_name="Mail",
description="Example showing correct event handler error handling.",
actions_explicit=True,
)
log = logging.getLogger(__name__)
async def _process_mail_stub(event: object) -> bool:
return True # placeholder
# ❌ Wrong — exception is swallowed, produces no feedback
@ext.on_event("mail.received.bad")
async def on_mail_bad(ctx, event) -> None: # type: ignore[no-untyped-def]
result = await _process_mail_stub(event)
if not result:
raise RuntimeError("processing failed") # silently lost
# ✅ Correct — log and return
@ext.on_event("mail.received")
async def on_mail(ctx, event) -> None: # type: ignore[no-untyped-def]
try:
result = await _process_mail_stub(event)
if not result:
log.warning("on_mail: processing returned empty result")
except Exception as exc:
log.error("on_mail handler error: %s", exc)Pitfall 5: calling ctx.skeleton.get() outside @ext.skeleton
from pydantic import BaseModel
from imperal_sdk import Extension, ChatExtension, ActionResult
ext = Extension(
"notes-ext",
display_name="Notes",
description="Example showing correct skeleton access patterns.",
actions_explicit=True,
)
chat = ChatExtension(
ext,
tool_name="tool_notes_chat",
description="AI chat interface for the notes extension",
)
class SearchParams(BaseModel):
query: str
# ❌ Wrong — raises SkeletonAccessForbidden at runtime (do not run this)
# @chat.function("search_notes_bad", description="Search notes — wrong pattern",
# action_type="read")
# async def fn_search_bad(ctx, params: SearchParams) -> ActionResult:
# schema = await ctx.skeleton.get("notes") # SkeletonAccessForbidden!
# ...
# ✅ Correct — read live data from ctx.store or ctx.http
@chat.function(
"search_notes",
description="Search notes by title using live store data",
action_type="read",
)
async def fn_search(ctx, params: SearchParams) -> ActionResult:
# Use ctx.store for live data; skeleton is for the LLM classifier only
results = await ctx.store.query("notes", where={"title": params.query})
return ActionResult.success(
data={"results": [d.data for d in results.data]},
summary=f"Found {len(results.data)} notes",
)Pitfall 6: returning error from @ext.skeleton
Skeleton handlers should degrade to empty/zero values on failure, not return error shapes. The web-kernel feeds skeleton data to the classifier — an unexpected shape breaks the classification step.
from imperal_sdk import Extension, ActionResult
import logging
ext = Extension(
"notes-ext",
display_name="Notes",
description="Example showing correct skeleton failure handling.",
actions_explicit=True,
)
log = logging.getLogger(__name__)
# ❌ Wrong — skeleton returning ActionResult.error shape
@ext.skeleton("notes_bad", ttl=300)
async def skeleton_refresh_notes_bad(ctx) -> dict:
try:
pass # fetch data
except Exception:
return ActionResult.error("failed").to_dict() # wrong shape!
return {"response": {}}
# ✅ Correct — return safe zero values
@ext.skeleton("notes", ttl=300)
async def skeleton_refresh_notes(ctx) -> dict:
count = 0
try:
count = await ctx.store.count("notes")
return {"response": {"total": count}}
except Exception as exc:
log.error("skeleton refresh failed: %s", exc)
return {"response": {"total": 0, "recent": []}}See also
Testing extensions
MockContext, [ActionResult](/en/reference/glossary/) assertions, testing error paths
Building extensions
@chat.function action_type, confirmation gate, effects
Pydantic typed args
How the Pydantic feedback loop handles malformed LLM params
Audit and security
Audit ledger, PII, OWASP-relevant patterns