-
Notifications
You must be signed in to change notification settings - Fork 50
645 - Add backoff and jitter to postgresql reconnect #1319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8084621
6f30197
8bf7a45
3c9d341
cfa521a
d24810c
7099b32
ffda5e4
d770785
8d6a435
9e9ab5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,8 +26,10 @@ | |
| import math | ||
| import types | ||
| import os | ||
| import random | ||
| import re | ||
| import threading | ||
| import time | ||
| import typing | ||
| from functools import wraps | ||
| from typing import Any, Callable, Dict, Generator, List, Literal, Mapping, Optional, Tuple, Type | ||
|
|
@@ -158,7 +160,7 @@ class PostgresConfig(pydantic.BaseModel): | |
| postgres_reconnect_retry: int = pydantic.Field( | ||
| default=5, | ||
| gt=0, | ||
| description='Reconnect try count after connection error', | ||
| description='Maximum total attempts for retryable PostgreSQL errors', | ||
| json_schema_extra={ | ||
| 'command_line': 'postgres_reconnect_retry', | ||
| 'env': 'OSMO_POSTGRES_RECONNECT_RETRY' | ||
|
|
@@ -213,6 +215,74 @@ class PostgresConfig(pydantic.BaseModel): | |
| json_schema_extra={'command_line': 'schema_version', 'env': 'OSMO_SCHEMA_VERSION'}) | ||
|
|
||
|
|
||
| _POSTGRES_RETRY_BASE_DELAY_SECONDS = 0.1 | ||
| _POSTGRES_RETRY_MAX_DELAY_SECONDS = 2.0 | ||
| _POSTGRES_RETRY_MAX_EXPONENT = 5 | ||
| _TRANSIENT_TRANSACTION_SQLSTATES = frozenset({'40001', '40P01'}) | ||
|
|
||
|
|
||
| def _get_postgres_sqlstate(error: Exception) -> str | None: | ||
| sqlstate = getattr(error, 'pgcode', None) | ||
| return sqlstate if isinstance(sqlstate, str) else None | ||
|
|
||
|
|
||
| def _get_retry_delay(retry_number: int) -> float: | ||
| exponent = min(retry_number - 1, _POSTGRES_RETRY_MAX_EXPONENT) | ||
| window = min( | ||
| _POSTGRES_RETRY_MAX_DELAY_SECONDS, | ||
| _POSTGRES_RETRY_BASE_DELAY_SECONDS * 2**exponent) | ||
| return window / 2 + random.random() * window / 2 | ||
|
|
||
|
|
||
| def _is_transient_postgres_error(error: Exception) -> bool: | ||
| if isinstance(error, (psycopg2.InterfaceError, psycopg2.pool.PoolError)): | ||
| return True | ||
|
|
||
| sqlstate = _get_postgres_sqlstate(error) | ||
| if isinstance(error, psycopg2.OperationalError) and sqlstate is None: | ||
| return True | ||
| return sqlstate in _TRANSIENT_TRANSACTION_SQLSTATES or bool( | ||
| sqlstate and sqlstate.startswith('08')) | ||
|
|
||
|
|
||
| def _requires_pool_reconnect(error: Exception) -> bool: | ||
| if isinstance(error, (osmo_errors.OSMOConnectionError, psycopg2.InterfaceError, | ||
| psycopg2.pool.PoolError)): | ||
| return True | ||
|
|
||
| sqlstate = _get_postgres_sqlstate(error) | ||
| if isinstance(error, psycopg2.OperationalError) and sqlstate is None: | ||
| return True | ||
| return bool(sqlstate and sqlstate.startswith('08')) | ||
|
|
||
|
|
||
| def _log_postgres_retry(operation_name: str, attempt_number: int, | ||
| maximum_attempts: int, delay: float, error: Exception) -> None: | ||
| logging.error( | ||
| 'Retrying PostgreSQL operation %s: attempt %d/%d, delay %.3fs, ' | ||
| 'exception=%s, sqlstate=%s', | ||
| operation_name, | ||
| attempt_number, | ||
| maximum_attempts, | ||
| delay, | ||
| type(error).__name__, | ||
| _get_postgres_sqlstate(error), | ||
| ) | ||
|
|
||
|
|
||
| def _log_postgres_retry_exhausted(operation_name: str, attempt_number: int, | ||
| maximum_attempts: int, error: Exception) -> None: | ||
| logging.error( | ||
| 'PostgreSQL retry exhausted: operation=%s, attempt=%d/%d, ' | ||
| 'error_type=%s, sqlstate=%s', | ||
| operation_name, | ||
| attempt_number, | ||
| maximum_attempts, | ||
| type(error).__name__, | ||
| _get_postgres_sqlstate(error), | ||
| ) | ||
|
|
||
|
|
||
| def retry(func=None, *, reconnect: bool = True): | ||
| """ | ||
| Retry database operations in case of connection/pool errors. | ||
|
|
@@ -226,21 +296,64 @@ def decorator(fn): | |
| def retry_wrapper(*args, **kwargs): | ||
| self = args[0] | ||
| last_error: Exception | None = None | ||
| for _ in range(self.config.postgres_reconnect_retry): | ||
| delay: float | None = None | ||
| reconnect_pool = False | ||
| maximum_attempts = self.config.postgres_reconnect_retry | ||
| for attempt_number in range(1, maximum_attempts + 1): | ||
| if delay is not None: | ||
| time.sleep(delay) | ||
| if reconnect_pool: | ||
| try: | ||
| self.connect() | ||
| except osmo_errors.OSMOConnectionError as error: | ||
| if attempt_number == maximum_attempts: | ||
| _log_postgres_retry_exhausted( | ||
| fn.__name__, attempt_number, maximum_attempts, error) | ||
| raise osmo_errors.OSMODatabaseError( | ||
| f'Error: {str(error)}') from error | ||
| last_error = error | ||
| delay = _get_retry_delay(attempt_number) | ||
| reconnect_pool = True | ||
| _log_postgres_retry( | ||
| fn.__name__, attempt_number, maximum_attempts, delay, error) | ||
| continue | ||
| except (psycopg2.InterfaceError, psycopg2.DatabaseError, | ||
| psycopg2.pool.PoolError) as error: | ||
| if not _is_transient_postgres_error(error): | ||
| raise osmo_errors.OSMODatabaseError( | ||
| f'Error: {str(error)}') from error | ||
| if attempt_number == maximum_attempts: | ||
| _log_postgres_retry_exhausted( | ||
| fn.__name__, attempt_number, maximum_attempts, error) | ||
| raise osmo_errors.OSMODatabaseError( | ||
| f'Error: {str(error)}') from error | ||
| last_error = error | ||
| delay = _get_retry_delay(attempt_number) | ||
| reconnect_pool = True | ||
| _log_postgres_retry( | ||
| fn.__name__, attempt_number, maximum_attempts, delay, error) | ||
| continue | ||
| try: | ||
| return fn(*args, **kwargs) | ||
| except (psycopg2.InterfaceError, psycopg2.DatabaseError, | ||
| psycopg2.pool.PoolError) as error: | ||
| logging.error('Database/pool error, retrying: %s', str(error)) | ||
| if not _is_transient_postgres_error(error): | ||
| raise osmo_errors.OSMODatabaseError(f'Error: {str(error)}') from error | ||
| if attempt_number == maximum_attempts: | ||
| _log_postgres_retry_exhausted( | ||
| fn.__name__, attempt_number, maximum_attempts, error) | ||
| raise osmo_errors.OSMODatabaseError(f'Error: {str(error)}') from error | ||
| last_error = error | ||
| if reconnect: | ||
| self.connect() | ||
| delay = _get_retry_delay(attempt_number) | ||
| reconnect_pool = reconnect and _requires_pool_reconnect(error) | ||
| _log_postgres_retry( | ||
| fn.__name__, attempt_number, maximum_attempts, delay, error) | ||
|
Comment on lines
336
to
+350
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Do not replay mutating operations after ambiguous connection failures. A SQLSTATE Add replay-safety metadata to The Go retry contract already has 🧰 Tools🪛 Ruff (0.16.1)[warning] 341-341: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 345-345: Use explicit conversion flag Replace with conversion flag (RUF010) 🤖 Prompt for AI Agents |
||
| except osmo_errors.OSMOError as error: | ||
| raise error | ||
| except Exception as error: # pylint: disable=broad-except | ||
| raise osmo_errors.OSMODatabaseError(f'Error: {str(error)}') | ||
| if last_error: | ||
| raise osmo_errors.OSMODatabaseError(f'Error: {str(last_error)}') | ||
| raise osmo_errors.OSMODatabaseError(f'Error: {str(last_error)}') from last_error | ||
|
Comment on lines
+308
to
+356
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Do not expose raw database errors in Each new 🧰 Tools🪛 Ruff (0.16.1)[warning] 313-313: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 324-324: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 329-329: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 341-341: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 345-345: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 353-353: Do not catch blind exception: (BLE001) [warning] 354-354: Within an (B904) [warning] 354-354: Use explicit conversion flag Replace with conversion flag (RUF010) [warning] 356-356: Use explicit conversion flag Replace with conversion flag (RUF010) 🤖 Prompt for AI Agents |
||
| return retry_wrapper | ||
| if func is None: | ||
| return decorator | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document the new Authz overrides.
gateway.authz.extraArgsis now a public chart value, butdeployments/charts/service/README.mddoes not list it in the Gateway Authz table. Document its default, expected list-of-string format, and a supported argument example. Documentgateway.authz.extraEnvthere as well if it is newly exposed by this change.🤖 Prompt for AI Agents