Repository: urllib3/urllib3

Issue:
Fix `RecentlyUsedContainer` regression
#2115 added typing to the `_collections` module. Unfortunately, it also introduced a regression that breaks `test_urllib3_pool_connection_closed` in the requests test suite, as noticed in https://github.com/urllib3/urllib3/pull/2316/checks?check_run_id=3049608329. `__setitem__` is implemented like this in 1.26.x:

https://github.com/urllib3/urllib3/blob/ba95e9eac73452d3bccfb5413b00d9a4fe3e4c31/src/urllib3/_collections.py#L61-L74

And it's now implemented like this in main:

https://github.com/urllib3/urllib3/blob/baba8c2b4aabda8907a777039baa3f8758e4333a/src/urllib3/_collections.py#L133-L155

When `len(self._container)` == `self._maxsize` == 0, `self._container[key] = value` is now called *after* `self._container.popitem(last=False)`, which is why that `popitem` call raises an exception.

Applying this hacky diff fixes the requests test:

```diff
diff --git a/src/urllib3/_collections.py b/src/urllib3/_collections.py
index 32d5330e..798ba432 100644
--- a/src/urllib3/_collections.py
+++ b/src/urllib3/_collections.py
@@ -139,15 +139,15 @@ class RecentlyUsedContainer(Generic[_KT, _VT], MutableMapping[_KT, _VT]):
                 # size of the pool. Because accessing a key should move it to
                 # the end of the eviction line, we pop it out first.
                 evicted_item = key, self._container.pop(key)
+                self._container[key] = value
             except KeyError:
+                self._container[key] = value
                 if len(self._container) >= self._maxsize:
                     # If we didn't evict an existing value, and we've hit our maximum
                     # size, then we have to evict the least recently used item from
                     # the beginning of the container.
                     evicted_item = self._container.popitem(last=False)
 
-            # Finally, insert the new value.
-            self._container[key] = value
 
         # After releasing the lock on the pool, dispose of any evicted value.
         if evicted_item is not None and self.dispose_func:
```

@haikuginger What are your thoughts on this? Is it possible to revert to the original logic?


Discussion:
@pquentin thanks for opening this! The new logic is generally easier to reason about when it comes to typing, so I'd prefer to stick with it, but fixing the bug should be relatively straightforward—I think this would do for avoiding the empty container case:

```diff
--- a/src/urllib3/_collections.py
+++ b/src/urllib3/_collections.py
@@ -139,15 +139,15 @@ class RecentlyUsedContainer(Generic[_KT, _VT], MutableMapping[_KT, _VT]):
                 # size of the pool. Because accessing a key should move it to
                 # the end of the eviction line, we pop it out first.
                 evicted_item = key, self._container.pop(key)
             except KeyError:
-                if len(self._container) >= self._maxsize:
+                if self._container and len(self._container) >= self._maxsize:
                     # If we didn't evict an existing value, and we've hit our maximum
                     # size, then we have to evict the least recently used item from
                     # the beginning of the container.
                     evicted_item = self._container.popitem(last=False)
 
             # Finally, insert the new value.
             self._container[key] = value
 
         # After releasing the lock on the pool, dispose of any evicted value.
         if evicted_item is not None and self.dispose_func:
```
@haikuginger That won't work as a fix, if `RecentlyUsedContainer` is given `maxsize=0` then there shouldn't be an object in the container after calling `__setitem__`.
My proposal is this:

```diff
diff --git a/src/urllib3/_collections.py b/src/urllib3/_collections.py
index 32d5330e..57fd868b 100644
--- a/src/urllib3/_collections.py
+++ b/src/urllib3/_collections.py
@@ -139,16 +139,15 @@ class RecentlyUsedContainer(Generic[_KT, _VT], MutableMapping[_KT, _VT]):
                 # size of the pool. Because accessing a key should move it to
                 # the end of the eviction line, we pop it out first.
                 evicted_item = key, self._container.pop(key)
+                self._container[key] = value
             except KeyError:
-                if len(self._container) >= self._maxsize:
+                self._container[key] = value
+                if len(self._container) > self._maxsize:
                     # If we didn't evict an existing value, and we've hit our maximum
                     # size, then we have to evict the least recently used item from
                     # the beginning of the container.
                     evicted_item = self._container.popitem(last=False)
 
-            # Finally, insert the new value.
-            self._container[key] = value
-
         # After releasing the lock on the pool, dispose of any evicted value.
         if evicted_item is not None and self.dispose_func:
             _, evicted_value = evicted_item
diff --git a/test/test_collections.py b/test/test_collections.py
index 7cc1b868..14ead448 100644
--- a/test/test_collections.py
+++ b/test/test_collections.py
@@ -22,6 +22,11 @@ class TestLRUContainer:
         assert 0 not in d
         assert (i + 1) in d
 
+    def test_maxsize_0(self):
+        d = Container(0)
+        d[1] = 1
+        assert len(d) == 0
+
     def test_expire(self):
         d = Container(5)
```

Passes all of our existing tests and adds a new one for an empty `RecentlyUsedContainer`. This is potentially a place where Hypothesis' state testing would come in handy.

We can add a comment about why the setitem to `_container` is in two places too to hopefully make this approach easier to follow?
@SethMichaelLarson ah good point; I misunderstood. That approach looks sound to me.

Resolving patch:
diff --git a/src/urllib3/_collections.py b/src/urllib3/_collections.py
--- a/src/urllib3/_collections.py
+++ b/src/urllib3/_collections.py
@@ -139,16 +139,17 @@ def __setitem__(self, key: _KT, value: _VT) -> None:
                 # size of the pool. Because accessing a key should move it to
                 # the end of the eviction line, we pop it out first.
                 evicted_item = key, self._container.pop(key)
+                self._container[key] = value
             except KeyError:
-                if len(self._container) >= self._maxsize:
+                # When the key does not exist, we insert the value first so that
+                # evicting works in all cases, including when self._maxsize is 0
+                self._container[key] = value
+                if len(self._container) > self._maxsize:
                     # If we didn't evict an existing value, and we've hit our maximum
                     # size, then we have to evict the least recently used item from
                     # the beginning of the container.
                     evicted_item = self._container.popitem(last=False)
 
-            # Finally, insert the new value.
-            self._container[key] = value
-
         # After releasing the lock on the pool, dispose of any evicted value.
         if evicted_item is not None and self.dispose_func:
             _, evicted_value = evicted_item


Test patch:
diff --git a/test/test_collections.py b/test/test_collections.py
--- a/test/test_collections.py
+++ b/test/test_collections.py
@@ -22,6 +22,11 @@ def test_maxsize(self):
         assert 0 not in d
         assert (i + 1) in d
 
+    def test_maxsize_0(self):
+        d = Container(0)
+        d[1] = 1
+        assert len(d) == 0
+
     def test_expire(self):
         d = Container(5)