Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions fastly_compute/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Top-level exceptions emitted by the Fastly API"""

from collections.abc import Callable, Mapping
from enum import Enum
from functools import wraps
from typing import Any

from wit_world.imports.types import Err


class FastlyError(Exception):
"""Abstract base class for all errors raised by Fastly APIs

This allows catching all errors emanating from Fastly APIs at once.
"""


class UnexpectedFastlyError(FastlyError):
"""An error arising from a Fastly API but of an unanticipated kind, such
that we merely package up the low-level error and send it along.

Any of these encountered in the wild means we neglected to keep our Python
wrappers up to date with the WIT.
"""

def __init__(self, error_value: object):
"""Construct.

:arg error_value: The ``value`` attr of the raised ``Err``
"""
self.value = error_value


# TODO: Move to somewhere more private once it becomes clear where.
def remap_wit_errors(
idiomatic_exceptions: Mapping[Any, type[FastlyError]] | None = None,
) -> Callable:
"""Raise more idiomatic exceptions from a function that returns a WIT ``result``.

A ``result``s error case is always wrapped in a generic ``Err`` exception by
componentize-py. Convert that to a more descriptive exception that can be
selectively caught.

:arg idiomatic_exceptions: A map of the types of WIT-level ``Err.value``s to
more informative exception classes. These classes receive the
``Err.value`` as a constructor argument. If the value's type is not
found in the map, wrap it in an ``UnexpectedFastlyError``.

Enum members may also be used as mapping keys. Exceptions raised based
on these receive no constructor arguments, since the values of enum
members are generated and meaningless.

Goals: Be idiomatic. Be reasonably efficient. Be readable as documentation.
In that order.

"""
# Someday, if we need more flexibility than class-by-class mapping, we can
# take a fallback callable that can do further thinking. Also, only the type
# signature keeps you from passing along an arbitrary callable that can
# emit, say, different exception classes for even and odd ints.
if idiomatic_exceptions is None:
idiomatic_exceptions = {}

def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Err as e:
error_value = e.value

# Look up ordinary instances by class but enum fields by value so
# we can easily give each enum member its own exception class:
if isinstance(error_value, Enum):
key = error_value
exc_args = ()
else:
key = type(error_value)
exc_args = (error_value,)
idiomatic_exception = idiomatic_exceptions.get(
key, UnexpectedFastlyError
)
raise idiomatic_exception(*exc_args) from e

return wrapper

return decorator
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ select = [
ignore = [
"E501", # line too long, handled by formatter
"UP031", # % string formatting, minimally disruptive for stdlib-based HTML templating
"D415", # Don't require punctuation at the end of a docstring summary: sometimes they are noun phrases, not sentences.
"D205", # This spuriously complains about line-wrapped single-sentence docstring summaries. Sometimes 80 chars isn't enough.
]

[tool.ruff.format]
Expand Down
122 changes: 122 additions & 0 deletions tests/test_nice_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Show (and test) some motivating examples of how the ``remap_wit_errors``
decorator makes WIT's ``result``-driven errors more Pythonic."""

import sys
from pathlib import Path

from pytest import raises

# Bring in stubs for local testing:
sys.path.append(str(Path(__file__).parent.parent / "stubs"))

from wit_world.imports.types import Error_BufferLen, OpenError
from wit_world.types import Err

from fastly_compute.exceptions import (
FastlyError,
UnexpectedFastlyError,
remap_wit_errors,
)


class BufferTooShortError(FastlyError):
# A "nice" version of a WIT exception takes the WIT error as the sole arg of
# its constructor. While it would make the exception class more
# constructable by customer code if we took, for example, simply an int here
# and added a from_wit_error() class method, this would complicate the
# calling contract of remap_wit_errors() for "escape-hatch" callables which
# conditionally choose exception mappings. It remains to be seen if we ever
# need those.
def __init__(self, wit_error: Error_BufferLen):
self.length = wit_error.value

# Freed of the generated skeletal dataclasses, we can add niceties like good
# error messages.
def __str__(self):
return f"Buffer was too short to hold the result. At least {self.length} is needed."


class NegativeHeightError(FastlyError):
def __init__(self, height: int):
self.height = height


def test_primitive():
"""Show that a primitive type can be mapped to a meaningful exception."""

@remap_wit_errors({int: NegativeHeightError})
def raise_int() -> Err:
"""Raise a primitive value, which is expected and gets wrapped in a descriptive exception."""
raise Err(value=-3)

try:
raise_int()
except NegativeHeightError as e:
assert e.height == -3


def test_unexpected():
"""For unexpected error types, an UnexpectedFastlyError should be raised.

This preserves the value of the original error and the ability for customers
to catch all Fastly API errors by catching FastlyError. It also keeps them
insulated from componentize-py's Err class, lest we move away from it
someday.
"""

@remap_wit_errors()
def raise_int_by_surprise() -> Err:
"""Raise a primitive value, which is a type we didn't expect."""
raise Err(value=-3)

try:
raise_int_by_surprise()
except UnexpectedFastlyError as e:
assert e.value == -3


def test_variant():
"""Show how a WIT variant case can be concisely mapped into a more idiomatic exception."""

@remap_wit_errors({Error_BufferLen: BufferTooShortError})
def raise_variant() -> Err:
"""Raise an Err whose value is a case of our generic ``error`` variant."""
raise Err(value=Error_BufferLen(64))

try:
raise_variant()
except BufferTooShortError as e:
assert e.length == 64


def test_enum():
"""Show how we can also map individual enum cases to exception classes."""

class InvalidSyntaxError(FastlyError):
pass

class NotFoundError(FastlyError):
pass

enum_map = {
OpenError.INVALID_SYNTAX: InvalidSyntaxError,
OpenError.NOT_FOUND: NotFoundError,
}

@remap_wit_errors(enum_map)
def raise_one_enum() -> Err:
raise Err(value=OpenError.INVALID_SYNTAX)

@remap_wit_errors(enum_map)
def raise_other_enum() -> Err:
raise Err(value=OpenError.NOT_FOUND)

try:
raise_one_enum()
except InvalidSyntaxError as e:
assert len(e.args) == 0, (
"Exceptions raised based on enum members should receive no constructor args."
)

with raises(NotFoundError):
raise_other_enum()