Skip to main content

A tiny, typed error-boundary decorator for Streamlit apps (UI-safe fallback + pluggable hooks)

Project description

PyPI version Python versions PyPI Downloads CI codecov

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_error and fallback)
  • PEP 561 Compatible: Ships with py.typed for 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()

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:

  1. User clicks button with on_click=callback
  2. Streamlit executes callback() -> Not protected by decorator
  3. Streamlit reruns the script
  4. 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 via st.error()) or a callable that renders custom UI
    • When fallback is a str, it is rendered using st.error() internally
    • To customize rendering (e.g., use st.warning() or custom widgets), pass a FallbackRenderer callable instead

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:

  1. Inner boundary handles first (first-match wins)

    • The innermost boundary that catches the exception handles it.
  2. 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.
  3. 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).
  4. Control flow exceptions pass through

    • Streamlit control flow exceptions (st.rerun(), st.stop()) pass through all boundaries without being caught.
  5. 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:

  • INNER fallback is displayed
  • Only inner_hook is called (not outer_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:

  • OUTER fallback is displayed (inner fallback raised exception)
  • Both inner_hook and outer_hook are 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 raise from 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

# Run linting and type checking
make

# Run tests
make test

# Run example app
make example

License

MIT

Contributing

Contributions are welcome! Please open an issue or submit a pull request.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

st_error_boundary-0.1.6.tar.gz (84.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

st_error_boundary-0.1.6-py3-none-any.whl (8.9 kB view details)

Uploaded Python 3

File details

Details for the file st_error_boundary-0.1.6.tar.gz.

File metadata

  • Download URL: st_error_boundary-0.1.6.tar.gz
  • Upload date:
  • Size: 84.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for st_error_boundary-0.1.6.tar.gz
Algorithm Hash digest
SHA256 3d768a9c2fa870eefc2f99f792660a1e29d774330f7403e9b0d9bf8fd3d405fd
MD5 d9b71fe046d72378ad213c435709c407
BLAKE2b-256 7d6c4075e72aa3f20638b9ae4c13a11a14d03c6762a7cac4bfaca39f4b4ac0e6

See more details on using hashes here.

Provenance

The following attestation bundles were made for st_error_boundary-0.1.6.tar.gz:

Publisher: release.yml on K-dash/st-error-boundary

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file st_error_boundary-0.1.6-py3-none-any.whl.

File metadata

File hashes

Hashes for st_error_boundary-0.1.6-py3-none-any.whl
Algorithm Hash digest
SHA256 6f615aedbb58ebfb8cee5b2bc33d9f5cef43ed17217e1a7a32376f9bca0cc2ec
MD5 97e139e8e07d159b9d75beaeab71312c
BLAKE2b-256 9ed5cea193cb3aa2443b1d8abd1234dc432c0ed83b8c79a590ca0e5634d952b6

See more details on using hashes here.

Provenance

The following attestation bundles were made for st_error_boundary-0.1.6-py3-none-any.whl:

Publisher: release.yml on K-dash/st-error-boundary

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page