fastapi-toolsets 4.0.0__tar.gz → 4.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/PKG-INFO +1 -1
  2. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/pyproject.toml +1 -1
  3. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/__init__.py +1 -1
  4. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/crud/factory.py +37 -14
  5. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/db.py +105 -2
  6. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/__init__.py +4 -0
  7. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/exceptions.py +29 -0
  8. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/LICENSE +0 -0
  9. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/README.md +0 -0
  10. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/_imports.py +0 -0
  11. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/__init__.py +0 -0
  12. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/app.py +0 -0
  13. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  14. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
  15. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/config.py +0 -0
  16. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  17. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/cli/utils.py +0 -0
  18. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/crud/__init__.py +0 -0
  19. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/crud/search.py +0 -0
  20. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/dependencies.py +0 -0
  21. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  22. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/__init__.py +0 -0
  23. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  24. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/registry.py +0 -0
  25. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/fixtures/utils.py +0 -0
  26. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/logger.py +0 -0
  27. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  28. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/metrics/handler.py +0 -0
  29. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/metrics/registry.py +0 -0
  30. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/models/__init__.py +0 -0
  31. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/models/columns.py +0 -0
  32. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/models/watched.py +0 -0
  33. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/py.typed +0 -0
  34. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  35. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/pytest/plugin.py +0 -0
  36. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/pytest/utils.py +0 -0
  37. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/schemas.py +0 -0
  38. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/__init__.py +0 -0
  39. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/abc.py +0 -0
  40. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/oauth.py +0 -0
  41. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
  42. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
  43. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
  44. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/header.py +0 -0
  45. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/security/sources/multi.py +0 -0
  46. {fastapi_toolsets-4.0.0 → fastapi_toolsets-4.1.1}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 4.0.0
3
+ Version: 4.1.1
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "4.0.0"
3
+ version = "4.1.1"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,4 +21,4 @@ Example usage:
21
21
  return Response(data={"user": user.username}, message="Success")
22
22
  """
23
23
 
24
- __version__ = "4.0.0"
24
+ __version__ = "4.1.1"
@@ -10,7 +10,7 @@ from collections.abc import Awaitable, Callable, Sequence
10
10
  from datetime import date, datetime
11
11
  from decimal import Decimal
12
12
  from enum import Enum
13
- from typing import Any, ClassVar, Generic, Literal, Self, cast, overload
13
+ from typing import Any, ClassVar, Generic, Literal, Self, TypeAlias, cast, overload
14
14
 
15
15
  from fastapi import Query
16
16
  from pydantic import BaseModel
@@ -52,6 +52,19 @@ from .search import (
52
52
  )
53
53
 
54
54
 
55
+ _ForUpdateMode: TypeAlias = bool | Literal["nowait", "skip_locked"]
56
+
57
+
58
+ def _apply_for_update(q: Any, mode: _ForUpdateMode) -> Any:
59
+ if not mode:
60
+ return q
61
+ if mode == "nowait":
62
+ return q.with_for_update(nowait=True)
63
+ if mode == "skip_locked":
64
+ return q.with_for_update(skip_locked=True)
65
+ return q.with_for_update()
66
+
67
+
55
68
  class _CursorDirection(str, Enum):
56
69
  NEXT = "next"
57
70
  PREV = "prev"
@@ -733,7 +746,7 @@ class AsyncCrud(Generic[ModelType]):
733
746
  *,
734
747
  joins: JoinType | None = None,
735
748
  outer_join: bool = False,
736
- with_for_update: bool = False,
749
+ with_for_update: _ForUpdateMode = False,
737
750
  load_options: Sequence[ExecutableOption] | None = None,
738
751
  schema: type[SchemaType],
739
752
  ) -> Response[SchemaType]: ...
@@ -747,7 +760,7 @@ class AsyncCrud(Generic[ModelType]):
747
760
  *,
748
761
  joins: JoinType | None = None,
749
762
  outer_join: bool = False,
750
- with_for_update: bool = False,
763
+ with_for_update: _ForUpdateMode = False,
751
764
  load_options: Sequence[ExecutableOption] | None = None,
752
765
  schema: None = ...,
753
766
  ) -> ModelType: ...
@@ -760,7 +773,7 @@ class AsyncCrud(Generic[ModelType]):
760
773
  *,
761
774
  joins: JoinType | None = None,
762
775
  outer_join: bool = False,
763
- with_for_update: bool = False,
776
+ with_for_update: _ForUpdateMode = False,
764
777
  load_options: Sequence[ExecutableOption] | None = None,
765
778
  schema: type[BaseModel] | None = None,
766
779
  ) -> ModelType | Response[Any]:
@@ -805,7 +818,7 @@ class AsyncCrud(Generic[ModelType]):
805
818
  *,
806
819
  joins: JoinType | None = None,
807
820
  outer_join: bool = False,
808
- with_for_update: bool = False,
821
+ with_for_update: _ForUpdateMode = False,
809
822
  load_options: Sequence[ExecutableOption] | None = None,
810
823
  schema: type[SchemaType],
811
824
  ) -> Response[SchemaType] | None: ...
@@ -819,7 +832,7 @@ class AsyncCrud(Generic[ModelType]):
819
832
  *,
820
833
  joins: JoinType | None = None,
821
834
  outer_join: bool = False,
822
- with_for_update: bool = False,
835
+ with_for_update: _ForUpdateMode = False,
823
836
  load_options: Sequence[ExecutableOption] | None = None,
824
837
  schema: None = ...,
825
838
  ) -> ModelType | None: ...
@@ -832,7 +845,7 @@ class AsyncCrud(Generic[ModelType]):
832
845
  *,
833
846
  joins: JoinType | None = None,
834
847
  outer_join: bool = False,
835
- with_for_update: bool = False,
848
+ with_for_update: _ForUpdateMode = False,
836
849
  load_options: Sequence[ExecutableOption] | None = None,
837
850
  schema: type[BaseModel] | None = None,
838
851
  ) -> ModelType | Response[Any] | None:
@@ -864,8 +877,7 @@ class AsyncCrud(Generic[ModelType]):
864
877
  q = q.where(and_(*filters))
865
878
  if resolved := cls._resolve_load_options(load_options):
866
879
  q = q.options(*resolved)
867
- if with_for_update:
868
- q = q.with_for_update()
880
+ q = _apply_for_update(q, with_for_update)
869
881
  result = await session.execute(q)
870
882
  item = result.unique().scalar_one_or_none()
871
883
  if item is None:
@@ -884,7 +896,7 @@ class AsyncCrud(Generic[ModelType]):
884
896
  *,
885
897
  joins: JoinType | None = None,
886
898
  outer_join: bool = False,
887
- with_for_update: bool = False,
899
+ with_for_update: _ForUpdateMode = False,
888
900
  load_options: Sequence[ExecutableOption] | None = None,
889
901
  schema: type[SchemaType],
890
902
  ) -> Response[SchemaType] | None: ...
@@ -898,7 +910,7 @@ class AsyncCrud(Generic[ModelType]):
898
910
  *,
899
911
  joins: JoinType | None = None,
900
912
  outer_join: bool = False,
901
- with_for_update: bool = False,
913
+ with_for_update: _ForUpdateMode = False,
902
914
  load_options: Sequence[ExecutableOption] | None = None,
903
915
  schema: None = ...,
904
916
  ) -> ModelType | None: ...
@@ -911,7 +923,7 @@ class AsyncCrud(Generic[ModelType]):
911
923
  *,
912
924
  joins: JoinType | None = None,
913
925
  outer_join: bool = False,
914
- with_for_update: bool = False,
926
+ with_for_update: _ForUpdateMode = False,
915
927
  load_options: Sequence[ExecutableOption] | None = None,
916
928
  schema: type[BaseModel] | None = None,
917
929
  ) -> ModelType | Response[Any] | None:
@@ -937,8 +949,7 @@ class AsyncCrud(Generic[ModelType]):
937
949
  q = q.where(and_(*filters))
938
950
  if resolved := cls._resolve_load_options(load_options):
939
951
  q = q.options(*resolved)
940
- if with_for_update:
941
- q = q.with_for_update()
952
+ q = _apply_for_update(q, with_for_update)
942
953
  result = await session.execute(q)
943
954
  item = result.unique().scalars().first()
944
955
  if item is None:
@@ -956,6 +967,7 @@ class AsyncCrud(Generic[ModelType]):
956
967
  filters: list[Any] | None = None,
957
968
  joins: JoinType | None = None,
958
969
  outer_join: bool = False,
970
+ with_for_update: _ForUpdateMode = False,
959
971
  load_options: Sequence[ExecutableOption] | None = None,
960
972
  order_by: OrderByClause | None = None,
961
973
  limit: int | None = None,
@@ -968,6 +980,9 @@ class AsyncCrud(Generic[ModelType]):
968
980
  filters: List of SQLAlchemy filter conditions
969
981
  joins: List of (model, condition) tuples for joining related tables
970
982
  outer_join: Use LEFT OUTER JOIN instead of INNER JOIN
983
+ with_for_update: Lock rows for update. ``True`` for plain ``FOR UPDATE``,
984
+ ``"nowait"`` for ``FOR UPDATE NOWAIT``, ``"skip_locked"`` for
985
+ ``FOR UPDATE SKIP LOCKED``.
971
986
  load_options: SQLAlchemy loader options
972
987
  order_by: Column or list of columns to order by
973
988
  limit: Max number of rows to return
@@ -982,6 +997,7 @@ class AsyncCrud(Generic[ModelType]):
982
997
  q = q.where(and_(*filters))
983
998
  if resolved := cls._resolve_load_options(load_options):
984
999
  q = q.options(*resolved)
1000
+ q = _apply_for_update(q, with_for_update)
985
1001
  if order_by is not None:
986
1002
  q = q.order_by(order_by)
987
1003
  if offset is not None:
@@ -1001,6 +1017,7 @@ class AsyncCrud(Generic[ModelType]):
1001
1017
  *,
1002
1018
  exclude_unset: bool = True,
1003
1019
  exclude_none: bool = False,
1020
+ with_for_update: _ForUpdateMode = False,
1004
1021
  schema: type[SchemaType],
1005
1022
  ) -> Response[SchemaType]: ...
1006
1023
 
@@ -1014,6 +1031,7 @@ class AsyncCrud(Generic[ModelType]):
1014
1031
  *,
1015
1032
  exclude_unset: bool = True,
1016
1033
  exclude_none: bool = False,
1034
+ with_for_update: _ForUpdateMode = False,
1017
1035
  schema: None = ...,
1018
1036
  ) -> ModelType: ...
1019
1037
 
@@ -1026,6 +1044,7 @@ class AsyncCrud(Generic[ModelType]):
1026
1044
  *,
1027
1045
  exclude_unset: bool = True,
1028
1046
  exclude_none: bool = False,
1047
+ with_for_update: _ForUpdateMode = False,
1029
1048
  schema: type[BaseModel] | None = None,
1030
1049
  ) -> ModelType | Response[Any]:
1031
1050
  """Update a record in the database.
@@ -1036,6 +1055,9 @@ class AsyncCrud(Generic[ModelType]):
1036
1055
  filters: List of SQLAlchemy filter conditions
1037
1056
  exclude_unset: Exclude fields not explicitly set in the schema
1038
1057
  exclude_none: Exclude fields with None value
1058
+ with_for_update: Lock the row before updating. ``True`` for plain
1059
+ ``FOR UPDATE``, ``"nowait"`` for ``FOR UPDATE NOWAIT``,
1060
+ ``"skip_locked"`` for ``FOR UPDATE SKIP LOCKED``.
1039
1061
  schema: Pydantic schema to serialize the result into. When provided,
1040
1062
  the result is automatically wrapped in a ``Response[schema]``.
1041
1063
 
@@ -1059,6 +1081,7 @@ class AsyncCrud(Generic[ModelType]):
1059
1081
  db_model = await cls.get(
1060
1082
  session=session,
1061
1083
  filters=filters,
1084
+ with_for_update=with_for_update,
1062
1085
  load_options=m2m_load_options or None,
1063
1086
  )
1064
1087
  values = obj.model_dump(
@@ -6,16 +6,26 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager
6
6
  from enum import Enum
7
7
  from typing import Any, TypeVar, cast
8
8
 
9
+ import asyncpg
9
10
  from sqlalchemy import Table, delete, text, tuple_
11
+ from sqlalchemy import exc as sa_exc
10
12
  from sqlalchemy.dialects.postgresql import insert as pg_insert
11
13
  from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
12
14
  from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
13
15
  from sqlalchemy.orm.relationships import RelationshipProperty
14
16
 
15
- from .exceptions import NotFoundError
17
+ from .exceptions import LockTimeoutError, NotFoundError, PoolExhaustedError
18
+
19
+
20
+ def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
21
+ return e.orig is not None and isinstance(
22
+ e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
23
+ )
24
+
16
25
 
17
26
  __all__ = [
18
27
  "LockMode",
28
+ "advisory_lock",
19
29
  "cleanup_tables",
20
30
  "create_database",
21
31
  "create_db_context",
@@ -64,7 +74,10 @@ def create_db_dependency(
64
74
 
65
75
  async def get_db() -> AsyncGenerator[_SessionT, None]:
66
76
  async with session_maker() as session:
67
- await session.connection()
77
+ try:
78
+ await session.connection()
79
+ except sa_exc.TimeoutError as e:
80
+ raise PoolExhaustedError() from e
68
81
  yield session
69
82
  if session.in_transaction():
70
83
  await session.commit()
@@ -197,6 +210,18 @@ def lock_tables(
197
210
  await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
198
211
  yield session
199
212
  await session.commit()
213
+ except sa_exc.TimeoutError as e:
214
+ await session.rollback()
215
+ raise PoolExhaustedError(
216
+ f"Connection pool exhausted while locking '{table_names}'. "
217
+ ) from e
218
+ except sa_exc.DBAPIError as e:
219
+ await session.rollback()
220
+ if _is_lock_not_available(e):
221
+ raise LockTimeoutError(
222
+ f"Lock on '{table_names}' could not be acquired within {timeout}."
223
+ ) from e
224
+ raise # pragma: no cover
200
225
  except BaseException:
201
226
  await session.rollback()
202
227
  raise
@@ -204,6 +229,84 @@ def lock_tables(
204
229
  return _lock()
205
230
 
206
231
 
232
+ @asynccontextmanager
233
+ async def advisory_lock(
234
+ session: AsyncSession,
235
+ key: int | tuple[int, int],
236
+ *,
237
+ shared: bool = False,
238
+ nowait: bool = False,
239
+ timeout: str | None = None,
240
+ ) -> AsyncGenerator[bool, None]:
241
+ """Acquire a PostgreSQL session-level advisory lock.
242
+
243
+ Args:
244
+ session: AsyncSession instance.
245
+ key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
246
+ shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
247
+ nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
248
+ timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
249
+ if exceeded. Ignored when *nowait* is ``True``.
250
+
251
+ Yields:
252
+ ``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
253
+ is already held.
254
+
255
+ Raises:
256
+ LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
257
+
258
+ Example:
259
+ ```python
260
+ from fastapi_toolsets.db import advisory_lock
261
+
262
+ async with advisory_lock(session, 42):
263
+ ...
264
+
265
+ async with advisory_lock(session, 42, nowait=True) as acquired:
266
+ if not acquired:
267
+ raise HTTPException(409, "Resource is locked")
268
+
269
+ async with advisory_lock(session, 42, timeout="5s"):
270
+ ...
271
+
272
+ async with advisory_lock(session, (1, user_id), shared=True):
273
+ ...
274
+ ```
275
+ """
276
+ suffix = "_shared" if shared else ""
277
+ acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
278
+ release_fn = f"pg_advisory_unlock{suffix}"
279
+
280
+ if isinstance(key, tuple):
281
+ k1, k2 = key
282
+ args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
283
+ params: dict[str, int] = {"k1": k1, "k2": k2}
284
+ else:
285
+ args = ":k"
286
+ params = {"k": key}
287
+
288
+ acquire_sql = text(f"SELECT {acquire_fn}({args})")
289
+ release_sql = text(f"SELECT {release_fn}({args})")
290
+
291
+ if timeout is not None and not nowait:
292
+ await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
293
+
294
+ try:
295
+ result = await session.execute(acquire_sql, params)
296
+ except sa_exc.DBAPIError as e:
297
+ if _is_lock_not_available(e):
298
+ raise LockTimeoutError(
299
+ f"Advisory lock {key!r} could not be acquired within {timeout}."
300
+ ) from e
301
+ raise # pragma: no cover
302
+ acquired = result.scalar() if nowait else True
303
+ try:
304
+ yield acquired
305
+ finally:
306
+ if acquired:
307
+ await session.execute(release_sql, params)
308
+
309
+
207
310
  async def create_database(
208
311
  db_name: str,
209
312
  *,
@@ -8,8 +8,10 @@ from .exceptions import (
8
8
  InvalidFacetFilterError,
9
9
  InvalidOrderFieldError,
10
10
  InvalidSearchColumnError,
11
+ LockTimeoutError,
11
12
  NoSearchableFieldsError,
12
13
  NotFoundError,
14
+ PoolExhaustedError,
13
15
  UnauthorizedError,
14
16
  UnsupportedFacetTypeError,
15
17
  generate_error_responses,
@@ -26,8 +28,10 @@ __all__ = [
26
28
  "InvalidFacetFilterError",
27
29
  "InvalidOrderFieldError",
28
30
  "InvalidSearchColumnError",
31
+ "LockTimeoutError",
29
32
  "NoSearchableFieldsError",
30
33
  "NotFoundError",
34
+ "PoolExhaustedError",
31
35
  "UnauthorizedError",
32
36
  "UnsupportedFacetTypeError",
33
37
  ]
@@ -223,6 +223,35 @@ class InvalidOrderFieldError(ApiException):
223
223
  )
224
224
 
225
225
 
226
+ class PoolExhaustedError(ApiException):
227
+ """HTTP 503 - Database connection pool is exhausted."""
228
+
229
+ api_error = ApiError(
230
+ code=503,
231
+ msg="Service Unavailable",
232
+ desc=(
233
+ "The database connection pool is exhausted. "
234
+ "Too many concurrent requests are holding connections. "
235
+ "Retry shortly or contact support if the issue persists."
236
+ ),
237
+ err_code="DB-503-POOL",
238
+ )
239
+
240
+
241
+ class LockTimeoutError(ApiException):
242
+ """HTTP 503 - A database lock could not be acquired within the timeout."""
243
+
244
+ api_error = ApiError(
245
+ code=503,
246
+ msg="Service Unavailable",
247
+ desc=(
248
+ "A database lock could not be acquired within the allowed timeout. "
249
+ "The resource is under heavy contention. Retry shortly."
250
+ ),
251
+ err_code="DB-503-LOCK",
252
+ )
253
+
254
+
226
255
  def generate_error_responses(
227
256
  *errors: type[ApiException],
228
257
  ) -> dict[int | str, dict[str, Any]]: