Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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 .
Expand Down
6 changes: 5 additions & 1 deletion deadline_budget/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions deadline_budget/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
5 changes: 4 additions & 1 deletion deadline_budget/contrib/dishka.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion deadline_budget/contrib/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from pydantic import BaseModel, Field
from pydantic import AliasChoices, BaseModel, Field


class OperationDeadlineConfig(BaseModel):
Expand All @@ -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)",
)

Expand Down
22 changes: 13 additions & 9 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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`,
Expand Down
27 changes: 25 additions & 2 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`).
Expand Down
12 changes: 12 additions & 0 deletions docs/guide/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]`
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/contrib/test_dishka.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/contrib/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down