Skip to content

perf: build the StatusError message on first use - #180

Merged
lesnik512 merged 1 commit into
mainfrom
perf/errors-decoders
Oct 17, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
perf/errors-decoders

Conversation

@lesnik512

Copy link
Copy Markdown
Member

StatusError.__init__ used to format "<status> <method> <redacted url>" as soon as the error was built. That meant str(request.url) plus redact_url on every 4xx/5xx, and Retry paid it again on every retried attempt, even though nothing reads the message unless the error is printed or logged.

Now __init__ only stores response. BaseException.__new__ has already put (response,) in the C-level args slot. StatusError.args is a property: the first time it is read and still holds that (response,), it builds the summary, writes (summary,) back into the slot, and returns it. Later reads return the cached tuple. __str__ reads self.args, so str(), tracebacks and logging all go through the same path. Assigning exc.args still works, and the assigned value replaces the message the way it does on any exception.

Nothing a user can see changes:

  • exc.args is still ("<status> <method> <redacted url>",). A cheaper-looking option was to skip super().__init__ and leave args as (response,). Its raise cost is the same, because the property only runs when someone reads args, but it changes args in a way users can see, so I did not take it.
  • str(exc), repr(exc), traceback.format_exception, the uncaught-exception hook and logging exc_info output are byte-identical to main for StatusError, ClientStatusError, ServerStatusError and every class in STATUS_TO_EXCEPTION. I checked this with URLs that have userinfo, a port, an IPv6 host, query values and fragment parameters, with and without add_note() and a user attribute.
  • Pickle, copy.copy and copy.deepcopy round-trip unchanged. ClientError.__reduce__ reads self.args, which builds the message, so the restored instance carries the same redacted string, and __dict__ holds no new keys.
  • The URL is still redacted, because the message is built by the same _summary() as before.

Measured on 3.14, best of 9, three alternating runs (the machine is noisy):

case main this PR
raise + catch, plain URL 1.3 to 2.7 us 0.5 to 1.1 us
raise + catch, URL with userinfo and query 4.1 to 8.2 us 0.6 to 1.1 us
construct + str(), plain URL 1.1 to 2.3 us 1.3 to 2.9 us
construct + str(), userinfo and query 3.5 to 8.0 us 3.8 to 8.8 us

When the message is read, the property costs about 0.2 to 0.3 us extra. That path only runs when an error is printed or logged.

Dropped: resolver memoization

I also measured caching the resolved decoder per model. It saved about 0.2 to 1.3 us per resolve() and under 1% end to end, and I dropped it because it would call a custom decoder's can_decode once per model instead of once per request.

Behaviour change for the 0.22.0 notes

None.

Tests

test_errors.py: args holds the redacted summary, construction does not call redact_url, and assigned args of length 0, 1 and 2 replace the message.

@lesnik512
lesnik512 merged commit 9336c45 into main Oct 17, 2026
13 checks passed
@lesnik512
lesnik512 deleted the perf/errors-decoders branch October 17, 2026 09:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant