Metadata-Version: 2.4
Name: nes-client-django
Version: 0.1.2
Summary: Fire-and-forget async Python client for the NES (Notification Sending Service) HTTP API, with a drop-in Django email backend.
Author: FPT platform team
License-Expression: Apache-2.0
Project-URL: Homepage, https://git.fpt.net/fli-backend/platform/nss
Project-URL: Documentation, https://git.fpt.net/fli-backend/platform/nss/-/tree/main/sdk/python
Project-URL: Source, https://git.fpt.net/fli-backend/platform/nss/-/tree/main/sdk/python
Project-URL: Issues, https://git.fpt.net/fli-backend/platform/nss/-/issues
Keywords: nes,notification,email,smtp,django,async,httpx
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Framework :: Django
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Provides-Extra: django
Requires-Dist: Django>=4.2; extra == "django"
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Requires-Dist: pytest-asyncio>=0.21; extra == "test"
Requires-Dist: Django>=4.2; extra == "test"
Dynamic: license-file

# nes-client-django

**Fire-and-forget** async Python SDK for the FPT **NES** notification API.

Design goal: the caller should never block on notification delivery. Every
send is scheduled on a background asyncio event loop (started lazily in a
daemon thread) with a **200ms** per-request timeout. If the enqueue takes
longer than that, the request is dropped and logged — the calling thread
already moved on.

The client also runs a small in-process **circuit breaker**: after 5
consecutive failures it stops making HTTP requests for 10 seconds and
drops sends instantly instead. When the backend recovers a single probe
call restores normal traffic. This bounds the CPU cost during a failure
storm — when NES is down, Django keeps flying.

## Install

```bash
pip install /path/to/nes-python-sdk           # local
# or
pip install "nes-client-django[django]"              # once published
```

## API surface

```python
from nes_client_django import NESClient, NESError, fire, NESEmailBackend, shutdown
```

- `NESClient` — plain async client (`await nss.send(...)`). 200ms timeout by default.
- `fire(coro)` — schedule any coroutine on the background loop, return immediately.
- `NESEmailBackend` — Django backend that wires `fire()` into `send_messages`.
- `shutdown()` — stop the background loop (called automatically at exit).

## Async use — inside asyncio / async Django views

```python
import asyncio
from nes_client_django import NESClient

async def main():
    async with NESClient(
        base_url="http://nss-api:8080",
        token="Bearer …",
        tenant_id="platform-cloud",
        # timeout=0.2 by default (200ms) — override if you need more time
    ) as nss:
        await nss.send(
            to="user@example.com",
            provider_id="prov-uuid",   # or channel="smtp"
            subject="Hi",
            body="Hello world",
        )

asyncio.run(main())
```

Errors are logged and swallowed by default. Opt into exceptions when you
actually care about the outcome:

```python
await nss.send(..., raise_on_error=True)
```

## Fire-and-forget from sync code (Django views, Celery tasks, scripts)

Use the top-level `fire()` — the coroutine runs on the shared background
loop and the call returns instantly:

```python
from nes_client_django import NESClient, fire

_client = NESClient(base_url="…", token="…", tenant_id="…", timeout=0.2)

def user_signed_up(user):
    fire(_client.send(
        to=user.email,
        provider_id="prov-uuid",
        subject="Welcome",
        body=f"Hi {user.name}",
    ))
    # returns in ~microseconds — HTTP happens on the background loop
```

## Django integration

Two ways to wire it. Pick either — SDK reads both.

### A) `settings.py` block (explicit)

```python
EMAIL_BACKEND = "nes_client_django.NESEmailBackend"

NES = {
    "BASE_URL":    "http://nss-api:8080",
    "TOKEN":       os.environ["NES_TOKEN"],
    "TENANT_ID":   "platform-cloud",
    "PROVIDER_ID": "prov-uuid",   # or "CHANNEL": "smtp"
    "TIMEOUT":     0.2,
    # Circuit breaker knobs (defaults shown). Set BREAKER_ENABLED to False
    # to disable — useful in tests.
    "BREAKER_ENABLED":      True,
    "BREAKER_THRESHOLD":    5,     # consecutive failures before opening
    "BREAKER_OPEN_SECONDS": 10.0,  # cooldown before HALF_OPEN probe
}
```

### B) Env vars only (no settings block needed)

Just export the env vars — SDK falls back to `NES_<KEY>` if `settings.NES`
is absent:

```bash
export NES_BASE_URL=http://nss-api:8080
export NES_TOKEN="Bearer …"
export NES_TENANT_ID=platform-cloud
export NES_PROVIDER_ID=prov-uuid
export NES_TIMEOUT=0.2
# Circuit breaker (optional — defaults are usually fine)
export NES_BREAKER_ENABLED=true
export NES_BREAKER_THRESHOLD=5
export NES_BREAKER_OPEN_SECONDS=10
```

```python
# settings.py — just this one line is enough
EMAIL_BACKEND = "nes_client_django.NESEmailBackend"
```

### Skip mode

If **neither** `settings.NES` nor `NES_BASE_URL` env is set, the backend
enters **silent skip mode**: `send_mail()` still returns the message count
(so app code that expects `sent == 1` keeps working) but nothing is sent
and no exception is raised. A one-time WARNING is logged so ops know the
backend is disarmed.

Then Django's stock helpers work — but never block the request:

```python
from django.core.mail import send_mail, EmailMultiAlternatives

def signup(request):
    User.objects.create(...)
    send_mail("Welcome!", "Thanks for joining.", None, [request.POST["email"]])
    return redirect("/thanks")   # HTTP request already dispatched on background loop
```

Bulk sends fan out onto the same loop — `send_messages([...])` returns
the message count instantly:

```python
from django.core.mail import EmailMessage, get_connection
with get_connection() as conn:
    conn.send_messages([EmailMessage("Hi", "body", to=[u.email]) for u in queryset])
```

## Templates

```python
await nss.send(
    to="user@example.com",
    provider_id="prov-uuid",
    template_id="welcome-v1",
    payload={"name": "Ben", "code": "ABC123"},
)
```

## Circuit breaker

The client keeps 3 states:

| State | When | Behaviour |
| --- | --- | --- |
| `closed` | Steady state | Every send hits NES. Consecutive-failure counter increments on transport/timeout/5xx errors and resets on success. |
| `open` | ≥ `threshold` consecutive failures | Sends are dropped **without an HTTP call** — no timeout cost. Duration: `open_seconds` (default 10s). |
| `half_open` | `open_seconds` elapsed | Exactly one probe request is allowed through. Success → `closed`. Failure → back to `open` for another cooldown. |

Business rejects (validation, ACL denied, quota exceeded — `codeStatus` in the 14xx range) **do not** count toward the failure counter; only genuine backend faults do. This keeps the breaker from opening because a caller passed a bad `provider_id`.

Inspect the current state:

```python
client.breaker_state()   # → "closed" | "open" | "half_open"
```

Tuning tips:

- **Low-traffic services** (a few sends/minute): lower `threshold` to 2-3 — otherwise the breaker never trips before a human notices.
- **High-traffic services**: keep default. 5 consecutive failures on a hot path signals real trouble.
- **Aggressive rolling deploys**: raise `open_seconds` to 20-30s so the breaker outlasts the rolling restart window.
- **Tests**: pass `breaker_enabled=False` for deterministic per-call behaviour.

## Logging

All background failures land on the `nes_client_django` logger at `WARNING`. Wire
it into your usual pipeline:

```python
LOGGING = {
    "loggers": {"nes_client_django": {"level": "WARNING", "handlers": ["console"]}},
}
```
