From 2637af651fe02777c0e5be370cfc9dc1d4fbbf2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 21:10:21 +0300 Subject: [PATCH 1/6] fix(settings): accept call_caps as an alias for calls_caps OperationDeadlineConfig spells the field calls_caps while BudgetContext.create takes call_caps. Pydantic ignores unknown keys, so OperationDeadlineConfig(call_caps={...}) produced a config with no caps at all and no complaint; the deadline factory then built every context for that operation uncapped. The field keeps its name -- it is what OperationDeadlineConfigProtocol requires and what existing configuration and dumps use -- and now accepts both spellings on input. --- deadline_budget/contrib/settings.py | 5 ++++- docs/agents.md | 11 ++++++----- docs/guide/integrations.md | 6 ++++++ tests/unit/contrib/test_settings.py | 23 +++++++++++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/deadline_budget/contrib/settings.py b/deadline_budget/contrib/settings.py index cb9c951..8236fcb 100644 --- a/deadline_budget/contrib/settings.py +++ b/deadline_budget/contrib/settings.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field class OperationDeadlineConfig(BaseModel): @@ -28,6 +28,9 @@ class OperationDeadlineConfig(BaseModel): ) calls_caps: dict[str, float] = Field( default_factory=dict, + # "call_caps" is the spelling BudgetContext uses; accepted here so the near-miss + # does not silently build a config with no caps at all. + validation_alias=AliasChoices("calls_caps", "call_caps"), description="Per-call timeout caps (key: call_name, value: seconds)", ) diff --git a/docs/agents.md b/docs/agents.md index 46d1773..7ed64c8 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -146,7 +146,7 @@ environment variables of their own. | Name | Fields | |---|---| -| `OperationDeadlineConfig` | `budget_timeout: float = 10.0` (1.0–60.0), `safety_margin: float | None = None` (0.0–5.0), `min_timeout: float | None = None` (0.01–1.0), `calls_caps: dict[str, float] = {}` | +| `OperationDeadlineConfig` | `budget_timeout: float = 10.0` (1.0–60.0), `safety_margin: float | None = None` (0.0–5.0), `min_timeout: float | None = None` (0.01–1.0), `calls_caps: dict[str, float] = {}` (also accepted as `call_caps` on input) | | `BaseDeadlineSettings` | `operations: dict[str, OperationDeadlineConfig] = {}`, `default_budget_timeout: float = 10.0`, `default_safety_margin: float = 0.5`, `default_min_timeout: float = 0.1` | `BaseDeadlineSettings.config_for_operation(operation)` returns the entry for that name, or @@ -250,10 +250,11 @@ watching the clock. 8. **An unknown `call_name` is not an error.** `timeout_for_call("typo")` returns the full remaining budget, uncapped. Caps are looked up with `dict.get`, so a misspelled or renamed key removes the ceiling instead of reporting it. Keep the keys in one constant. -9. **The settings field is `calls_caps`; the context argument is `call_caps`.** The names - differ by one letter and Pydantic ignores unknown keys by default, so - `OperationDeadlineConfig(call_caps={...})` builds a config with no caps at all and no - complaint. +9. **The settings field is `calls_caps`; the context argument is `call_caps`.** + `OperationDeadlineConfig` accepts either spelling as input and stores the value under + `calls_caps` — that is the name to read it back under, the name it dumps to, and the + name `OperationDeadlineConfigProtocol` requires. Every other field is matched exactly, + and Pydantic still ignores keys it does not know. 10. **`BaseDeadlineSettings` is a `BaseModel`, not a `BaseSettings`,** despite the extra being called `settings`. It reads no environment and no `.env`; nest it inside your own `pydantic_settings.BaseSettings` if you want that. diff --git a/docs/guide/integrations.md b/docs/guide/integrations.md index 3ce6e62..6a0dcee 100644 --- a/docs/guide/integrations.md +++ b/docs/guide/integrations.md @@ -9,6 +9,8 @@ Install with: `pip install deadline-budget[settings]` ### Configuration Classes ```python +from pydantic import BaseModel, Field + from deadline_budget.contrib.settings import BaseDeadlineSettings, OperationDeadlineConfig # Define settings @@ -57,6 +59,10 @@ ctx = BudgetContext.create( ) ``` +Note the spelling: the settings field is `calls_caps`, the `BudgetContext` argument is +`call_caps`. `OperationDeadlineConfig` takes either spelling as input and stores it under +`calls_caps`, which is the name to read it back under and the name it serialises to. + ## Dishka DI Provider Install with: `pip install deadline-budget[dishka]` diff --git a/tests/unit/contrib/test_settings.py b/tests/unit/contrib/test_settings.py index fee14b4..e36d920 100644 --- a/tests/unit/contrib/test_settings.py +++ b/tests/unit/contrib/test_settings.py @@ -43,6 +43,29 @@ def test__operation_config__with_custom_values__stores_all_fields() -> None: } +def test__operation_config__with_call_caps_spelling__populates_calls_caps() -> None: + # Arrange + caps = {"identity_create_user": 3.0} + + # Act + config = OperationDeadlineConfig(call_caps=caps) + + # Assert + assert config.calls_caps == caps + + +def test__operation_config__dump__keeps_calls_caps_as_the_field_name() -> None: + # Arrange + config = OperationDeadlineConfig(call_caps={"identity_create_user": 3.0}) + + # Act + dumped = config.model_dump() + + # Assert + assert dumped["calls_caps"] == {"identity_create_user": 3.0} + assert "call_caps" not in dumped + + @pytest.mark.parametrize( "invalid_timeout,reason", [ From 3c0a1d35691e108cc81a9bc03a72c30e860ec1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 21:11:33 +0300 Subject: [PATCH 2/6] fix(context): copy the call caps handed to BudgetContext The context stored the caller's dict, so ctx.call_caps handed back the very mapping it was built from. DeadlineContextFactory passes config.calls_caps straight through, which made the settings object -- APP-scoped, shared by every request -- writable through any context built from it: mutating ctx.call_caps changed the caps of every later request for that operation. The context now takes a copy. Mutating ctx.call_caps still changes that context's later calls; it no longer reaches anything else. --- deadline_budget/context.py | 5 +++-- docs/agents.md | 11 +++++++---- tests/unit/contrib/test_dishka.py | 22 ++++++++++++++++++++++ tests/unit/test_context.py | 27 +++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/deadline_budget/context.py b/deadline_budget/context.py index 95d9bc9..c2ad5fa 100644 --- a/deadline_budget/context.py +++ b/deadline_budget/context.py @@ -35,9 +35,10 @@ def __init__(self, budget: DeadlineBudget, call_caps: dict[str, float]) -> None: budget: The underlying DeadlineBudget instance. call_caps: Mapping of call names to their timeout caps in seconds. If a call is not in this dict, it will use remaining budget without cap. + Copied, so the context is not tied to the lifetime of the mapping given. """ self._budget: DeadlineBudget = budget - self._call_caps: dict[str, float] = call_caps + self._call_caps: dict[str, float] = dict(call_caps) @classmethod def create( @@ -115,5 +116,5 @@ def budget(self) -> DeadlineBudget: @property def call_caps(self) -> dict[str, float]: - """Access to configured call caps.""" + """Access to this context's own copy of the configured call caps.""" return self._call_caps diff --git a/docs/agents.md b/docs/agents.md index 7ed64c8..45f326a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -134,10 +134,10 @@ classmethod instead: | `timeout_for_call(call_name, reserve_for_next=0.0)` | `float` | `budget.timeout_for(cap=call_caps.get(call_name), reserve_for_next=…)`. An unknown name is not an error — it means no cap. | | `check_expired()` / `remaining()` / `elapsed()` / `expired()` | as above | Straight delegation to the budget. | | `budget` (property) | `DeadlineBudget` | The underlying budget, for anything the context does not expose. | -| `call_caps` (property) | `dict[str, float]` | The live mapping, not a copy. | +| `call_caps` (property) | `dict[str, float]` | The context's own mapping — a copy of what was passed in, live for every later call on this context. | There is no `timeout_for` on `BudgetContext` and no `call_caps` mutator; reach through -`ctx.budget` or edit the dict you passed in. +`ctx.budget`, or mutate `ctx.call_caps` in place. ### `deadline_budget.contrib.settings` — extra `settings`, needs Pydantic 2 @@ -265,8 +265,11 @@ watching the clock. 12. **A budget is immutable after construction**, so reading it from several tasks or threads is safe. What is not safe is assuming those readers are sharing the time: see fan-out above. -13. **`ctx.call_caps` is the live dict.** Mutating what you get back — or the dict you - passed to `create()` — changes the caps for every later call. +13. **`ctx.call_caps` is the context's own dict.** The mapping handed to `BudgetContext` + is copied at construction, so changing the dict you passed to `create()` afterwards + changes nothing — and a context built from settings cannot write back into them. + Mutating what the property returns does change the caps for every later call, on that + context only. 14. **The contrib modules are not re-exported.** `deadline_budget` exports exactly `DeadlineBudget`, `BudgetContext` and `DeadlineExceededError`. Everything else is imported from `deadline_budget.contrib.settings` or `deadline_budget.contrib.dishka`, diff --git a/tests/unit/contrib/test_dishka.py b/tests/unit/contrib/test_dishka.py index fd50c49..1e4a92e 100644 --- a/tests/unit/contrib/test_dishka.py +++ b/tests/unit/contrib/test_dishka.py @@ -233,6 +233,28 @@ def test__factory__create_for_unknown_operation__uses_default_config() -> None: assert ctx.call_caps == {} +def test__factory__context_caps_mutated__does_not_leak_into_the_next_context() -> None: + # Arrange + settings = MockSettings( + operations={ + "signup": MockOperationConfig( + budget_timeout=10.0, + calls_caps={"identity_create": 3.0}, + ), + } + ) + factory = DeadlineContextFactory(settings) + first = factory.create_for_operation("signup") + + # Act + first.call_caps["identity_create"] = 0.5 + + # Assert + second = factory.create_for_operation("signup") + assert second.call_caps == {"identity_create": 3.0} + assert settings.config_for_operation("signup").calls_caps == {"identity_create": 3.0} + + def test__deadline_provider__scope__is_app_scope() -> None: # Arrange provider = DeadlineProvider() diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index 8449128..77ff183 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -36,6 +36,33 @@ def test__budget_context__create_without_caps__creates_context_with_empty_caps() assert ctx.call_caps == {} +def test__budget_context__caps_mutated_after_create__keeps_the_caps_it_was_created_with() -> None: + # Arrange + call_caps = {"identity_create_user": 5.0} + ctx = BudgetContext.create(total_seconds=10.0, call_caps=call_caps) + + # Act + call_caps["identity_create_user"] = 0.5 + call_caps["credential_set_password"] = 0.5 + + # Assert + assert ctx.call_caps == {"identity_create_user": 5.0} + assert ctx.timeout_for_call("identity_create_user") == 5.0 + + +def test__budget_context__context_caps_mutated__leaves_the_caps_passed_in_untouched() -> None: + # Arrange + call_caps = {"identity_create_user": 5.0} + ctx = BudgetContext.create(total_seconds=10.0, call_caps=call_caps) + + # Act + ctx.call_caps["identity_create_user"] = 0.5 + + # Assert + assert call_caps == {"identity_create_user": 5.0} + assert ctx.timeout_for_call("identity_create_user") == 0.5 + + def test__budget_context__timeout_for_call_with_cap__applies_call_specific_cap() -> None: # Arrange call_caps = { From afc74076b1c2e3dc3abcb74d35cc3acc6b8e134f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 21:12:08 +0300 Subject: [PATCH 3/6] chore: install the optional extras in make install make install ran uv sync --group dev, which leaves Pydantic and Dishka out, so make test-unit -- the command CONTRIBUTING tells a contributor to run -- failed at collection on tests/unit/contrib with two ModuleNotFoundError. CI already syncs with --all-extras. --- CONTRIBUTING.md | 5 ++++- Makefile | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f842720..a50c258 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,10 +7,13 @@ Thank you for your interest in contributing! This document covers everything you ```bash git clone https://github.com/bedrock-python/deadline-budget.git cd deadline-budget -uv sync --group dev +uv sync --group dev --all-extras uv run pre-commit install --hook-type commit-msg ``` +`--all-extras` installs Pydantic and Dishka. They are optional for users of the library but +not for its test suite: without them `tests/unit/contrib/` fails at collection. + ## Running checks ```bash diff --git a/Makefile b/Makefile index 9dd8fd8..ac0f6e8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test test-unit test-integration fmt check build install docs-serve docs-build clean install: - uv sync --group dev + uv sync --group dev --all-extras fmt: uv run ruff format . From b9cc82a959892e472311e6cfa3af5940f2dc6d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 21:13:22 +0300 Subject: [PATCH 4/6] docs: say which of the budget, the floor and the cap wins The configuration guide said timeout_for(cap=...) returns min(cap, remaining), which holds only while remaining is above min_timeout. The floor outranks the remaining budget and the cap, applied last, outranks the floor -- neither was written down anywhere outside the agents page. Behaviour is unchanged; the guides and the two docstrings now describe it. --- deadline_budget/budget.py | 6 +++++- deadline_budget/context.py | 4 ++-- docs/guide/configuration.md | 27 +++++++++++++++++++++++++-- docs/guide/quickstart.md | 7 +++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/deadline_budget/budget.py b/deadline_budget/budget.py index 5114681..d2fd82e 100644 --- a/deadline_budget/budget.py +++ b/deadline_budget/budget.py @@ -71,13 +71,17 @@ def timeout_for( ) -> float: """Compute timeout for the next downstream call. + The remaining budget, less reserve_for_next, raised to min_timeout and then limited by cap. + Both bounds outrank what is left: min_timeout is returned even when the budget holds less + than that, and cap is applied last, so a cap below min_timeout wins over it. + Args: cap: Maximum allowed timeout for this call (service-level cap). min_timeout: Minimum timeout override (default: use budget min_timeout). reserve_for_next: Reserve this many seconds for subsequent steps. Returns: - Computed timeout in seconds, bounded by [min_timeout, cap]. + Computed timeout in seconds. May exceed the remaining budget when min_timeout does. Raises: DeadlineExceededError: If remaining budget is already exhausted. diff --git a/deadline_budget/context.py b/deadline_budget/context.py index c2ad5fa..830c826 100644 --- a/deadline_budget/context.py +++ b/deadline_budget/context.py @@ -77,8 +77,8 @@ def timeout_for_call(self, call_name: str, reserve_for_next: float = 0.0) -> flo reserve_for_next: Reserve this many seconds for subsequent steps. Returns: - Computed timeout in seconds, bounded by [min_timeout, call_cap] if cap exists, - or [min_timeout, remaining] if no cap configured. + Computed timeout in seconds, from DeadlineBudget.timeout_for with the configured cap. + May exceed the remaining budget when min_timeout does. Raises: DeadlineExceededError: If remaining budget is already exhausted. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 07b043b..8cedac8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -35,6 +35,10 @@ budget = DeadlineBudget(total_seconds=10.0, min_timeout=0.1) timeout = budget.timeout_for() ``` +The floor outranks the budget: `timeout_for()` returns `min_timeout` even when less than that +is left, so the last call before exhaustion can overrun the deadline by up to `min_timeout`. +That is what the safety margin pays for. + #### `safety_margin` (optional, default: 0.0) Reserve time subtracted from total budget to prevent deadline violations: @@ -53,10 +57,10 @@ budget = DeadlineBudget(total_seconds=10.0, safety_margin=0.5) Compute timeout for next downstream call. ```python -# No cap: returns remaining budget (or min_timeout if less) +# No cap: returns remaining budget (or min_timeout if remaining is less) timeout = budget.timeout_for() -# With cap: returns min(cap, remaining) +# With cap: returns min(cap, remaining), and min_timeout if remaining is less than that timeout = budget.timeout_for(cap=5.0) # Reserve budget for subsequent calls @@ -73,6 +77,22 @@ timeout = budget.timeout_for(min_timeout=0.5) **Returns:** `float` — timeout in seconds +**Precedence.** The whole computation is: + +```python +available = max(remaining - reserve_for_next, min_timeout) +timeout = min(available, cap) if cap is not None else available +``` + +Two consequences worth knowing before picking numbers: + +- **The floor outranks the budget.** With 0.4s left and `min_timeout=1.0`, `timeout_for()` + returns 1.0 — more time than the budget has. `reserve_for_next` disappears the same way: + when `remaining - reserve_for_next` falls under the floor, the floor wins and nothing is + reserved. +- **The cap outranks the floor.** The cap is applied last, so `timeout_for(cap=0.05)` with + `min_timeout=0.1` returns 0.05. A service-level cap is never widened to reach the floor. + ## BudgetContext Configuration ### Creation @@ -113,6 +133,9 @@ ctx = BudgetContext.create(total_seconds=10.0, call_caps=call_caps) - Configured calls: Uses `min(cap, remaining_budget)` - Unconfigured calls: Uses `remaining_budget` +Both go through `timeout_for()` and follow its precedence rules above, so `min_timeout` still +applies underneath and a cap below `min_timeout` still wins. + #### `min_timeout` (optional, default: 0.1) Minimum timeout (same as `DeadlineBudget`). diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 05aa153..65018d4 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -139,6 +139,9 @@ budget = DeadlineBudget(total_seconds=10.0, min_timeout=0.1) **Default:** 0.1 seconds +The floor wins over the remaining budget, so the last call before exhaustion can be granted +more time than is left. The safety margin pays for that. + ### `cap` Maximum timeout for a specific call: @@ -151,6 +154,10 @@ timeout = budget.timeout_for(cap=5.0) timeout = budget.timeout_for(cap=5.0) ``` +The cap is applied after the floor, so a cap below `min_timeout` wins over it: +`timeout_for(cap=0.05)` with `min_timeout=0.1` returns 0.05. The +[configuration guide](configuration.md) spells out the full precedence. + ### `reserve_for_next` Reserve budget for subsequent calls: From dcb4ace563b29916811a4517f44c884c3bf80ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 21:13:38 +0300 Subject: [PATCH 5/6] docs(dishka): DeadlineProvider provides the factory only The class docstring promised "DeadlineContextFactory and optionally per-request BudgetContext"; there is one provider on it and it returns the factory. --- deadline_budget/contrib/dishka.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deadline_budget/contrib/dishka.py b/deadline_budget/contrib/dishka.py index 53f5ba4..c01fbfc 100644 --- a/deadline_budget/contrib/dishka.py +++ b/deadline_budget/contrib/dishka.py @@ -95,7 +95,10 @@ def create_for_operation(self, operation: Any) -> BudgetContext: class DeadlineProvider(Provider): """Generic Dishka provider for request deadline budgeting. - Provides DeadlineContextFactory and optionally per-request BudgetContext. + Provides DeadlineContextFactory, and requires a DeadlineSettingsProtocol binding from one of + your own providers. It does not provide BudgetContext: a context belongs to one operation and + its countdown starts when it is built, so build it with DeadlineContextFactory.create_for_operation + where the request starts. """ scope = Scope.APP From dd6ff5c9054b0fd4303f8b26e607e6edd4075614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0Alex=20Shalaev?= Date: Sun, 6 Sep 2026 21:13:52 +0300 Subject: [PATCH 6/6] docs: settings models read no environment, and total_seconds is the usable budget Two things a reader could only find by opening the source: the models behind the settings extra are BaseModel and not BaseSettings, and DeadlineBudget.total_seconds reports the total minus the safety margin -- as does DeadlineExceededError.budget_seconds. --- docs/guide/integrations.md | 6 ++++++ docs/guide/quickstart.md | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/docs/guide/integrations.md b/docs/guide/integrations.md index 6a0dcee..9bffb69 100644 --- a/docs/guide/integrations.md +++ b/docs/guide/integrations.md @@ -6,6 +6,12 @@ Extensions for deadline-budget with popular libraries. Install with: `pip install deadline-budget[settings]` +Despite the name of the extra, `BaseDeadlineSettings` and `OperationDeadlineConfig` are plain +`pydantic.BaseModel` classes, not `pydantic_settings.BaseSettings`. They read no environment +variables and no `.env` file of their own; they hold the values you give them. Nest them in +whatever your application already loads configuration into — a `BaseSettings` subclass if that +is where your configuration comes from — as `AppSettings` does below. + ### Configuration Classes ```python diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 65018d4..57f93be 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -128,6 +128,10 @@ budget = DeadlineBudget(total_seconds=10.0, safety_margin=0.5) **Why use this?** Prevents returning timeouts that expire during network roundtrip. +What you get back is the usable budget, not the total you passed: `budget.total_seconds` is +9.5 here, and `DeadlineExceededError.budget_seconds` reports the same 9.5. Keep your own +constant if a log line or a metric needs the 10.0. + ### `min_timeout` Minimum timeout value returned by `timeout_for()`: