no-custom-control-flow-exception
Return coded typed outcomes for expected domain failures instead of exception-only contracts.
Why
A marker, fixed-message or payload-bearing custom exception creates another control-flow type without declaring failure data in the function's return contract. Expected domain outcomes can often use tagged result variants that callers handle explicitly. A caller may need error identifiers for an access check before reporting a rejected record; preserve that handling context. Exceptional failures and framework rejection still need exceptions.
Fix
Review direct raises of this exception. If the condition is an expected domain outcome, reuse an established Result[T, E] or a generic Success[T] | Error[E] union with a shared Literal discriminator, a stable domain error code and the handling context. Update callers to handle, propagate or translate each relevant variant; use the native type checker's assert_never for exhaustiveness. Extend an existing outcome union rather than wrapping it again. For a serializable contract, wrap the successful payload too; avoid a raw scalar | Error union. Use the project's established codec and validate incoming tags and payloads; Pydantic models with an Annotated Field(discriminator=...) union and TypeAdapter are one option. Format the message at the consumer. Preserve true exceptional failures, validation rejection, transport/framework protocols, cancellation, unwinding and useful cause context. A class name or a raise does not prove the condition is expected.
Examples
class Denied(Exception): def __init__(self, name: str) -> None: self.name = name super().__init__(f"Denied {name}")
def load(name: str, allowed: bool) -> str: if not allowed: raise Denied(name) return namefrom typing import Literal, assert_neverfrom pydantic import BaseModel, TypeAdapterfrom app.outcomes import Error, Result, Success
class Denied(BaseModel): code: Literal["denied"] = "denied" name: str
def load(name: str, allowed: bool) -> Result[str, Denied]: return ( Success[str](value=name) if allowed else Error[Denied](error=Denied(name=name)) )
def format_result(result: Result[str, Denied]) -> str: if result.kind == "success": return result.value if result.kind == "error": return f"Denied {result.error.name}" assert_never(result)
codec = TypeAdapter[Result[str, Denied]](Result[str, Denied])
def decode_result(data: str) -> Result[str, Denied]: return codec.validate_json(data, strict=True)from typing import Annotated, Literalfrom pydantic import BaseModel, Field
class Success[T](BaseModel): kind: Literal["success"] = "success" value: T
class Error[E](BaseModel): kind: Literal["error"] = "error" error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]class Denied(Exception): def __init__(self, detail: str) -> None: self.detail = detail super().__init__(detail)
def save(name: str, allowed: bool) -> str: if not allowed: message = f"Denied {name}" raise Denied(message) return namefrom typing import Annotated, Literalfrom pydantic import BaseModel, Field
class Success[T](BaseModel): kind: Literal["success"] = "success" value: T
class Error[E](BaseModel): kind: Literal["error"] = "error" error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]from typing import Literal, assert_neverfrom pydantic import BaseModel, TypeAdapterfrom app.outcomes import Error, Result, Success
class Denied(BaseModel): code: Literal["denied"] = "denied" name: str
def save(name: str, allowed: bool) -> Result[str, Denied]: return ( Success[str](value=name) if allowed else Error[Denied](error=Denied(name=name)) )
def format_result(result: Result[str, Denied]) -> str: if result.kind == "success": return result.value if result.kind == "error": return f"Denied {result.error.name}" assert_never(result)
codec = TypeAdapter[Result[str, Denied]](Result[str, Denied])
def decode_result(data: str) -> Result[str, Denied]: return codec.validate_json(data, strict=True)class UnsupportedContent(ValueError): pass
def read_content(known: bool) -> str: if not known: raise UnsupportedContent("Unsupported content") return "content"from dataclasses import dataclassfrom typing import Literal
@dataclass(frozen=True)class Content: value: str kind: Literal["content"] = "content"
@dataclass(frozen=True)class Unsupported: kind: Literal["unsupported"] = "unsupported"
def read_content(known: bool) -> Content | Unsupported: return Content("content") if known else Unsupported()from typing import final
@finalclass Missing(Exception): def __init__(self, record_id: str) -> None: self.record_id = record_id super().__init__(f"Missing {record_id}")from app.errors import Missing
def load(record_id: str, exists: bool) -> str: if not exists: raise Missing(record_id) return record_idfrom typing import Annotated, Literalfrom pydantic import BaseModel, Field
class Success[T](BaseModel): kind: Literal["success"] = "success" value: T
class Error[E](BaseModel): kind: Literal["error"] = "error" error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]from typing import Literal, assert_neverfrom pydantic import BaseModel, TypeAdapterfrom app.outcomes import Error, Result, Success
class Missing(BaseModel): code: Literal["missing"] = "missing" record_id: str
def load(record_id: str, exists: bool) -> Result[str, Missing]: return ( Success[str](value=record_id) if exists else Error[Missing](error=Missing(record_id=record_id)) )
def format_result(result: Result[str, Missing]) -> str: if result.kind == "success": return result.value if result.kind == "error": return f"Missing {result.error.record_id}" assert_never(result)
codec = TypeAdapter[Result[str, Missing]](Result[str, Missing])
def decode_result(data: str) -> Result[str, Missing]: return codec.validate_json(data, strict=True)class Rejected(Exception): passfrom app.errors import Rejected
def save(name: str, allowed: bool) -> str: if not allowed: raise Rejected(f"Denied {name}") return namefrom typing import Annotated, Literalfrom pydantic import BaseModel, Field
class Success[T](BaseModel): kind: Literal["success"] = "success" value: T
class Error[E](BaseModel): kind: Literal["error"] = "error" error: E
type Result[T, E] = Annotated[Success[T] | Error[E], Field(discriminator="kind")]from typing import Literal, assert_neverfrom pydantic import BaseModel, TypeAdapterfrom app.outcomes import Error, Result, Success
class Denied(BaseModel): code: Literal["denied"] = "denied" name: str
def save(name: str, allowed: bool) -> Result[str, Denied]: return ( Success[str](value=name) if allowed else Error[Denied](error=Denied(name=name)) )
def format_result(result: Result[str, Denied]) -> str: if result.kind == "success": return result.value if result.kind == "error": return f"Denied {result.error.name}" assert_never(result)
codec = TypeAdapter[Result[str, Denied]](Result[str, Denied])
def decode_result(data: str) -> Result[str, Denied]: return codec.validate_json(data, strict=True)class Rejected(Exception): passfrom collections.abc import Generatorfrom contextlib import contextmanagerfrom app.errors import Rejected
@contextmanagerdef transaction() -> Generator[None]: yield None
def save(name: str, allowed: bool) -> str: with transaction(): pass if not allowed: raise Rejected(name) return nameclass Rejected(Exception): passfrom collections.abc import Generatorfrom contextlib import contextmanagerfrom app.errors import Rejected
@contextmanagerdef transaction() -> Generator[None]: yield None
def save(name: str, allowed: bool) -> str: with transaction(): if not allowed: raise Rejected(name) return nameclass Rejected(ValueError): passfrom app.errors import Rejected
def save(value: str) -> str: if not value: raise Rejected("name required") return valueclass Rejected(ValueError): passfrom pydantic import BaseModel, field_validatorfrom app.errors import Rejected
class Model(BaseModel): name: str
@field_validator("name") @classmethod def validate_name(cls, value: str) -> str: if not value: raise Rejected("name required") return value