headroom/tests/test_integrations/test_litellm_callback.py
Abhinav Kumar Singh 07d89a751d
fix(litellm): close shared cloud client
## Description

Adds an explicit, idempotent async cleanup lifecycle for the LiteLLM
callback's shared cloud HTTP client.

Fixes #2894

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Added `HeadroomCallback.aclose()` to close the lazily-created
`httpx.AsyncClient` and clear its reference.
- Made cleanup safe when cloud mode was never used and when shutdown
cleanup is invoked more than once.
- Added regression coverage for initialized-client cleanup, reference
clearing, and repeated/no-op cleanup.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
python -m pytest -q tests/test_integrations/test_litellm_callback.py
5 passed

ruff check .
All checks passed!

ruff format --check .
1382 files already formatted

python -m mypy headroom
Success: no issues found in 515 source files

python -m pytest -q
Collection blocked in this Windows environment by 174 errors, primarily missing compiled headroom._core; one unrelated test also lacks respx.
```

## Real Behavior Proof

- Environment: Windows, Python 3.12, loopback HTTP server, real
`httpx.AsyncClient`.
- Exact command / steps: Started a local HTTP server, configured
`HeadroomCallback(api_key="hdr_test",
api_url="http://127.0.0.1:<port>")`, ran `_cloud_compress()` against it,
saved the created client, awaited `callback.aclose()`, then awaited
`callback.aclose()` again.
- Observed result: The real cloud request succeeded; the client was open
during the request, reported closed after `aclose()`, the callback
reference became `None`, and repeated cleanup was harmless.
- Who maintains it: Headroom Labs maintains this active upstream
repository and its LiteLLM integration.
- Install surface: No dependencies or install behavior changed. Cloud
mode continues to use the existing optional `httpx` dependency; no
native code or runtime network access is introduced by this fix.
- Not tested: The complete test suite could not run past collection
because the local Windows environment lacks the compiled
`headroom._core` extension.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] Documentation changes are not required; `aclose()` is documented
in its public docstring and the host owns shutdown sequencing
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes (full
suite blocked by missing native extension; targeted tests pass)
- [x] I did not edit `CHANGELOG.md` - it is generated by release-please
from my Conventional Commit PR title.

## Screenshots (if applicable)

Not applicable.

## Additional Notes

The callback exposes `aclose()` for the host application's async
shutdown lifecycle, matching the existing ASGI integration pattern.
2026-08-11 10:13:10 -07:00

82 lines
2.5 KiB
Python

"""Tests for headroom.integrations.litellm_callback."""
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
def _import_callback() -> type:
# Import the module directly to avoid triggering headroom/integrations/__init__.py
# which pulls in langchain and the native .so extension.
module_path = (
Path(__file__).resolve().parents[2] / "headroom" / "integrations" / "litellm_callback.py"
)
spec = importlib.util.spec_from_file_location(
"headroom.integrations.litellm_callback",
module_path,
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod.HeadroomCallback # type: ignore[attr-defined]
HeadroomCallback = _import_callback()
class TestHeadroomCallbackPostCallSuccessHook:
"""async_post_call_success_hook must exist and return response unchanged."""
def test_method_exists(self) -> None:
cb = HeadroomCallback()
assert hasattr(cb, "async_post_call_success_hook"), (
"HeadroomCallback must define async_post_call_success_hook "
"for LiteLLM proxy compatibility"
)
def test_method_is_coroutine(self) -> None:
cb = HeadroomCallback()
assert inspect.iscoroutinefunction(cb.async_post_call_success_hook)
@pytest.mark.asyncio
async def test_returns_response_unchanged(self) -> None:
cb = HeadroomCallback()
sentinel = object()
result = await cb.async_post_call_success_hook(
data={},
user_api_key_dict=None,
response=sentinel,
)
assert result is sentinel
class TestHeadroomCallbackClientLifecycle:
"""Cloud client cleanup must be explicit and safe to repeat."""
@pytest.mark.asyncio
async def test_aclose_closes_and_clears_initialized_client(self) -> None:
cb = HeadroomCallback(api_key="hdr_test")
client = MagicMock()
client.aclose = AsyncMock()
cb._client = client
await cb.aclose()
client.aclose.assert_awaited_once_with()
assert cb._client is None
await cb.aclose()
client.aclose.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_aclose_without_initialized_client_is_a_noop(self) -> None:
cb = HeadroomCallback(api_key="hdr_test")
await cb.aclose()
assert cb._client is None