Skip to content

postgres_retry costs ~21 µs per call on the success path #71

Closed

Description

@lesnik512

postgres_retry costs about 21 µs per call even when the first attempt succeeds, which is almost every call. That is more than some of the database work it wraps.

Measurements

Wrapping a coroutine that returns immediately, on Python 3.14 with tenacity 9.1.4, 50,000 calls:

µs per call
bare await 0.05
postgres_retry 21.5
calling a prebuilt AsyncRetrying 15.3
building AsyncRetrying alone 1.1
get_retries_number() (env read) 0.45
try/except around the call 0.10

Most of the cost is tenacity's attempt loop, which runs even for a single successful attempt. Building the retryer for each call is only about 1 µs. I did not break down the remaining few µs.

In a real request loop the cost is larger. Reading one row by primary key through SQLAlchemy and asyncpg, against a local Postgres on the autocommit engine, took 167 µs per request without the decorator and 210 µs with it. In the same setup pool_pre_ping=True costs 25 µs. So with a nearby database, replacing pre-ping with postgres_retry (now possible after #69) makes requests slower rather than faster.

Proposal

Call the function once directly, and build the tenacity retryer only after that first attempt raises a retriable error:

  1. await f(*args, **kwargs). On success, return.
  2. On an exception that is not retriable, or when the attempt budget is 1, re-raise.
  3. Otherwise wait the first backoff and run the remaining attempts through AsyncRetrying, with the budget reduced by one.

Constraints from the existing invariants:

  • The attempt count is still resolved for each call, from DB_RETRY_RETRIES_NUMBER or retries=N (ADR 0001, test_the_default_attempt_count_is_re_read_on_every_call). It can now be read after the first failure instead of before the first attempt.
  • A retry never re-runs immediately. The wait between the first and second attempt has to stay (test_a_retry_never_re_runs_the_body_immediately).
  • The total number of attempts and the retriable predicate stay the same.

One visible change: before_log writes a DEBUG line at the start of every attempt, including the first. With the fast path, a call that succeeds first time logs nothing. That seems acceptable, but it belongs in the release notes.

Expected result: the success path drops to roughly the cost of a try/except, about 0.1 µs, from 21 µs.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions