st-error-boundary
English | 日本語
A minimal, type-safe error boundary library for Streamlit applications with pluggable hooks and safe fallback UI.
Motivation
Streamlit's default behavior displays detailed stack traces in the browser when exceptions occur. While client.showErrorDetails = "none" prevents information leakage, it shows only generic error messages, leaving users confused. The typical solution—scattering st.error() and st.stop() calls throughout your code—severely degrades readability and maintainability, and creates a risk of forgetting exception handling in critical places.
This library solves the problem with the decorator pattern: a single "last line of defense" decorator that separates exception handling (cross-cutting concern) from business logic. Just decorate your main function, and all unhandled exceptions are caught and displayed with user-friendly messages—no need to pollute your code with error handling boilerplate everywhere.
This pattern is extracted from production use and open-sourced to help others build robust Streamlit applications without sacrificing code clarity. For the full architectural context, see the PyConJP 2025 presentation.
In customer-facing and regulated environments, an unhandled exception that leaks internals isn’t just noisy—it can be a business incident. You want no stack traces in the UI, but rich, sanitized telemetry behind the scenes.
Who is this for?
Teams shipping customer-facing Streamlit apps (B2B/B2C, regulated or enterprise settings) where you want no stack traces in the UI, but rich telemetry in your logs/alerts. The boundary provides a consistent, user-friendly fallback while on_error sends sanitized details to your observability stack.
Features
- Minimal API: Just two required arguments (
on_errorandfallback) - PEP 561 Compatible: Ships with
py.typedfor full type checker support - Callback Protection: Protect both decorated functions and widget callbacks (
on_click,on_change, etc.) - Pluggable Hooks: Execute side effects (audit logging, metrics, notifications) when errors occur
- Safe Fallback UI: Display user-friendly error messages instead of tracebacks
Installation
pip install st-error-boundary
Quick Start
Basic Usage (Decorator Only)
For simple cases where you only need to protect the main function:
import streamlit as st
from st_error_boundary import ErrorBoundary
# Create error boundary
boundary = ErrorBoundary(
on_error=lambda exc: print(f"Error logged: {exc}"),
fallback="An error occurred. Please try again later."
)
@boundary.decorate
def main() -> None:
st.title("My App")
if st.button("Trigger Error"):
raise ValueError("Something went wrong")
if __name__ == "__main__":
main()
⚠️ Important: The @boundary.decorate decorator alone does not protect on_click/on_change callbacks—you must use boundary.wrap_callback() for those (see Advanced Usage below).
Advanced Usage (With Callbacks)
To protect both decorated functions and widget callbacks:
import streamlit as st
from st_error_boundary import ErrorBoundary
def audit_log(exc: Exception) -> None:
# Log to monitoring service
print(f"Error: {exc}")
def fallback_ui(exc: Exception) -> None:
st.error("An unexpected error occurred.")
st.link_button("Contact Support", "https://example.com/support")
if st.button("Retry"):
st.rerun()
# Single ErrorBoundary instance for DRY configuration
boundary = ErrorBoundary(on_error=audit_log, fallback=fallback_ui)
def handle_click() -> None:
# This will raise an error
result = 1 / 0
@boundary.decorate
def main() -> None:
st.title("My App")
# Protected: error in if statement
if st.button("Direct Error"):
raise ValueError("Error in main function")
# Protected: error in callback
st.button("Callback Error", on_click=boundary.wrap_callback(handle_click))
if __name__ == "__main__":
main()
Comparison: Traditional try/except vs ErrorBoundary (raise-only)
Traditional: scatter try/except + st.error() / st.stop() in each screen
from __future__ import annotations
import streamlit as st
def save_profile(name: str) -> None:
if not name:
raise ValueError("Name is required")
# ... persist ...
def main() -> None:
st.title("Profile")
name: str = st.text_input("Name", "")
if st.button("Save"):
try:
save_profile(name)
st.success("Saved!")
except ValueError as exc:
st.error(f"Input error: {exc}")
st.stop() # stop to avoid broken UI after errors
if __name__ == "__main__":
main()
- Drawbacks: exception handling is duplicated and easy to forget, readability suffers, and a cross-cutting concern leaks into business logic.
ErrorBoundary: just raise, and let the boundary handle UI + hooks
from __future__ import annotations
import streamlit as st
from st_error_boundary import ErrorBoundary
def audit_log(exc: Exception) -> None:
# send to your telemetry/metrics
print(f"[audit] {exc!r}")
def fallback_ui(exc: Exception) -> None:
st.error("An unexpected error occurred. Please try again later.")
if st.button("Retry"):
st.rerun()
boundary: ErrorBoundary = ErrorBoundary(
on_error=audit_log,
fallback=fallback_ui, # string is also allowed; it is rendered via st.error() internally
)
def save_profile(name: str) -> None:
if not name:
raise ValueError("Name is required")
# ... persist ...
@boundary.decorate
def main() -> None:
st.title("Profile")
name: str = st.text_input("Name", "")
if st.button("Save"):
# No local try/except: domain errors bubble to the boundary
save_profile(name)
if __name__ == "__main__":
main()
- Benefits: single place for error UI and side effects; business code stays clean. When
fallbackis a string, it is shown withst.error()internally (use a callable for custom UI). - Callbacks (
on_click/on_change) run outside the decorated scope—wrap them withboundary.wrap_callback(...). - Control-flow exceptions (
st.rerun(),st.stop()) pass through boundaries; keep using them intentionally as needed (you don't needst.stop()as error handling boilerplate).
When to still use local try/except
- You want to recover inline (e.g., fix input and continue).
- You need fine-grained branching UI for a specific, expected exception.
- You implement a local retry for an external API and intentionally swallow the exception.
Why ErrorBoundary Class?
Streamlit executes on_click and on_change callbacks before the script reruns, meaning they run outside the decorated function's scope. This is why @boundary.decorate alone cannot catch callback errors.
Execution Flow:
- User clicks button with
on_click=callback - Streamlit executes
callback()-> Not protected by decorator - Streamlit reruns the script
- Decorated function executes -> Protected by decorator
Solution: Use boundary.wrap_callback() to explicitly wrap callbacks with the same error handling logic.
API Reference
ErrorBoundary
ErrorBoundary(
on_error: ErrorHook | Iterable[ErrorHook],
fallback: str | FallbackRenderer
)
Parameters:
on_error: Single hook or list of hooks for side effects (logging, metrics, etc.)fallback: Either a string (displayed viast.error()) or a callable that renders custom UI- When
fallbackis astr, it is rendered usingst.error()internally - To customize rendering (e.g., use
st.warning()or custom widgets), pass aFallbackRenderercallable instead
- When
Methods:
.decorate(func): Decorator to wrap a function with error boundary.wrap_callback(callback): Wrap a widget callback (on_click, on_change, etc.)
ErrorHook Protocol
def hook(exc: Exception) -> None:
"""Handle exception with side effects."""
...
FallbackRenderer Protocol
def renderer(exc: Exception) -> None:
"""Render fallback UI for the exception."""
...
Examples
Multiple Hooks
def log_error(exc: Exception) -> None:
logging.error(f"Error: {exc}")
def send_metric(exc: Exception) -> None:
metrics.increment("app.errors")
boundary = ErrorBoundary(
on_error=[log_error, send_metric], # Hooks execute in order
fallback="An error occurred."
)
Custom Fallback UI
def custom_fallback(exc: Exception) -> None:
st.error(f"Error: {type(exc).__name__}")
st.warning("Please try again or contact support.")
col1, col2 = st.columns(2)
with col1:
if st.button("Retry"):
st.rerun()
with col2:
st.link_button("Report Bug", "https://example.com/bug-report")
boundary = ErrorBoundary(on_error=lambda _: None, fallback=custom_fallback)
Important Notes
Callback Error Rendering Position
TL;DR: Errors in callbacks appear at the top of the page, not near the widget. Use the deferred rendering pattern (below) to control error position.
When using wrap_callback(), errors in widget callbacks (on_click, on_change) are rendered at the top of the page instead of near the widget. This is a Streamlit architectural limitation.
Deferred Rendering Pattern
Store errors in session_state during callback execution, then render them during main script execution:
import streamlit as st
from st_error_boundary import ErrorBoundary
# Initialize session state
if "error" not in st.session_state:
st.session_state.error = None
# Store error instead of rendering it
boundary = ErrorBoundary(
on_error=lambda exc: st.session_state.update(error=str(exc)),
fallback=lambda _: None # Silent - defer to main script
)
def trigger_error():
raise ValueError("Error in callback!")
# Main app
st.button("Click", on_click=boundary.wrap_callback(trigger_error))
# Render error after the button
if st.session_state.error:
st.error(f"Error: {st.session_state.error}")
if st.button("Clear"):
st.session_state.error = None
st.rerun()
Result: Error appears below the button instead of at the top.
For more details, see Callback Rendering Position Guide.
Nested ErrorBoundary Behavior
When ErrorBoundary instances are nested (hierarchical), the following rules apply:
-
Inner boundary handles first (first-match wins)
- The innermost boundary that catches the exception handles it.
-
Only inner hooks execute
- When the inner boundary handles an exception, only the inner boundary's hooks are called. Outer boundary hooks are NOT executed.
-
Fallback exceptions bubble up
- If the inner boundary's fallback raises an exception, that exception propagates to the outer boundary. The outer boundary then handles it (by design, fallback bugs are not silently ignored).
-
Control flow exceptions pass through
- Streamlit control flow exceptions (
st.rerun(),st.stop()) pass through all boundaries without being caught.
- Streamlit control flow exceptions (
-
Same rules for callbacks
wrap_callback()follows the same nesting rules—the innermost boundary wrapping the callback handles exceptions.
Example: Inner Boundary Handles
outer = ErrorBoundary(on_error=outer_hook, fallback="OUTER")
inner = ErrorBoundary(on_error=inner_hook, fallback="INNER")
@outer.decorate
def main():
@inner.decorate
def section():
raise ValueError("boom")
section()
Result:
INNERfallback is displayed- Only
inner_hookis called (notouter_hook)
Example: Fallback Exception Bubbles
def bad_fallback(exc: Exception):
raise RuntimeError("fallback failed")
outer = ErrorBoundary(on_error=outer_hook, fallback="OUTER")
inner = ErrorBoundary(on_error=inner_hook, fallback=bad_fallback)
@outer.decorate
def main():
@inner.decorate
def section():
raise ValueError("boom")
section()
Result:
OUTERfallback is displayed (inner fallback raised exception)- Both
inner_hookandouter_hookare called (inner first, then outer)
Best Practice
- Inner fallback: Render UI and finish (don't raise). This keeps errors isolated.
- Outer fallback: If you want outer boundaries to handle certain errors, explicitly
raisefrom the inner fallback.
Test Coverage
All nested boundary behaviors are verified by automated tests.
See tests/test_integration.py for implementation details.
Development
# Install dependencies
make install
# Install pre-commit hooks (recommended)
make install-hooks
# Run linting and type checking
make
# Run tests
make test
# Run example app
make example
# Run demo
make demo
Pre-commit Hooks
This project uses pre-commit to automatically run code quality checks before each commit:
- Code Formatting: ruff format
- Linting: ruff check
- Type Checking: mypy and pyright
- Tests: pytest
- Other Checks: trailing whitespace, end-of-file, YAML/TOML validation
Setup:
# Install pre-commit hooks (one-time setup)
make install-hooks
After installation, the hooks will run automatically on git commit. To run manually:
# Run on all files
uv run pre-commit run --all-files
# Skip hooks for a specific commit (not recommended)
git commit --no-verify
License
MIT
Contributing
Contributions are welcome! Please open an issue or submit a pull request.
Metadata
Release files for st-error-boundary 0.1.8
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| st_error_boundary-0.1.8.tar.gz | 96.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| st_error_boundary-0.1.8-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 105.8 kB
Release files / st_error_boundary-0.1.8.tar.gz
| Download URL | st_error_boundary-0.1.8.tar.gz |
|---|---|
| Size | 96.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b0aaa77af3521221b5cfe898cf82f765f30cc7f2729cb9cdf7ab4f0619fde5d1
|
|
BLAKE2b-256 checksum How to use checksums |
76883935e6bc245c89186a4798d3bc60b4f81a4ca4d4dcd0a24f38caf55adbf3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2025.
Transparency logRelease files / st_error_boundary-0.1.8-py3-none-any.whl
| Download URL | st_error_boundary-0.1.8-py3-none-any.whl |
|---|---|
| Size | 9.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cb3a7978465cca61ec925495f19e1cba5fc35b5e17fc8735f69e1f305e0f3c1a
|
|
BLAKE2b-256 checksum How to use checksums |
23d04744f8ffebfbbb9fac55fdcd82f172ee19dd8af70d13828062a63f364894
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 24, 2025.
Transparency log