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 . 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 95d9bc9..830c826 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( @@ -76,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. @@ -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/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 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..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 @@ -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. @@ -264,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/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/integrations.md b/docs/guide/integrations.md index 3ce6e62..9bffb69 100644 --- a/docs/guide/integrations.md +++ b/docs/guide/integrations.md @@ -6,9 +6,17 @@ 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 +from pydantic import BaseModel, Field + from deadline_budget.contrib.settings import BaseDeadlineSettings, OperationDeadlineConfig # Define settings @@ -57,6 +65,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/docs/guide/quickstart.md b/docs/guide/quickstart.md index 05aa153..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()`: @@ -139,6 +143,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 +158,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: 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/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", [ 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 = {