Deriver Jitter (#765)

* fix(deriver): Remove connection retry logic and add jitter to polling interval

* chore(docs): Update changelog and document new configurations

* chore: increment version numbers
This commit is contained in:
Vineeth Voruganti 2026-06-02 11:48:36 -04:00 committed by GitHub
parent bb6dad9157
commit 9f26fdd2ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 298 additions and 550 deletions

View File

@ -44,18 +44,15 @@ DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
# DB_POOL_CLASS=default
# DB_POOL_SIZE=10
# DB_MAX_OVERFLOW=20
# DB_POOL_TIMEOUT=5 # seconds; must stay under DB_CONNECTION_RETRY_MAX_DELAY_SECONDS for a pooled (non-null) DB_POOL_CLASS
# DB_POOL_TIMEOUT=5 # seconds a pooled checkout waits for a free connection (QueuePool only)
# DB_POOL_RECYCLE=300
# DB_POOL_PRE_PING=true
# DB_POOL_USE_LIFO=true
# DB_SQL_DEBUG=false
# DB_TRACING=false
# Bounded exponential-backoff retry around connection checkout (guards against
# transient transaction-pooler saturation). Applied lazily on first DB use.
# DB_CONNECTION_RETRY_ENABLED=true
# DB_CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0
# DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS=0.1
# DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS=2.0
# Per-connection establish timeout (seconds) so a single connection attempt
# fails fast instead of hanging when the server/pooler is unreachable.
# DB_CONNECT_TIMEOUT_SECONDS=2
# =============================================================================
# Authentication Settings
@ -115,6 +112,11 @@ LLM_OPENAI_API_KEY=your-api-key-here
# DERIVER_POLLING_BACKOFF_ENABLED=true
# DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0
# DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0
# Jitter so instances that start together don't poll in lockstep. Startup: sleep
# a random delay in [0, value] before the first poll (0.0 disables). Per-cycle:
# multiply every poll sleep by a random factor in [1-ratio, 1+ratio] (0.0 disables).
# DERIVER_POLLING_STARTUP_JITTER_SECONDS=30.0
# DERIVER_POLLING_JITTER_RATIO=0.5
# DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# DERIVER_QUEUE_ERROR_RETENTION_SECONDS=2592000 # 30 days
# DERIVER_MODEL_CONFIG__TEMPERATURE=

View File

@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [3.0.9] - 2026-06-02
### Changed
- Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation.
### Added
- Deriver poll jitter so instances that start together don't poll in lockstep: `DERIVER_POLLING_STARTUP_JITTER_SECONDS` (random delay before the first poll, default 30s) and `DERIVER_POLLING_JITTER_RATIO` (±fraction applied to every poll sleep, default 0.5). Both disable at `0.0`; the underlying backoff schedule is unchanged.
### Removed
- Reverted the connection-checkout retry and `HonchoAsyncSession` custom session introduced in 3.0.8. Removed the `DB_CONNECTION_RETRY_ENABLED` / `DB_CONNECTION_RETRY_MAX_DELAY_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS` settings, the `db_connection_acquisitions{outcome=...}` Prometheus counter, and the `db.pool.acquire` Sentry span. Alerting built on `db_connection_acquisitions` should migrate to `db_pool_connections` / `db_queries_in_flight`.
## [3.0.8] - 2026-06-01
### Added

View File

@ -8,7 +8,7 @@
---
![Static Badge](https://img.shields.io/badge/Server-3.0.7-blue)
![Static Badge](https://img.shields.io/badge/Server-3.0.9-blue)
[![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/)
[![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho)

View File

@ -26,18 +26,14 @@ POOL_CLASS = "default"
POOL_PRE_PING = true
POOL_SIZE = 10
MAX_OVERFLOW = 20
POOL_TIMEOUT = 5 # seconds; must stay under CONNECTION_RETRY_MAX_DELAY_SECONDS
POOL_TIMEOUT = 5 # seconds a pooled checkout waits for a free connection (QueuePool only)
POOL_RECYCLE = 300 # seconds
POOL_USE_LIFO = true
SQL_DEBUG = false
TRACING = false
# Bounded retry around connection checkout (used by background/tracked_db and
# the lazy request session). For a pooled (non-null) POOL_CLASS, POOL_TIMEOUT
# must be < CONNECTION_RETRY_MAX_DELAY_SECONDS or startup validation fails.
CONNECTION_RETRY_ENABLED = true
CONNECTION_RETRY_MAX_DELAY_SECONDS = 10.0
CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS = 0.1
CONNECTION_RETRY_BACKOFF_MAX_SECONDS = 2.0
# Per-connection establish timeout (seconds) so a single connection attempt
# fails fast instead of hanging when the server/pooler is unreachable.
CONNECT_TIMEOUT_SECONDS = 2
# Authentication settings
[auth]
@ -95,6 +91,12 @@ POLLING_SLEEP_INTERVAL_SECONDS = 1.0
POLLING_BACKOFF_ENABLED = true
POLLING_SLEEP_MAX_INTERVAL_SECONDS = 30.0
POLLING_BACKOFF_MULTIPLIER = 2.0
# Jitter so instances that start together don't poll in lockstep. Startup:
# sleep a random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before the first
# poll (0.0 disables). Per-cycle: multiply every poll sleep by a random factor
# in [1 - ratio, 1 + ratio] (0.5 -> [0.5x, 1.5x]; 0.0 disables).
POLLING_STARTUP_JITTER_SECONDS = 30.0
POLLING_JITTER_RATIO = 0.5
STALE_SESSION_TIMEOUT_MINUTES = 5
# QUEUE_ERROR_RETENTION_SECONDS = 2592000 # 30 days
DEDUPLICATE = true

View File

@ -30,7 +30,8 @@ This guide helps you match the right SDK version to your Honcho API version. New
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v3.0.8 (Current) | v2.1.2 | v2.1.2 |
| v3.0.9 (Current) | v2.1.2 | v2.1.2 |
| v3.0.8 | v2.1.2 | v2.1.2 |
| v3.0.7 | v2.1.2 | v2.1.2 |
| v3.0.6 | v2.1.1 | v2.1.1 |
| v3.0.5 | v2.1.0 | v2.1.0 |

View File

@ -27,7 +27,21 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Honcho API and SDK Changelogs
<Tabs>
<Tab title="Honcho API">
<Update label="v3.0.8 (Current)">
<Update label="v3.0.9 (Current)">
### Changed
- Connection acquisition is now a single attempt with no server-side retry, on a vanilla `AsyncSession`. A new `DB_CONNECT_TIMEOUT_SECONDS` (default 2s) bounds the attempt so a saturated or unreachable pooler fails fast instead of holding a client connection open to re-knock. A saturated DB now surfaces to the caller — the API returns an error and the deriver backs off and retries on a later poll — which lets the pooler drain rather than amplifying saturation.
### Added
- Deriver poll jitter so instances that start together don't poll in lockstep: `DERIVER_POLLING_STARTUP_JITTER_SECONDS` (random delay before the first poll, default 30s) and `DERIVER_POLLING_JITTER_RATIO` (±fraction applied to every poll sleep, default 0.5). Both disable at `0.0`; the underlying backoff schedule is unchanged.
### Removed
- Reverted the connection-checkout retry and `HonchoAsyncSession` custom session introduced in 3.0.8. Removed the `DB_CONNECTION_RETRY_ENABLED` / `DB_CONNECTION_RETRY_MAX_DELAY_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS` / `DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS` settings, the `db_connection_acquisitions{outcome=...}` Prometheus counter, and the `db.pool.acquire` Sentry span. Alerting built on `db_connection_acquisitions` should migrate to `db_pool_connections` / `db_queries_in_flight`.
</Update>
<Update label="v3.0.8">
### Added
- Connection-checkout retry with bounded exponential backoff (tenacity) on `get_db`/`tracked_db`: transient transaction-pooler (Supavisor) rejections — SQLAlchemy `TimeoutError` and `OperationalError` — now retry with backoff instead of surfacing as 500s under client-connection saturation. Gated by

View File

@ -24,7 +24,7 @@
"navigation": {
"versions": [
{
"version": "v3.0.8",
"version": "v3.0.9",
"api": {
"openapi": ["v3/openapi.json"]
},

View File

@ -360,6 +360,11 @@ DERIVER_POLLING_SLEEP_INTERVAL_SECONDS=1.0
DERIVER_POLLING_BACKOFF_ENABLED=true
DERIVER_POLLING_SLEEP_MAX_INTERVAL_SECONDS=30.0
DERIVER_POLLING_BACKOFF_MULTIPLIER=2.0
# Jitter so instances that start together don't poll in lockstep. Startup: sleep
# a random delay in [0, value] before the first poll (0.0 disables). Per-cycle:
# multiply every poll sleep by a random factor in [1-ratio, 1+ratio] (0.0 disables).
DERIVER_POLLING_STARTUP_JITTER_SECONDS=30.0
DERIVER_POLLING_JITTER_RATIO=0.5
DERIVER_STALE_SESSION_TIMEOUT_MINUTES=5
# Queue management
@ -469,12 +474,9 @@ DB_POOL_TIMEOUT=5
DB_POOL_RECYCLE=300
DB_POOL_USE_LIFO=true
DB_SQL_DEBUG=false
# Bounded retry around connection checkout. For a pooled (non-null) DB_POOL_CLASS,
# DB_POOL_TIMEOUT must be < DB_CONNECTION_RETRY_MAX_DELAY_SECONDS or startup fails.
DB_CONNECTION_RETRY_ENABLED=true
DB_CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0
DB_CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS=0.1
DB_CONNECTION_RETRY_BACKOFF_MAX_SECONDS=2.0
# Per-connection establish timeout (seconds) so a single connection attempt
# fails fast instead of hanging when the server/pooler is unreachable.
DB_CONNECT_TIMEOUT_SECONDS=2
```
### Authentication

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "3.0.8"
version = "3.0.9"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},

View File

@ -613,8 +613,8 @@ class DBSettings(HonchoSettings):
POOL_SIZE: Annotated[int, Field(default=10, gt=0, le=1000)] = 10
MAX_OVERFLOW: Annotated[int, Field(default=20, ge=0, le=1000)] = 20
POOL_TIMEOUT: Annotated[int, Field(default=5, gt=0, le=300)] = (
5 # seconds; kept under CONNECTION_RETRY_MAX_DELAY_SECONDS so a pool
# checkout fails fast enough to allow a retry within the budget
5 # seconds a pooled checkout may wait for a free connection (QueuePool
# only; NullPool has no local queue wait)
)
POOL_RECYCLE: Annotated[int, Field(default=300, gt=0, le=7200)] = (
300 # seconds (max 2 hours)
@ -623,47 +623,12 @@ class DBSettings(HonchoSettings):
SQL_DEBUG: bool = False
TRACING: bool = False
# Bounded exponential-backoff retry around connection acquisition. Applied
# lazily on the first DB use of any session (HonchoAsyncSession) — both the
# request path and background/tracked_db scopes — without forcing an eager
# checkout. Guards against transient transaction-pooler saturation (e.g.
# Supavisor rejecting with "too many clients") by retrying the checkout
# instead of failing immediately. CONNECTION_RETRY_MAX_DELAY_SECONDS is the
# TOTAL retry
# budget; with a real QueuePool, a single checkout can block up to
# POOL_TIMEOUT, so POOL_TIMEOUT must stay below the budget for a retry to be
# possible (enforced below). With NullPool (the transaction-pooler setup)
# there is no local queue wait, so saturation is a fast OperationalError and
# POOL_TIMEOUT does not apply.
CONNECTION_RETRY_ENABLED: bool = True
CONNECTION_RETRY_MAX_DELAY_SECONDS: Annotated[
float, Field(default=10.0, gt=0.0, le=120.0)
] = 10.0
CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS: Annotated[
float, Field(default=0.1, gt=0.0, le=10.0)
] = 0.1
CONNECTION_RETRY_BACKOFF_MAX_SECONDS: Annotated[
float, Field(default=2.0, gt=0.0, le=30.0)
] = 2.0
@model_validator(mode="after")
def _validate_retry_budget_vs_pool_timeout(self) -> "DBSettings":
# Only meaningful for a real local pool: NullPool has no queue wait, so
# POOL_TIMEOUT is unused there. For a QueuePool, a checkout can block up
# to POOL_TIMEOUT, so it must be < the total retry budget or the first
# attempt consumes the whole budget and no retry ever happens.
if (
self.POOL_CLASS != "null"
and self.CONNECTION_RETRY_ENABLED
and self.POOL_TIMEOUT >= self.CONNECTION_RETRY_MAX_DELAY_SECONDS
):
raise ValueError(
f"DB_POOL_TIMEOUT ({self.POOL_TIMEOUT}s) must be less than "
+ "DB_CONNECTION_RETRY_MAX_DELAY_SECONDS "
+ f"({self.CONNECTION_RETRY_MAX_DELAY_SECONDS}s) so a pooled checkout "
+ "can fail fast enough to be retried within the budget."
)
return self
# Per-connection establish timeout (seconds) passed to the driver, so a
# single connection attempt fails fast instead of hanging when the server or
# pooler is unreachable or stalled. Connection acquisition is a single
# attempt with no retry; callers handle failure (the API surfaces it, the
# deriver backs off and retries on a later poll).
CONNECT_TIMEOUT_SECONDS: Annotated[int, Field(default=2, gt=0, le=60)] = 2
class AuthSettings(HonchoSettings):
@ -792,6 +757,17 @@ class DeriverSettings(HonchoSettings):
POLLING_BACKOFF_MULTIPLIER: Annotated[
float, Field(default=2.0, ge=1.0, le=10.0)
] = 2.0
# Sleep a uniform-random delay in [0, POLLING_STARTUP_JITTER_SECONDS] before
# the first poll so instances that start together don't poll in lockstep.
# Set to 0.0 to disable.
POLLING_STARTUP_JITTER_SECONDS: Annotated[
float, Field(default=30.0, ge=0.0, le=300.0)
] = 30.0
# Multiply every poll sleep by a random factor in [1 - ratio, 1 + ratio]
# (0.5 -> [0.5x, 1.5x]) so poll loops don't re-converge over time. The
# backoff schedule is unchanged; only the returned sleep is scattered. Set
# to 0.0 to disable.
POLLING_JITTER_RATIO: Annotated[float, Field(default=0.5, ge=0.0, le=1.0)] = 0.5
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5
# Retention window (seconds) for keeping errored items in the queue

257
src/db.py
View File

@ -2,10 +2,7 @@ import contextvars
import logging
from typing import Any
import sentry_sdk
from sqlalchemy import MetaData, event, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import TimeoutError as SQLAlchemyTimeoutError
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@ -13,22 +10,18 @@ from sqlalchemy.ext.asyncio import (
)
from sqlalchemy.orm import declarative_base
from sqlalchemy.pool import NullPool, QueuePool
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_delay,
wait_exponential_jitter,
)
from src.config import settings
from src.telemetry.prometheus.metrics import (
db_queries_in_flight_gauge,
prometheus_metrics,
)
from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge
logger = logging.getLogger(__name__)
connect_args = {"prepare_threshold": None}
connect_args = {
"prepare_threshold": None,
# Bound a single connection attempt so it fails fast instead of hanging when
# the server/pooler is unreachable or stalled (psycopg, seconds).
"connect_timeout": settings.DB.CONNECT_TIMEOUT_SECONDS,
}
# Context variable to store request context
request_context: contextvars.ContextVar[str | None] = contextvars.ContextVar(
@ -59,25 +52,46 @@ engine = create_async_engine(
**engine_kwargs,
)
# NOTE: SessionLocal is defined further down, after HonchoAsyncSession (its
# session class) and acquire_connection_with_retry (which that class calls).
# Errors worth retrying when acquiring a pooled connection: SQLAlchemy's local
# pool-checkout timeout, and OperationalError (how a saturated transaction
# pooler surfaces "too many clients" / connection refusals).
RETRYABLE_DB_CONNECTION_ERRORS = (SQLAlchemyTimeoutError, OperationalError)
# Identifies this process ("api" | "deriver") on DB metrics. Set once at startup
# by register_db_query_instrumentation; stays "unknown" if metrics are disabled.
_db_instance_type: str = "unknown"
# A vanilla AsyncSession is lazy: it checks out a pooled connection on the first
# DB-touching call (not at construction) and couples the checkout to the
# statement, so a handler doing non-DB work (embedding/file/LLM) before its
# first query does not pin a connection across it. Connection acquisition is a
# single attempt with no retry — callers handle a saturated/unreachable DB (the
# API surfaces the error; the deriver backs off and retries on a later poll).
SessionLocal = async_sessionmaker(
autocommit=False,
autoflush=False,
expire_on_commit=False,
bind=engine,
class_=AsyncSession,
)
def _record_acquisition_outcome(outcome: str) -> None:
"""Record a connection-acquisition outcome (no-op when metrics disabled)."""
if settings.METRICS.ENABLED:
prometheus_metrics.record_db_connection_acquisition(
instance_type=_db_instance_type, outcome=outcome
)
def _set_application_name_on_checkout(
dbapi_connection: Any, _connection_record: Any, _connection_proxy: Any
) -> None:
"""Tag each checked-out connection with the current request context.
Registered only when ``DB.TRACING`` is on. Fires on every pool checkout (so a
reused pooled connection is re-tagged for the new caller), reading the
per-task ``request_context`` the request/task scope has already set.
Best-effort: a failure here must never break the checkout.
"""
context = request_context.get() or "unknown"
try:
cursor = dbapi_connection.cursor()
try:
cursor.execute(
"SELECT set_config('application_name', %s, false)", (context,)
)
finally:
cursor.close()
except Exception:
logger.debug("setting application_name on checkout failed", exc_info=True)
if settings.DB.TRACING:
event.listen(engine.sync_engine, "checkout", _set_application_name_on_checkout)
def get_pool_stats() -> dict[str, int]:
@ -107,184 +121,6 @@ def get_pool_stats() -> dict[str, int]:
return zeros
async def acquire_connection_with_retry(db: AsyncSession, context: str) -> None:
"""Force pool checkout (which ``SessionLocal()`` defers) with bounded backoff.
``SessionLocal()`` is lazy: the pool checkout and any pooler rejection
happens on the first query. We force it here inside a retry block so that
transient saturation of the transaction pooler is retried with exponential
backoff rather than surfacing as an immediate error. The checkout is wrapped
in a Sentry span so wait time is visible in traces; on budget exhaustion the
original error is reraised after capturing live pool stats to Sentry.
Each attempt rolls the session back on a retryable failure before retrying:
a failed checkout can leave the autobegun transaction in a pending-rollback
state, which would make the next ``db.connection()`` raise instead of
re-checking-out cleanly. The rollback is pure Python-side state cleanup when
no connection was bound, so it is cheap and safe.
"""
with sentry_sdk.start_span(op="db.pool.acquire", name=context):
if not settings.DB.CONNECTION_RETRY_ENABLED:
await db.connection()
return
attempts = 0
try:
async for attempt in AsyncRetrying(
wait=wait_exponential_jitter(
initial=settings.DB.CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS,
max=settings.DB.CONNECTION_RETRY_BACKOFF_MAX_SECONDS,
),
stop=stop_after_delay(settings.DB.CONNECTION_RETRY_MAX_DELAY_SECONDS),
retry=retry_if_exception_type(RETRYABLE_DB_CONNECTION_ERRORS),
reraise=True,
):
with attempt:
attempts += 1
try:
await db.connection()
except RETRYABLE_DB_CONNECTION_ERRORS:
# Reset session state so the next attempt starts clean.
try:
await db.rollback()
except Exception:
logger.debug(
"rollback after failed checkout failed",
exc_info=True,
)
raise
except RETRYABLE_DB_CONNECTION_ERRORS as e:
_record_acquisition_outcome("exhausted")
if settings.SENTRY.ENABLED:
sentry_sdk.set_context("db_pool", get_pool_stats())
sentry_sdk.capture_exception(e)
raise
# "ok" on first try, "retried" if backoff was needed before success.
_record_acquisition_outcome("ok" if attempts <= 1 else "retried")
class HonchoAsyncSession(AsyncSession):
"""AsyncSession that lazily checks out its connection, with retry.
The pool checkout and any pooler-rejection retry via
``acquire_connection_with_retry`` happens on the FIRST DB-touching call,
not at construction. So a request handler that does non-DB work (embedding,
file processing, an LLM call) before its first query does NOT pin a
connection across that work, while still getting checkout retry on the
request path.
Only the checkout is retried; the SQL statement itself runs exactly once
(we never retry ``super().execute`` after a broad OperationalError), so
writes are never duplicated. The context for tracing is read from the
``request_context`` ContextVar, which the request/task scope has already set.
"""
# Class-level default; per-instance assignment shadows it (the subclass has
# a __dict__ even though AsyncSession declares __slots__).
_honcho_acquired: bool = False
async def _ensure_acquired(self) -> None:
if self._honcho_acquired:
return
context = request_context.get() or "unknown"
await acquire_connection_with_retry(self, context)
self._honcho_acquired = True
if settings.DB.TRACING:
# Forced checkout already happened above; this rides the same
# connection. super() to avoid re-entering _ensure_acquired.
await super().execute(
text("SELECT set_config('application_name', :name, false)"),
{"name": context},
)
# The overrides below are thin: ensure the connection is checked out (once,
# with retry) before delegating to AsyncSession. They cover every public
# DB-touching async method so the "lazy retry on first DB use" guarantee has
# no holes. Signatures are widened to *args/**kwargs because we only forward;
# call sites are typed against the AsyncSession base, so this does not weaken
# type-checking elsewhere. (connection() is intentionally NOT wrapped —
# acquire_connection_with_retry calls it, so wrapping would recurse.)
async def execute(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().execute(*args, **kwargs)
async def scalar(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().scalar(*args, **kwargs)
async def scalars(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().scalars(*args, **kwargs)
async def get(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().get(*args, **kwargs)
async def get_one(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().get_one(*args, **kwargs)
async def stream(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().stream(*args, **kwargs)
async def stream_scalars(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().stream_scalars(*args, **kwargs)
async def flush(self, *args: Any, **kwargs: Any) -> None:
await self._ensure_acquired()
await super().flush(*args, **kwargs)
async def merge(self, *args: Any, **kwargs: Any) -> Any:
await self._ensure_acquired()
return await super().merge(*args, **kwargs)
async def refresh(self, *args: Any, **kwargs: Any) -> None:
await self._ensure_acquired()
await super().refresh(*args, **kwargs)
async def delete(self, *args: Any, **kwargs: Any) -> None:
await self._ensure_acquired()
await super().delete(*args, **kwargs)
async def commit(self) -> None:
# Ensures the add()->commit() path (autoflush on commit) also retries.
await self._ensure_acquired()
try:
await super().commit()
finally:
# Transaction ended; a later op must re-acquire (and re-wrap retry).
self._honcho_acquired = False
async def rollback(self) -> None:
try:
await super().rollback()
finally:
self._honcho_acquired = False
async def close(self) -> None:
try:
await super().close()
finally:
# The connection is released; a reused session must re-acquire.
self._honcho_acquired = False
async def reset(self) -> None:
try:
await super().reset()
finally:
self._honcho_acquired = False
SessionLocal = async_sessionmaker(
autocommit=False,
autoflush=False,
expire_on_commit=False,
bind=engine,
class_=HonchoAsyncSession,
)
class DBQueryInflightTracker:
"""Tracks statements executing on the wire via SQLAlchemy cursor events.
@ -340,8 +176,7 @@ def register_db_query_instrumentation(instance_type: str) -> None:
re-run lifespan or test startup) won't attach duplicate listeners, which
would double-count in-flight statements.
"""
global _db_instance_type, _inflight_tracker, _db_query_instrumentation_registered
_db_instance_type = instance_type
global _inflight_tracker, _db_query_instrumentation_registered
if not settings.METRICS.ENABLED or _db_query_instrumentation_registered:
return
child = db_queries_in_flight_gauge.labels(instance_type=instance_type)

View File

@ -10,11 +10,10 @@ from src.db import SessionLocal, request_context
async def get_db():
"""FastAPI Dependency Generator for Database.
The session is lazy: it does NOT check out a pooled connection here.
HonchoAsyncSession acquires it (with retry) on the first DB-touching call,
so a handler doing non-DB work (embedding/file/LLM) before its first query
does not pin a connection across it. Tracing's application_name and the
checkout retry both live in the session, keyed off request_context.
The session is lazy: it does NOT check out a pooled connection here. The
AsyncSession checks one out on the first DB-touching call, so a handler doing
non-DB work (embedding/file/LLM) before its first query does not pin a
connection across it.
"""
db: AsyncSession = SessionLocal()
try:

View File

@ -1,4 +1,6 @@
import asyncio
import contextlib
import random
import signal
from asyncio import Task
from collections.abc import Sequence
@ -205,6 +207,7 @@ class QueueManager:
# Run the polling loop directly in this task
logger.debug("Starting polling loop directly")
try:
await self._sleep_startup_jitter()
await self.polling_loop()
finally:
await self.cleanup()
@ -391,6 +394,35 @@ class QueueManager:
"""Snap the polling interval back to the base after finding work."""
self._current_poll_interval = settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS
def _jitter(self, seconds: float) -> float:
"""Scatter a sleep by +/- POLLING_JITTER_RATIO to avoid lockstep polling.
Returns a uniform-random value in [(1-ratio)*seconds, (1+ratio)*seconds].
Only the returned sleep is scattered; the underlying backoff schedule is
left unchanged. A ratio of 0.0 returns ``seconds`` unchanged.
"""
ratio = settings.DERIVER.POLLING_JITTER_RATIO
if ratio <= 0.0:
return seconds
# Scheduling jitter, not security/crypto — stdlib random is appropriate.
return seconds * random.uniform(1.0 - ratio, 1.0 + ratio) # nosec B311
async def _sleep_startup_jitter(self) -> None:
"""Sleep a random delay before the first poll so instances that start
together don't poll in lockstep. Interruptible by shutdown so a signal
during the delay exits promptly. No-op when the window is 0.0.
"""
window = settings.DERIVER.POLLING_STARTUP_JITTER_SECONDS
if window <= 0.0:
return
# Scheduling jitter, not security/crypto — stdlib random is appropriate.
delay = random.uniform(0.0, window) # nosec B311
logger.debug(f"Startup poll jitter: sleeping {delay:.1f}s before first poll")
# Timeout (slept the full delay without a shutdown) is the normal path;
# an early return means shutdown fired and polling_loop will exit at once.
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.shutdown_event.wait(), timeout=delay)
def _advance_poll_interval(self) -> float:
"""Return the current idle/backoff sleep, then grow it toward the cap."""
interval = self._current_poll_interval
@ -400,7 +432,7 @@ class QueueManager:
* settings.DERIVER.POLLING_BACKOFF_MULTIPLIER,
settings.DERIVER.POLLING_SLEEP_MAX_INTERVAL_SECONDS,
)
return interval
return self._jitter(interval)
async def polling_loop(self) -> None:
"""Main polling loop to find and process new work units"""
@ -419,7 +451,9 @@ class QueueManager:
# when capacity frees rather than backing off.
if self.semaphore.locked():
# logger.debug("All workers busy, waiting")
await asyncio.sleep(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
await asyncio.sleep(
self._jitter(settings.DERIVER.POLLING_SLEEP_INTERVAL_SECONDS)
)
continue
try:

View File

@ -119,8 +119,7 @@ if SENTRY_ENABLED:
FastApiIntegration(
transaction_style="endpoint",
),
# Explicit so DB-query spans (and our db.pool.acquire span) are not
# reliant on auto-enabling.
# Explicit so DB-query spans are not reliant on auto-enabling.
SqlalchemyIntegration(),
],
before_send=before_send,

View File

@ -127,17 +127,9 @@ telemetry_buffer_size_gauge = NamespacedGauge(
["namespace"],
)
# DB connection-pool health. The acquisitions counter measures how often we hit
# (and retry through) transaction-pooler saturation; the in-flight gauge counts
# statements actually executing on the wire, so checked_out minus in_flight
# reveals connections held but parked (the "idle in transaction during an
# external call" antipattern).
db_connection_acquisitions_counter = NamespacedCounter(
"db_connection_acquisitions",
"DB connection acquisitions by outcome (ok=first try, retried, exhausted)",
["namespace", "instance_type", "outcome"],
)
# DB connection-pool health. The in-flight gauge counts statements actually
# executing on the wire, so checked_out minus in_flight reveals connections held
# but parked (the "idle in transaction during an external call" antipattern).
db_queries_in_flight_gauge = NamespacedGauge(
"db_queries_in_flight",
"DB statements currently executing on a connection for this instance",
@ -294,18 +286,6 @@ class PrometheusMetrics:
except Exception as e:
self._handle_metric_error("set_telemetry_buffer_size", e)
def record_db_connection_acquisition(
self, *, instance_type: str, outcome: str
) -> None:
# outcome is one of "ok" | "retried" | "exhausted".
try:
db_connection_acquisitions_counter.labels(
instance_type=instance_type,
outcome=outcome,
).inc()
except Exception as e:
self._handle_metric_error("record_db_connection_acquisition", e)
prometheus_metrics = PrometheusMetrics()

View File

@ -1,3 +1,4 @@
import asyncio
from collections.abc import Callable
from typing import Any
from unittest.mock import patch
@ -1583,3 +1584,62 @@ class TestQueueProcessing:
claimed = await qm.get_and_claim_work_units()
assert work_unit_key is not None
assert work_unit_key in claimed
class TestPollingJitter:
"""Polling jitter: desynchronize poll loops without changing the schedule."""
def test_jitter_stays_within_ratio_bounds(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.5)
qm = QueueManager()
samples = [qm._jitter(10.0) for _ in range(1000)] # pyright: ignore[reportPrivateUsage]
assert all(5.0 <= s <= 15.0 for s in samples)
# A 0.5 ratio over 1000 samples should produce real spread, not a constant.
assert max(samples) - min(samples) > 1.0
def test_jitter_ratio_zero_is_identity(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0)
qm = QueueManager()
assert all(qm._jitter(7.5) == 7.5 for _ in range(50)) # pyright: ignore[reportPrivateUsage]
def test_advance_jitters_return_but_keeps_deterministic_schedule(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.5)
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", True)
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0)
monkeypatch.setattr(
settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 30.0
)
qm = QueueManager()
# The underlying schedule advances 1 -> 2 -> 4 -> ... -> 30 deterministically;
# each returned sleep is jittered within [0.5x, 1.5x] of the pre-advance step.
expected_schedule = [1.0, 2.0, 4.0, 8.0, 16.0, 30.0, 30.0]
for step in expected_schedule:
returned = qm._advance_poll_interval() # pyright: ignore[reportPrivateUsage]
assert 0.5 * step <= returned <= 1.5 * step
@pytest.mark.asyncio
async def test_startup_jitter_disabled_returns_immediately(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "POLLING_STARTUP_JITTER_SECONDS", 0.0)
qm = QueueManager()
# Window 0.0 must not sleep at all.
await qm._sleep_startup_jitter() # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_startup_jitter_interrupted_by_shutdown(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings.DERIVER, "POLLING_STARTUP_JITTER_SECONDS", 300.0)
qm = QueueManager()
qm.shutdown_event.set()
# A shutdown already signalled must short-circuit the (long) jitter sleep.
await asyncio.wait_for(qm._sleep_startup_jitter(), timeout=1.0) # pyright: ignore[reportPrivateUsage]

View File

@ -1,248 +1,96 @@
"""Unit tests for DB connection resilience + observability.
These are DB-free: they exercise the retry helper against a fake session, the
deriver polling backoff math, and the in-flight gauge listeners directly.
These are DB-free: they exercise the application_name checkout hook against a
fake DBAPI connection, the deriver polling backoff math, and the in-flight gauge
listeners directly.
"""
from types import SimpleNamespace
from typing import Any
import pytest
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
import src.db as db_module
from src.config import settings
from src.db import DBQueryInflightTracker, acquire_connection_with_retry
from src.telemetry.prometheus.metrics import (
db_connection_acquisitions_counter,
db_queries_in_flight_gauge,
)
from src.db import DBQueryInflightTracker
from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge
def _make_operational_error() -> OperationalError:
"""A stand-in for how a saturated pooler surfaces ('too many clients')."""
return OperationalError("SELECT 1", {}, Exception("too many clients"))
class _FlakyConnSession:
"""Fake AsyncSession whose connection() fails N times then succeeds."""
def __init__(self, fail_times: int, *, always_fail: bool = False) -> None:
self.fail_times: int = fail_times
self.always_fail: bool = always_fail
self.calls: int = 0
self.rollback_calls: int = 0
async def connection(self) -> None:
self.calls += 1
if self.always_fail or self.calls <= self.fail_times:
raise _make_operational_error()
async def rollback(self) -> None:
self.rollback_calls += 1
def _acq_count(outcome: str) -> float:
child = db_connection_acquisitions_counter.labels(
instance_type="api", outcome=outcome
)
return float(child._value.get()) # pyright: ignore[reportPrivateUsage, reportUnknownArgumentType]
@pytest.fixture
def metrics_on(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(settings.METRICS, "ENABLED", True)
monkeypatch.setattr(settings.METRICS, "NAMESPACE", "test")
monkeypatch.setattr(db_module, "_db_instance_type", "api")
# Fast, deterministic backoff so retry tests don't sleep.
monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_ENABLED", True)
monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS", 0.001)
monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_BACKOFF_MAX_SECONDS", 0.01)
@pytest.mark.asyncio
@pytest.mark.usefixtures("metrics_on")
async def test_acquire_succeeds_first_try_records_ok() -> None:
before = _acq_count("ok")
session = _FlakyConnSession(fail_times=0)
await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType]
assert session.calls == 1
assert _acq_count("ok") == before + 1
@pytest.mark.asyncio
@pytest.mark.usefixtures("metrics_on")
async def test_acquire_retries_then_succeeds_records_retried() -> None:
before = _acq_count("retried")
session = _FlakyConnSession(fail_times=2)
await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType]
assert session.calls == 3 # two failures then success
# Session is reset after each failed checkout before the next attempt.
assert session.rollback_calls == 2
assert _acq_count("retried") == before + 1
@pytest.mark.asyncio
@pytest.mark.usefixtures("metrics_on")
async def test_acquire_exhausts_budget_reraises_and_records(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Tiny budget so the loop gives up quickly under sustained failure.
monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_MAX_DELAY_SECONDS", 0.05)
before = _acq_count("exhausted")
session = _FlakyConnSession(fail_times=0, always_fail=True)
with pytest.raises(OperationalError):
await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType]
assert session.calls >= 1
assert _acq_count("exhausted") == before + 1
@pytest.mark.asyncio
async def test_acquire_disabled_calls_once(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings.DB, "CONNECTION_RETRY_ENABLED", False)
session = _FlakyConnSession(fail_times=0)
await acquire_connection_with_retry(session, "request:test") # pyright: ignore[reportArgumentType]
assert session.calls == 1
# --- HonchoAsyncSession: lazy checkout with retry on first DB use ------------
@pytest.mark.asyncio
async def test_session_lazy_acquires_on_first_db_use(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No checkout at construction; acquisition (with retry) happens once on the
first execute, and the statement itself runs exactly once."""
monkeypatch.setattr(settings.DB, "TRACING", False)
acquired: list[str] = []
executed: list[Any] = []
async def fake_acquire(_session: Any, context: str) -> None:
acquired.append(context)
async def fake_super_execute(_self: Any, *args: Any, **_kw: Any) -> str:
executed.append(args[0] if args else None)
return "result"
monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire)
monkeypatch.setattr(AsyncSession, "execute", fake_super_execute)
def test_session_local_uses_vanilla_async_session() -> None:
"""Regression guard: no custom session subclass / acquisition logic.
Connection acquisition is a single lazy checkout owned by AsyncSession; there
must be no re-introduced eager-checkout or retry hooks on the session.
"""
session = db_module.SessionLocal()
assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage]
assert acquired == [] # constructing the session does NOT check out
result = await session.execute("SELECT 1")
assert result == "result"
assert acquired == ["unknown"] # acquired exactly once, on first use
assert session._honcho_acquired is True # pyright: ignore[reportPrivateUsage]
assert executed == ["SELECT 1"] # statement ran once (not retried)
await session.execute("SELECT 2")
assert acquired == ["unknown"] # still once — no re-acquire on later use
assert type(session) is AsyncSession
assert not hasattr(session, "_ensure_acquired")
assert not hasattr(session, "_honcho_acquired")
@pytest.mark.asyncio
async def test_session_tracing_sets_application_name_on_acquire(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings.DB, "TRACING", True)
calls: list[tuple[Any, ...]] = []
# --- application_name checkout hook ------------------------------------------
async def fake_acquire(_session: Any, _context: str) -> None:
return None
async def fake_super_execute(_self: Any, *args: Any, **_kw: Any) -> None:
calls.append(args)
class _FakeCursor:
def __init__(self, recorder: list[Any], raise_exc: Exception | None) -> None:
self.recorder: list[Any] = recorder
self.raise_exc: Exception | None = raise_exc
self.closed: bool = False
monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire)
monkeypatch.setattr(AsyncSession, "execute", fake_super_execute)
def execute(self, sql: str, params: Any = None) -> None:
if self.raise_exc is not None:
raise self.raise_exc
self.recorder.append((sql, params))
def close(self) -> None:
self.closed = True
class _FakeDBAPIConn:
def __init__(self, recorder: list[Any], raise_exc: Exception | None = None) -> None:
self._cursor: _FakeCursor = _FakeCursor(recorder, raise_exc)
def cursor(self) -> _FakeCursor:
return self._cursor
def test_checkout_hook_sets_application_name_from_request_context() -> None:
recorder: list[Any] = []
conn = _FakeDBAPIConn(recorder)
token = db_module.request_context.set("request:trace-ctx")
try:
session = db_module.SessionLocal()
await session.execute("SELECT 1")
db_module._set_application_name_on_checkout(conn, None, None) # pyright: ignore[reportPrivateUsage]
finally:
db_module.request_context.reset(token)
# First super().execute is the set_config, then the real statement.
assert any("set_config" in str(c[0]) for c in calls)
set_config_call = next(c for c in calls if "set_config" in str(c[0]))
assert set_config_call[1] == {"name": "request:trace-ctx"}
assert len(recorder) == 1
sql, params = recorder[0]
assert "set_config" in sql and "application_name" in sql
assert params == ("request:trace-ctx",)
assert conn._cursor.closed is True # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_session_lifecycle_methods_reset_acquired_flag(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_acquire(_session: Any, _context: str) -> None:
return None
def test_checkout_hook_defaults_to_unknown_without_context() -> None:
recorder: list[Any] = []
conn = _FakeDBAPIConn(recorder)
token = db_module.request_context.set(None)
try:
db_module._set_application_name_on_checkout(conn, None, None) # pyright: ignore[reportPrivateUsage]
finally:
db_module.request_context.reset(token)
async def noop(_self: Any) -> None:
return None
monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire)
for method in ("commit", "rollback", "close", "reset"):
monkeypatch.setattr(AsyncSession, method, noop)
session = db_module.SessionLocal()
await session.commit() # ensures acquired, commits, then resets
assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage]
# rollback/close/reset must each clear the flag so a reused session
# re-acquires (and re-wraps retry) on its next DB use.
for method in ("rollback", "close", "reset"):
session._honcho_acquired = True # pyright: ignore[reportPrivateUsage]
await getattr(session, method)()
assert session._honcho_acquired is False # pyright: ignore[reportPrivateUsage]
assert recorder[0][1] == ("unknown",)
@pytest.mark.asyncio
async def test_session_get_and_delete_also_acquire(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The lazy-acquire guarantee covers get/delete, not just execute."""
monkeypatch.setattr(settings.DB, "TRACING", False)
acquired: list[str] = []
async def fake_acquire(_session: Any, context: str) -> None:
acquired.append(context)
async def fake_get(_self: Any, *_a: Any, **_k: Any) -> str:
return "row"
async def fake_delete(_self: Any, *_a: Any, **_k: Any) -> None:
return None
monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire)
monkeypatch.setattr(AsyncSession, "get", fake_get)
monkeypatch.setattr(AsyncSession, "delete", fake_delete)
session = db_module.SessionLocal()
await session.get(object, 1)
await session.delete(object())
assert acquired == ["unknown"] # acquired once on the first DB-touching call
def test_checkout_hook_swallows_errors() -> None:
"""A failure tagging the connection must never break the checkout."""
conn = _FakeDBAPIConn([], raise_exc=RuntimeError("boom"))
# Must not raise.
db_module._set_application_name_on_checkout(conn, None, None) # pyright: ignore[reportPrivateUsage]
@pytest.mark.asyncio
async def test_get_db_does_not_acquire_at_dependency_entry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from src.dependencies import get_db as real_get_db
acquired: list[str] = []
async def fake_acquire(_session: Any, context: str) -> None:
acquired.append(context)
monkeypatch.setattr(db_module, "acquire_connection_with_retry", fake_acquire)
dep_gen = real_get_db()
await anext(dep_gen)
assert acquired == [] # yielding the session must not check out
await dep_gen.aclose()
assert acquired == [] # finally rollback/close must not check out either
# --- deriver polling backoff math --------------------------------------------
def test_polling_backoff_sequence_and_reset(monkeypatch: pytest.MonkeyPatch) -> None:
@ -250,6 +98,9 @@ def test_polling_backoff_sequence_and_reset(monkeypatch: pytest.MonkeyPatch) ->
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 30.0)
# Disable jitter so the schedule is asserted exactly (jitter is tested
# separately in tests/deriver/test_queue_processing.py::TestPollingJitter).
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0)
from src.deriver.queue_manager import QueueManager
@ -266,6 +117,7 @@ def test_polling_backoff_disabled_stays_constant(
) -> None:
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_ENABLED", False)
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0)
from src.deriver.queue_manager import QueueManager
@ -273,29 +125,6 @@ def test_polling_backoff_disabled_stays_constant(
assert [qm._advance_poll_interval() for _ in range(3)] == [1.0, 1.0, 1.0] # pyright: ignore[reportPrivateUsage]
def test_pool_timeout_must_be_under_retry_budget() -> None:
"""A pooled QueuePool checkout that can block past the retry budget is a
contradiction (no retry ever happens) and must fail config validation."""
from src.config import DBSettings
# Contradiction: pooled + retry on, but POOL_TIMEOUT >= budget.
with pytest.raises(ValueError, match="DB_POOL_TIMEOUT"):
DBSettings(
POOL_CLASS="default",
POOL_TIMEOUT=30,
CONNECTION_RETRY_ENABLED=True,
CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0,
)
# NullPool has no queue wait, so POOL_TIMEOUT is irrelevant -> allowed.
DBSettings(
POOL_CLASS="null",
POOL_TIMEOUT=30,
CONNECTION_RETRY_ENABLED=True,
CONNECTION_RETRY_MAX_DELAY_SECONDS=10.0,
)
@pytest.mark.asyncio
async def test_polling_loop_idle_sleeps_once_per_cycle(
monkeypatch: pytest.MonkeyPatch,
@ -306,6 +135,7 @@ async def test_polling_loop_idle_sleeps_once_per_cycle(
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_INTERVAL_SECONDS", 1.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_BACKOFF_MULTIPLIER", 2.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_SLEEP_MAX_INTERVAL_SECONDS", 8.0)
monkeypatch.setattr(settings.DERIVER, "POLLING_JITTER_RATIO", 0.0)
import asyncio

View File

@ -40,8 +40,8 @@ class FakeSession:
async def test_get_db_yields_lazily_without_checkout_or_tracing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# get_db must NOT touch the connection or run set_config itself — those now
# happen lazily inside HonchoAsyncSession on first DB use, so a handler doing
# get_db must NOT touch the connection or run set_config itself — checkout
# happens lazily inside the AsyncSession on first DB use, so a handler doing
# non-DB work before its first query never pins a connection.
fake_db = FakeSession()
monkeypatch.setattr(dependencies_module, "SessionLocal", lambda: fake_db)

View File

@ -8,7 +8,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-05-27T18:30:19.790621Z"
exclude-newer = "2026-05-28T15:27:36.945866Z"
exclude-newer-span = "P5D"
[manifest]
@ -1159,7 +1159,7 @@ wheels = [
[[package]]
name = "honcho"
version = "3.0.8"
version = "3.0.9"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },