Skip to content

_describe_exception can still drop the record — type(exc).__name__ resolves through the metaclass #1576

Description

@groupthinking

Summary

#1515 closed #1525's three residual holes and is on main (5473bcc). One path in the same function is still reachable, and it is the one #1515's own docstring asserts is safe:

def _describe_exception(exc: BaseException) -> str:
    """...The type name is a plain attribute lookup and is always safe."""
    try:
        return f"{type(exc).__name__}: {exc}"
    except Exception:
        return type(exc).__name__          # <- this can raise too

__name__ on a class is looked up on its metaclass, so a metaclass defining __name__ as a raising property defeats the except branch. The second raise happens outside any guard, so it propagates past the _JSON_UNSERIALIZABLE_RECORD tier and out of _format_json entirely — Handler.handleError then drops the record. The final constant-record tier does not catch it.

This is the same failure shape #1525 was filed about, one level further down: the recovery step for a failure is itself able to fail.

Reproduction

Against main @ 5473bcc:

class HostileMeta(type):
    @property
    def __name__(cls): raise RuntimeError("__name__ exploded")

class NamelessExc(Exception, metaclass=HostileMeta):
    def __str__(self): raise RuntimeError("str exploded")

class RaisesNameless:
    def __str__(self): raise NamelessExc()

logger.info("poisoned", extra={"request_id": RaisesNameless()})
_describe_exception -> RAISES RuntimeError: __name__ exploded  => record LOST

Healthy → poisoned → healthy through a real StreamHandler: 2 of 3 records reach the sink.

The obvious fix does not work

CodeRabbit recommended object.__getattribute__(type(exc), "__name__") for this on #1494. It still routes through the metaclass descriptor:

type(exc).__name__                             -> RAISES RuntimeError
object.__getattribute__(type(exc),"__name__")  -> RAISES RuntimeError
type.__dict__["__name__"].__get__(type(exc))   -> OK: 'NamelessExc'

Fetching the descriptor from type.__dict__ and binding it directly bypasses the override, and returns the ordinary name for ordinary classes (ValueError'ValueError').

Suggested fix

 def _describe_exception(exc: BaseException) -> str:
     try:
         return f"{type(exc).__name__}: {exc}"
-    except Exception:  # noqa: BLE001 - the fallback must not need a fallback
-        return type(exc).__name__
+    except Exception:  # noqa: BLE001 - fall through to a narrower attempt
+        pass
+    try:
+        name: str = type.__dict__["__name__"].__get__(type(exc))
+        return name
+    except Exception:  # noqa: BLE001 - the fallback must not need a fallback
+        return "UnrenderableException"

The docstring's "always safe" claim should be corrected at the same time — that inaccuracy is what makes this worth tracking rather than leaving as a curiosity, since it invites the next reader to trust the branch.

Reachability and severity

Severity: low, and lower than #1525's. Reaching it requires a call site to pass a value whose __str__ raises an exception whose class also has a hostile metaclass. No current call site passes a non-scalar at all. This is a correctness gap in a safety net, not an exploitable path — filed because the guarantee is stated unconditionally in the code and is not unconditional.

Acceptance criteria

  1. A record whose enrichment raises an exception with a hostile metaclass is still emitted, with level and message authoritative.
  2. A regression test in the shape of the existing ones in tests/unit/test_logging_config_crlf.py covers it and fails on 5473bcc.
  3. Ordinary exceptions are unaffected — _describe_exception(ValueError("msg")) == "ValueError: msg" still holds, and the existing test_describe_exception_survives_an_exception_that_cannot_be_stringified still passes unchanged.
  4. _describe_exception's docstring states the guarantee the code actually provides.

History

This was found during the #1452 remediation and handed to #1515 as a patch and a failing test before it merged; it was not applied. #1516 carries the fix and is being reduced to exactly this delta against current main.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions