Skip to content

MultiDict.get() on an absent key logs an ERROR record per lookup #362

Description

@JarryShaw

Summary

MultiDict.get() / OrderedMultiDict.get() emit a logging record at ERROR level every time a key is absent, then correctly return the default. On a successful parse of a capture containing unfragmented IPv6 with reassembly enabled, this puts one ERROR line on stderr per IPv6 frame, for what the calling code itself describes as the ordinary case.

file:line

pcapkit/utilities/exceptions.py:112-113, in BaseError.__init__ (lines 100-117):

100    def __init__(self, *args: 'Any', quiet: 'bool' = False, **kwargs: 'Any') -> 'None':
101        # log error
102        if not quiet:
103            if DEVMODE:
104                logger.critical('%s: %s', type(self).__name__, str(self),
105                                exc_info=self if VERBOSE else False,
106                                stack_info=VERBOSE, stacklevel=-stacklevel())
107            else:
108                logger.critical("%s: %s", type(self).__name__, str(self))
109
110            # logger.error('%s: %s', type(self).__name__, str(self), exc_info=self,
111            #              stack_info=True, stacklevel=-stacklevel())
112        else:
113            logger.error('%s: %s', type(self).__name__, str(self))

The offending expression is line 113:

logger.error('%s: %s', type(self).__name__, str(self))

quiet=True selects that branch. So quiet currently means "log at ERROR instead of CRITICAL", not "do not log".

Intent vs behaviour

docs/source/pcapkit/utilities/exceptions.rst:14 documents the parameter as:

   :param quiet: If :data:`True`, suppress exception message.

How an ordinary lookup reaches it

pcapkit/corekit/multidict.py:210-214 — get is defined on MultiDict and inherited by OrderedMultiDict:

210    def get(self, key: '_KT', default: '_VT | _T' = None) -> '_VT | _T':
211        try:
212            return self[key]
213        except MissingKeyError:
214            return default

Both __getitem__ implementations raise with quiet=True:

183        raise MissingKeyError(key, quiet=True)   # MultiDict.__getitem__        (169-183)
514        raise MissingKeyError(key, quiet=True)   # OrderedMultiDict.__getitem__ (511-514)

The IPv6 caller is pcapkit/toolkit/pcap.py:101-104 (identical code in pcapkit/toolkit/pcapng.py:104-107), whose own comment says a miss is expected:

101        if (ipv6_frag := ipv6.extension_headers.get(  # type: ignore[call-overload]
102            Enum_ExtensionHeader.IPv6_Frag
103        )) is None:  # dismiss not fragmented frame
104            return None

Observable symptom

pcapkit/utilities/logging.py:49-55 attaches a StreamHandler(sys.stderr) at level INFO unconditionally, so these records reach the terminal with no logging configuration at all.

On examples/captures/ipv6.pcap (16 frames, parse succeeds):

[INFO] 09/14/2026 12:24:22 AM - IPv6 reassembly enabled
[ERROR] 09/14/2026 12:24:22 AM - MissingKeyError: <ExtensionHeader.IPv6_Frag: 44>
[ERROR] 09/14/2026 12:24:22 AM - MissingKeyError: <ExtensionHeader.IPv6_Frag: 44>
[ERROR] 09/14/2026 12:24:22 AM - MissingKeyError: <ExtensionHeader.IPv6_Frag: 44>
... (12 in total)
[WARNING] 09/14/2026 12:24:22 AM - EOF reached
frames parsed: 16
reassembled ipv6 datagrams: 1

Counted programmatically (Python 3.14.7):

capture frames records by level MissingKeyError ERROR records
examples/captures/ipv6.pcap 16 {'INFO': 1, 'ERROR': 12, 'WARNING': 1} 12
examples/captures/http6.cap 26 {'INFO': 1, 'ERROR': 26, 'WARNING': 3} 26

Every one of them is the identical string MissingKeyError: <ExtensionHeader.IPv6_Frag: 44>.

Scope, measured on ipv6.pcap — IPv6 reassembly has to be on:

config                             frames  MissingKeyError ERRORs
default (no reassembly)                16        0
reassembly=True only                   16        0
reassembly=True, ip=True               16       12
reassembly=True, ipv6=True             16       12
reassembly=True, ipv4=True             16        0

This corrects the way it was first described to me as happening on "any capture containing unfragmented IPv6" — reassembly must be enabled, and the CLI exposes no reassembly flag, so it is a library-API path. The minimal form below has no preconditions at all, though.

Minimal reproduction

No capture and no reassembly needed:

import logging

from pcapkit.corekit.multidict import OrderedMultiDict


class Sink(logging.Handler):
    def __init__(self):
        super().__init__()
        self.records = []

    def emit(self, record):
        self.records.append(record)


sink = Sink()
logging.getLogger('pcapkit').addHandler(sink)

result = OrderedMultiDict().get('absent')

print('returned:', repr(result))
for r in sink.records:
    print(r.levelname, '|', r.getMessage())

Output:

returned: None
ERROR | MissingKeyError: 'absent'

The capture-level form:

import pcapkit

pcapkit.extract(fin='examples/captures/ipv6.pcap', nofile=True, store=True,
                reassembly=True, ipv6=True)

Related side effect in the same constructor

Lines 115-116 run on both branches, so the same absent-key .get() also sets sys.tracebacklimit = 0 for the rest of the process, truncating tracebacks for entirely unrelated exceptions:

sys.tracebacklimit before: <unset>
traceback lines for an unrelated ValueError, before: 14
OrderedMultiDict().get("absent") returned: None
sys.tracebacklimit after : 0
traceback lines for the same unrelated ValueError, after: 1

What a fix would need to touch

Two defensible places; the second is arguably the root cause.

  1. pcapkit/utilities/exceptions.py, BaseError.__init__, the else branch at 112-113 — make quiet=True mean what the docs say (emit nothing, or emit at DEBUG). Also decide whether sys.tracebacklimit = 0 at 115-116 should apply on the quiet path.
  2. pcapkit/corekit/multidict.py — MultiDict.get (210-214) with MultiDict.__getitem__ (169-183) and OrderedMultiDict.__getitem__ (511-514): a .get() miss is control flow, not an error, and need not construct a logging exception at all. Fixing here removes the per-lookup cost as well as the noise and leaves quiet semantics alone.

Other callers passing quiet=True, which a semantics change would affect: pcapkit/corekit/multidict.py:183, pcapkit/corekit/multidict.py:514, pcapkit/protocols/protocol.py:835.

Tests pinning current behaviour: tests/utilities/test_exceptions_warnings.py:22-25 and :27-38.

Observed vs inferred

  • Observed by running code: the single ERROR record from a bare .get() miss on both MultiDict and OrderedMultiDict; the correct None return; 12 and 26 ERROR records on ipv6.pcap and http6.cap; that they appear on stderr with no logging configuration; the trigger matrix; the sys.tracebacklimit = 0 side effect and its effect on an unrelated traceback.
  • Inferred by reading code: that docs/source/pcapkit/utilities/exceptions.rst:14 is the authoritative statement of what quiet is meant to do; that pcapkit/toolkit/pcapng.py:104-107 behaves the same as the pcap.py path (identical code, not separately exercised).

Related

Two other defects in the same family, filed separately because no single change fixes more than one of them: the double/triple emission in pcapkit/utilities/warnings.py:52-54, and the process-global warnings.filters mutation at pcapkit/utilities/warnings.py:74. Note the structural rhyme with the latter — both are constructors with unscoped process-global side effects.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssues reporting a defect (set by the bug report template; a default, not an assessment)

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions