skip to content

A proxy whose __getattr__ delegates to self._client raises RecursionError when deep-copied — why?

level: seniorimportance: should knowfreq 33%

answer

  1. Only runs when lookup already failed
  2. The hook reads a missing attribute
  3. Copying skips the initializer entirely
  4. Something probes a blank instance
  5. Bypass the fallback to fetch state

basics

~20 s

Copying builds an instance without running init, so _client is absent. Probing that blank object for a hook name falls through to getattr, which reads self._client, which is also absent, so getattr calls itself forever.

solid answer

~40 s

`__getattr__` runs only when normal attribute lookup has already failed. A delegating implementation that reads `self._client` therefore has a trap: if `_client` itself is missing, looking it up fails, `__getattr__` is invoked again for `_client`, and the cycle never terminates until `RecursionError` fires. The copy and pickle machinery is the classic trigger, because it creates the new instance without calling `__init__` and then probes it for hook names such as `__setstate__`; that probe hits the blank object and starts the loop. The fix is to fetch the delegate without re-entering the hook — `object.__getattribute__(self, "_client")` inside a `try`, re-raising `AttributeError(name)` if it is missing — or to raise `AttributeError` immediately for the delegate's own name and for dunder names.

code

python · 17 lines
python
import copy

class Client:
    def submit(self, bid):
        return bid

class BidderProxy:
    def __init__(self, client):
        self._client = client

    def __getattr__(self, name):
        return getattr(self._client, name)

try:
    copy.deepcopy(BidderProxy(Client()))
except RecursionError:
    print("RecursionError while deep-copying the proxy")

go deeper

for a junior

Recall the trigger condition: __getattr__ runs only when an attribute is missing, so reading another missing attribute inside it calls the same hook again with no way out.

for a middle

Explain the full lookup order and why the copy and pickle paths expose it: they build the instance without __init__ and then probe a blank object for hook names.

for a senior

Show that you would find it from the repeating traceback and fix it with object.__getattribute__ or an early AttributeError, and connect it to the leaked resource when cleanup itself goes through the broken hook.

for a principal

Take the position on delegation as a design choice: implicit proxies are convenient and hostile to the object protocol, and a codebase may be better served by explicit forwarding or a narrow allow-list.

## The lookup order that makes this possible `obj.x` does not call `__getattr__` first. It calls `type(obj).__getattribute__`, which walks data descriptors on the type, then the instance `__dict__`, then class attributes and non-data descriptors. Only when **all** of that fails does Python fall back to `__getattr__`, if the class defines one. So `__getattr__` is, by construction, the code that runs when a name is absent. A delegating implementation like this one: ```python def __getattr__(self, name): return getattr(self._client, name) ``` reads another attribute on `self` in order to answer. If `_client` is present, fine — normal lookup finds it in the instance dict and no fallback happens. If `_client` is **absent**, normal lookup fails, Python calls `__getattr__("_client")`, whose body reads `self._client` again, and the recursion has no base case. It ends in `RecursionError`, roughly a thousand frames later. ## Why the failure appears only on copy, pickle or unpickle The object is never blank in normal use, because `__init__` sets `_client` immediately. The copy and pickle machinery does not go through `__init__`: it constructs the new instance directly from the class, then restores state onto it. Between those two steps it **probes** the half-built object for hook names — `__setstate__` in particular — and that probe is a plain attribute lookup on an object whose instance dict is still empty. The probe misses, `__getattr__` is called, `self._client` is missing, and the loop starts. That is why the defect hides so well. The path is exercised by deep-copying a proxy, by pickling one to send it to another process, by a caching layer that snapshots configuration objects, or by any library that calls `hasattr()` on an object it did not construct. A regression pack of several hundred cases that only ever *uses* proxies, never copies them, passes every time. ## The production shape of it Take an ad-auction bidder that wraps each upstream connection in a delegating proxy. A code path deep-copies the bidder's configuration, which drags a proxy along with it. The copy blows up with `RecursionError`; the half-built proxy is discarded; and the connection it should have released is never closed, because `close()` on that half-built object would go through `__getattr__` too and raise again. One leaked descriptor per occurrence is invisible for hours and then is not: the process runs out of file descriptors while every functional test still passes. The lesson an interviewer is listening for is that an attribute-protocol loop does not merely raise — it raises *inside cleanup paths*, which is how it turns into a resource leak. ## Diagnosing it from the traceback A `RecursionError` whose collapsed traceback repeats two or three lines of your own code, one of which is a dunder method, is almost always a protocol loop rather than deep data. Note also that `hasattr()` only swallows `AttributeError`, so a `RecursionError` raised inside a delegating `__getattr__` propagates straight out of an innocent-looking `hasattr()` call in library code. ## Writing it safely Fetch the delegate in a way that cannot re-enter the hook: ```python class SafeProxy: def __init__(self, client): self._client = client def __getattr__(self, name): try: client = object.__getattribute__(self, "_client") except AttributeError: raise AttributeError(name) from None return getattr(client, name) ``` `object.__getattribute__` performs the normal lookup **without** the `__getattr__` fallback, so a missing `_client` produces a clean `AttributeError` and the recursion cannot start. Two cheaper guards work too, and are often combined with it: raise `AttributeError(name)` immediately when `name` is the delegate's own attribute, and raise it for any name that starts and ends with double underscores, so the protocol probes performed by copy, pickle, logging and interactive completion never reach the delegate at all. ## Related loops with the same shape The same trap reaches further than proxies. A `property` whose getter raises `AttributeError` — for any reason, including a genuine bug several calls deeper — is indistinguishable from a missing attribute as far as the lookup machinery is concerned, so Python falls back to `__getattr__`. If that hook then reads the same name, the two mechanisms feed each other and the real error is buried under a `RecursionError`. The defensive habit is to make sure a property getter never lets an `AttributeError` escape from code it merely calls: catch it and re-raise as something else, so the fallback path is never entered by accident. ## The sharper relative `__getattribute__` is easier still to break, because it intercepts **every** attribute access rather than only the failed ones. Any implementation that reads `self.anything` inside it recurses on the first call. The rule there is absolute: inside `__getattribute__`, reach for state only through `object.__getattribute__`. `__setattr__` has the mirror-image trap, and the mirror-image fix in `object.__setattr__`.

  • Why does a hasattr() call in third-party code surface this bug rather than hiding it?
    `hasattr()` returns False only for `AttributeError`; every other exception propagates. A delegating `__getattr__` that loops raises `RecursionError`, which is not an `AttributeError`, so it escapes the probe and surfaces in a caller that looks entirely unrelated to your proxy.
  • How is __getattribute__ more dangerous than __getattr__ here?
    `__getattr__` runs only after lookup fails, so a correct instance never enters it. `__getattribute__` intercepts every access, so reading `self.anything` inside it recurses on the very first attribute access. Inside `__getattribute__` you must reach state through `object.__getattribute__` exclusively.
  • What is the cheapest guard that prevents this entire class of loop?
    Raise `AttributeError(name)` at the top of `__getattr__` for the delegate's own attribute name and for any dunder name. That stops copy, pickle, logging and completion probes from ever reaching the delegate, and it costs one comparison per miss.

saying these in an interview costs you the question

  • Blames the recursion limit rather than the lookup loop
  • Says __getattr__ runs on every attribute access
  • Fixes it by raising the recursion limit
  • Uses getattr(self, '_client') inside the hook
  • Assumes copying always calls __init__
  • Thinks hasattr() suppresses RecursionError

context