This commit is contained in:
JiriUhlir
2026-06-18 15:11:49 +02:00
parent 28596fe772
commit a5795fe344
5 changed files with 405 additions and 200 deletions
+174 -33
View File
@@ -1,9 +1,10 @@
import logging import logging
import os import os
from typing import Any
from fastapi import FastAPI, Header, HTTPException from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
from app.models import CreateCompanyRequest, CreateCompanyResponse from app.models import CompanyData, CreateCompanyResponse
from app.raynet_client import ( from app.raynet_client import (
RaynetAuthError, RaynetAuthError,
RaynetClient, RaynetClient,
@@ -18,20 +19,90 @@ APP_NAME = os.getenv("APP_NAME", "raynet")
APP_VERSION = os.getenv("APP_VERSION", "1.0.0") APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
ROOT_PATH = os.getenv("ROOT_PATH", "") ROOT_PATH = os.getenv("ROOT_PATH", "")
# Popis se zobrazí nahoře ve Swaggeru (/docs) přihlašovací údaje jsou
# společné pro celé připojení a posílají se v hlavičkách u KAŽDÉHO requestu.
API_DESCRIPTION = """
Stateless proxy nad **RAYNET CRM API v2**.
## Přihlášení (společné pro celé připojení)
Každý endpoint vyžaduje tři hlavičky. Jsou stejné pro všechna volání:
| Hlavička | Co to je | Kde to vzít |
|-------------------|--------------------|-------------|
| **`X-Api-Key`** | API klíč (secret) | V RAYNET CRM: **Nastavení → Klíč k API** (vygeneruj / zkopíruj). |
| **`X-Raynet-Email`** | Email uživatele | Přihlašovací email do RAYNET (tvoří dvojici s API klíčem pro Basic Auth). |
| **`X-Instance-Name`** | Název instance | Identifikátor tvé RAYNET instance (subdoména účtu). |
`X-Api-Key` je **secret** nikdy se neloguje ani nevrací z endpointů.
## Endpointy
- Typované zkratky pro firmy: `POST/GET/PUT/DELETE /company...`
- Generický průchod na celé API: `/api/{resource}` podporuje libovolnou
RAYNET entitu (`company`, `person`, `lead`, `businessCase`, `activity`,
`product`, `offer`, `order`, `project`, ...).
Plná dokumentace RAYNET: <https://app.raynetcrm.com/api/doc/index-en.html>
"""
app = FastAPI( app = FastAPI(
title=APP_NAME, title=APP_NAME,
version=APP_VERSION, version=APP_VERSION,
root_path=ROOT_PATH, root_path=ROOT_PATH,
description="Stateless proxy nad RAYNET CRM API v2.", description=API_DESCRIPTION,
) )
@app.get("/health") # --------------------------------------------------------------------------- #
# Sdílená credentials dependency 3 hlavičky pro celé připojení.
# --------------------------------------------------------------------------- #
def get_client(
x_api_key: str = Header(
..., alias="X-Api-Key", description="RAYNET API klíč (secret)."
),
x_raynet_email: str = Header(
..., alias="X-Raynet-Email", description="Email uživatele RAYNET."
),
x_instance_name: str = Header(
..., alias="X-Instance-Name", description="Název RAYNET instance."
),
):
"""Vytvoří RaynetClient z hlaviček a po dokončení requestu uvolní session."""
client = RaynetClient(
api_key=x_api_key,
email=x_raynet_email,
instance_name=x_instance_name,
)
try:
yield client
finally:
client.close()
def _run(fn, *args, **kwargs) -> Any:
"""Spustí volání klienta a převede výjimky konektoru na HTTP odpovědi."""
try:
return fn(*args, **kwargs)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except RaynetAuthError as exc:
raise HTTPException(status_code=401, detail=exc.message) from exc
except RaynetValidationError as exc:
raise HTTPException(status_code=400, detail=exc.message) from exc
except RaynetError as exc:
raise HTTPException(status_code=502, detail=exc.message) from exc
# --------------------------------------------------------------------------- #
# Servisní endpointy
# --------------------------------------------------------------------------- #
@app.get("/health", tags=["service"])
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@app.get("/version") @app.get("/version", tags=["service"])
def version(): def version():
return { return {
"app": APP_NAME, "app": APP_NAME,
@@ -41,35 +112,105 @@ def version():
} }
# --------------------------------------------------------------------------- #
# Typované zkratky pro firmy (company)
# --------------------------------------------------------------------------- #
@app.post("/company", response_model=CreateCompanyResponse, tags=["company"]) @app.post("/company", response_model=CreateCompanyResponse, tags=["company"])
def create_company( def create_company(
request: CreateCompanyRequest, company: CompanyData,
x_api_key: str = Header( client: RaynetClient = Depends(get_client),
...,
alias="X-Api-Key",
description="RAYNET API klíč (secret). Předává se výhradně hlavičkou.",
),
): ):
"""Vytvoří firmu v RAYNET CRM. """Vytvoří firmu v RAYNET CRM (validovaná data dle CompanyInsertDto)."""
data = company.model_dump(exclude_none=True)
- **X-Api-Key** (hlavička, secret): API klíč z RAYNET (Nastavení > API). result = _run(client.create_company, data)
- **email** / **instance_name** (tělo): identifikátory účtu a instance.
- **company** (tělo): data firmy dle RAYNET CompanyInsertDto.
"""
try:
with RaynetClient(
api_key=x_api_key,
email=request.email,
instance_name=request.instance_name,
) as client:
# Posíláme jen vyplněná pole (žádné None), aby RAYNET nedostal prázdné hodnoty.
data = request.company.model_dump(exclude_none=True)
result = client.create_company(data)
except RaynetAuthError as exc:
raise HTTPException(status_code=401, detail=exc.message) from exc
except RaynetValidationError as exc:
raise HTTPException(status_code=400, detail=exc.message) from exc
except RaynetError as exc:
raise HTTPException(status_code=502, detail=exc.message) from exc
return CreateCompanyResponse(id=result.get("id"), success=True, raw=result) return CreateCompanyResponse(id=result.get("id"), success=True, raw=result)
@app.get("/company", tags=["company"])
def list_companies(
client: RaynetClient = Depends(get_client),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=1000),
fulltext: str | None = Query(None, description="Fulltextové hledání"),
):
"""Vrátí seznam firem s podporou stránkování a fulltextu."""
params = {"offset": offset, "limit": limit}
if fulltext:
params["fulltext"] = fulltext
return _run(client.list_companies, **params)
@app.get("/company/{company_id}", tags=["company"])
def get_company(company_id: int, client: RaynetClient = Depends(get_client)):
"""Vrátí detail firmy."""
return _run(client.get_company, company_id)
@app.put("/company/{company_id}", tags=["company"])
def update_company(
company_id: int,
data: dict = Body(..., description="Pole firmy ke změně"),
client: RaynetClient = Depends(get_client),
):
"""Upraví firmu."""
return _run(client.update_company, company_id, data)
@app.delete("/company/{company_id}", tags=["company"])
def delete_company(company_id: int, client: RaynetClient = Depends(get_client)):
"""Smaže firmu."""
return _run(client.delete_company, company_id)
# --------------------------------------------------------------------------- #
# Generický průchod na CELÉ RAYNET API
# resource = libovolná entita: company, person, lead, businessCase, activity,
# product, offer, order, project, ...
# --------------------------------------------------------------------------- #
@app.get("/api/{resource}", tags=["generic"])
def api_list(
resource: str,
request: Request,
client: RaynetClient = Depends(get_client),
):
"""Seznam záznamů entity. Všechny query parametry se předávají do RAYNET
(např. `offset`, `limit`, `fulltext`, `name`, ...)."""
params = dict(request.query_params)
return _run(client.list_records, resource, params or None)
@app.get("/api/{resource}/{record_id}", tags=["generic"])
def api_detail(
resource: str, record_id: str, client: RaynetClient = Depends(get_client)
):
"""Detail jednoho záznamu."""
return _run(client.get_record, resource, record_id)
@app.post("/api/{resource}", tags=["generic"])
def api_create(
resource: str,
data: dict = Body(..., description="Data nového záznamu"),
client: RaynetClient = Depends(get_client),
):
"""Vytvoří záznam (mapuje se na RAYNET `PUT /{resource}/`)."""
return _run(client.create_record, resource, data)
@app.put("/api/{resource}/{record_id}", tags=["generic"])
def api_update(
resource: str,
record_id: str,
data: dict = Body(..., description="Pole ke změně"),
client: RaynetClient = Depends(get_client),
):
"""Upraví záznam (mapuje se na RAYNET `POST /{resource}/{id}/`)."""
return _run(client.update_record, resource, record_id, data)
@app.delete("/api/{resource}/{record_id}", tags=["generic"])
def api_delete(
resource: str, record_id: str, client: RaynetClient = Depends(get_client)
):
"""Smaže záznam."""
return _run(client.delete_record, resource, record_id)
-12
View File
@@ -99,18 +99,6 @@ class CompanyData(BaseModel):
model_config = {"extra": "allow"} model_config = {"extra": "allow"}
class CreateCompanyRequest(BaseModel):
"""Vstup endpointu /company.
API klíč (secret) se předává hlavičkou ``X-Api-Key`` NENÍ součástí tohoto
těla. Email a název instance jsou běžné identifikátory, proto jdou v těle.
"""
email: str = Field(..., examples=["user@firma.cz"], description="Email uživatele RAYNET")
instance_name: str = Field(..., examples=["moje-instance"], description="Název RAYNET instance")
company: CompanyData
class CreateCompanyResponse(BaseModel): class CreateCompanyResponse(BaseModel):
id: Optional[int] = Field(default=None, description="ID nově vytvořené firmy") id: Optional[int] = Field(default=None, description="ID nově vytvořené firmy")
success: bool = True success: bool = True
+79 -21
View File
@@ -115,41 +115,99 @@ class RaynetClient:
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Veřejné metody # Generické CRUD nad libovolnou RAYNET entitou (resource)
#
# RAYNET má napříč celým API jednotné konvence:
# list GET /{resource}/
# detail GET /{resource}/{id}/
# create PUT /{resource}/
# update POST /{resource}/{id}/
# delete DELETE /{resource}/{id}/
# Tyto metody proto pokrývají celé RAYNET API (company, person, lead,
# businessCase, activity, product, offer, order, project, ...).
# ------------------------------------------------------------------ #
def list_records(self, resource: str, params: Optional[dict] = None) -> dict:
"""Vrátí seznam záznamů dané entity.
Args:
resource: název entity, např. ``"company"``, ``"person"``, ``"lead"``.
params: filtry / stránkování (``offset``, ``limit``, ``fulltext``, ...).
"""
return self._request("GET", f"/{self._res(resource)}/", params=params)
def get_record(self, resource: str, record_id: int | str) -> dict:
"""Vrátí detail jednoho záznamu."""
return self._request("GET", f"/{self._res(resource)}/{record_id}/")
def create_record(self, resource: str, data: dict) -> dict:
"""Vytvoří záznam (RAYNET používá pro create metodu PUT)."""
if not isinstance(data, dict):
raise ValueError("Parametr 'data' musí být slovník (dict).")
return self._request("PUT", f"/{self._res(resource)}/", json=data)
def update_record(self, resource: str, record_id: int | str, data: dict) -> dict:
"""Upraví záznam (RAYNET používá pro update metodu POST)."""
if not isinstance(data, dict):
raise ValueError("Parametr 'data' musí být slovník (dict).")
return self._request("POST", f"/{self._res(resource)}/{record_id}/", json=data)
def delete_record(self, resource: str, record_id: int | str) -> dict:
"""Smaže záznam."""
return self._request("DELETE", f"/{self._res(resource)}/{record_id}/")
def call(
self,
method: str,
path: str,
params: Optional[dict] = None,
json: Optional[Any] = None,
) -> dict:
"""Univerzální volání pro vnořené zdroje a speciální akce.
Příklady cest: ``/company/{id}/address/``,
``/company/{id}/lock``, ``/company/{id}/merge/{sourceId}/``.
"""
return self._request(method.upper(), path, params=params, json=json)
# ------------------------------------------------------------------ #
# Pojmenované zkratky pro nejčastější entity (tenké wrappery)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def create_company(self, data: dict) -> dict: def create_company(self, data: dict) -> dict:
"""Vytvoří firmu (company) v RAYNET CRM. """Vytvoří firmu (company) v RAYNET CRM.
RAYNET používá pro vytvoření záznamu HTTP metodu ``PUT`` na kolekci Povinná pole: ``name``, ``rating``, ``state``, ``role``.
``/company/`` (nikoli POST). Příklad struktury viz :data:`EXAMPLE_COMPANY` níže.
Args:
data: tělo požadavku dle RAYNET dokumentace. Povinná pole jsou
``name``, ``rating``, ``state`` a ``role``. Příklad kompletní
struktury viz :data:`EXAMPLE_COMPANY` níže.
Returns:
Parsovaná JSON odpověď serveru (obsahuje mj. ``id`` nové firmy).
Raises:
ValueError: pokud ``data`` nejsou slovník nebo chybí povinná pole.
RaynetAuthError: při chybě autentizace (401/403).
RaynetValidationError: při zamítnutí dat serverem (4xx).
RaynetError: při ostatních chybách (síť, 5xx, nevalidní JSON).
""" """
if not isinstance(data, dict): missing = [f for f in ("name", "rating", "state", "role") if not (data or {}).get(f)]
raise ValueError("Parametr 'data' musí být slovník (dict).")
missing = [f for f in ("name", "rating", "state", "role") if not data.get(f)]
if missing: if missing:
raise ValueError( raise ValueError(
"Firma musí mít vyplněná povinná pole: " + ", ".join(missing) + "." "Firma musí mít vyplněná povinná pole: " + ", ".join(missing) + "."
) )
return self.create_record("company", data)
return self._request("PUT", "/company/", json=data) def list_companies(self, **params: Any) -> dict:
return self.list_records("company", params or None)
def get_company(self, company_id: int | str) -> dict:
return self.get_record("company", company_id)
def update_company(self, company_id: int | str, data: dict) -> dict:
return self.update_record("company", company_id, data)
def delete_company(self, company_id: int | str) -> dict:
return self.delete_record("company", company_id)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Interní HTTP vrstva # Interní HTTP vrstva
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@staticmethod
def _res(resource: str) -> str:
"""Očistí název resource (bez lomítek), ať nejde sestavit divná URL."""
cleaned = (resource or "").strip().strip("/")
if not cleaned:
raise ValueError("Název resource nesmí být prázdný.")
return cleaned
def _request(self, method: str, path: str, **kwargs: Any) -> dict: def _request(self, method: str, path: str, **kwargs: Any) -> dict:
url = f"{self.base_url}/{path.lstrip('/')}" url = f"{self.base_url}/{path.lstrip('/')}"
kwargs.setdefault("timeout", self.timeout) kwargs.setdefault("timeout", self.timeout)
-134
View File
@@ -1,134 +0,0 @@
# RAYNET konektor vytvoření firmy
Stateless proxy nad [RAYNET CRM API v2](https://app.raynetcrm.com/api/doc/index-en.html).
## Autentizace RAYNET
RAYNET API používá:
- **HTTP Basic Auth**: username = email uživatele, password = **API klíč**
- hlavičku **`X-Instance-Name`** = název instance
- base URL: `https://app.raynet.cz/api/v2`
V této službě se **API klíč (secret) předává hlavičkou `X-Api-Key`**. Email a název
instance jsou běžné identifikátory a předávají se v těle požadavku.
> Secret se nikdy neloguje, necommituje ani nevrací z endpointů (viz AGENTS.md).
## Endpoint
```text
POST /company
```
Veřejně přes AppFactory reverse proxy:
```text
POST https://services.csbot.cz/apps/<app-id>/company
```
### Hlavičky
| Hlavička | Povinná | Popis |
|--------------|---------|-------------------------------|
| `X-Api-Key` | ano | RAYNET API klíč (secret) |
### Tělo požadavku
```json
{
"email": "user@firma.cz",
"instance_name": "moje-instance",
"company": {
"name": "ACME s.r.o.",
"rating": "A",
"state": "A_POTENTIAL",
"role": "A_SUBSCRIBER",
"regNumber": "12345678",
"taxNumber": "CZ12345678",
"taxPayer": "YES",
"addresses": [
{
"address": {
"name": "Sídlo klienta",
"street": "Francouzská 6167/5",
"city": "Ostrava",
"province": "Morava",
"zipCode": "708 00",
"country": "CZ"
},
"contactInfo": {
"email": "info@acme.cz",
"tel1": "+420 553 401 520",
"tel1Type": "recepce",
"www": "www.acme.cz"
}
}
],
"tags": ["import", "konektor"]
}
}
```
#### Povinná pole firmy
| Pole | Typ | Hodnoty |
|----------|--------|---------------------------------------------------------------|
| `name` | string | název firmy |
| `rating` | enum | `A`, `B`, `C` |
| `state` | enum | `A_POTENTIAL`, `B_ACTUAL`, `C_DEFERRED`, `D_UNATTRACTIVE` |
| `role` | enum | `A_SUBSCRIBER`, `B_PARTNER`, `C_SUPPLIER`, `D_RIVAL` |
Volitelná pole: `regNumber` (IČO), `taxNumber` (DIČ), `taxPayer` (`YES`/`NO`),
`bankAccount`, `notice`, `owner`, `category`, `legalForm`, `paymentTerm`,
`turnover`, `territory`, `addresses`, `tags`, `customFields` a další dle
RAYNET `CompanyInsertDto`. Neuvedená/`null` pole se do RAYNET neposílají.
### Odpověď (200)
```json
{
"id": 123456,
"success": true,
"raw": { "...": "surová odpověď RAYNET" }
}
```
### Chybové stavy
| HTTP | Kdy |
|------|------------------------------------------------------------|
| 400 | RAYNET odmítl data (nevalidní vstup) |
| 401 | chybný API klíč / email / instance (autentizace selhala) |
| 422 | nevalidní tělo požadavku (FastAPI validace) |
| 502 | RAYNET nedostupný / chyba serveru / timeout |
## Použití konektoru přímo v Pythonu
```python
from app.raynet_client import RaynetClient, RaynetError
client = RaynetClient(api_key="...", email="user@firma.cz", instance_name="moje-instance")
try:
company = client.create_company({
"name": "ACME s.r.o.",
"rating": "A",
"state": "A_POTENTIAL",
"role": "A_SUBSCRIBER",
})
print(company["id"])
except RaynetError as exc:
print("Chyba:", exc.message, exc.status_code)
```
`RaynetClient` drží `requests.Session` (Basic Auth + `X-Instance-Name`),
automaticky opakuje přechodné chyby (síť, 429, 5xx) a vyhazuje typované
výjimky `RaynetAuthError` / `RaynetValidationError` / `RaynetError`.
## Ověření po nasazení
```text
GET https://services.csbot.cz/apps/<app-id>/health
GET https://services.csbot.cz/apps/<app-id>/docs
POST https://services.csbot.cz/apps/<app-id>/company (Swagger „Try it out")
```
+152
View File
@@ -0,0 +1,152 @@
# RAYNET konektor
Stateless proxy nad [RAYNET CRM API v2](https://app.raynetcrm.com/api/doc/index-en.html).
Pokrývá celé RAYNET API díky jednotným REST konvencím.
## Přihlášení (společné pro celé připojení)
RAYNET API používá HTTP Basic Auth (email + API klíč) a hlavičku s názvem
instance. V této službě se předává **třemi hlavičkami u každého requestu**:
| Hlavička | Co to je | Kde to vzít |
|-----------------------|-------------------|-------------|
| `X-Api-Key` | API klíč (secret) | RAYNET CRM: **Nastavení → Klíč k API** |
| `X-Raynet-Email` | Email uživatele | Přihlašovací email do RAYNET |
| `X-Instance-Name` | Název instance | Identifikátor instance (subdoména účtu) |
> `X-Api-Key` je secret nikdy se neloguje, necommituje ani nevrací z endpointů
> (viz AGENTS.md). Tyto údaje jsou zobrazené i nahoře ve Swaggeru (`/docs`).
Base URL RAYNET: `https://app.raynet.cz/api/v2`.
## REST konvence RAYNET (platí pro celé API)
| Operace | RAYNET metoda + cesta |
|---------|------------------------------|
| List | `GET /{resource}/` |
| Detail | `GET /{resource}/{id}/` |
| Create | `PUT /{resource}/` |
| Update | `POST /{resource}/{id}/` |
| Delete | `DELETE /{resource}/{id}/` |
Příklady entit: `company`, `person`, `lead`, `businessCase`, `activity`,
`product`, `offer`, `order`, `project`, …
## Endpointy služby
Veřejně přes AppFactory reverse proxy: `https://services.csbot.cz/apps/<app-id>/...`
### Typované zkratky pro firmy
| Metoda | Cesta | Popis |
|--------|-------------------------|-------------------------------|
| POST | `/company` | Vytvoří firmu (validace) |
| GET | `/company` | Seznam firem (offset/limit/fulltext) |
| GET | `/company/{id}` | Detail firmy |
| PUT | `/company/{id}` | Úprava firmy |
| DELETE | `/company/{id}` | Smazání firmy |
### Generický průchod na celé API
| Metoda | Cesta | Mapuje se na RAYNET |
|--------|-----------------------------|-------------------------------|
| GET | `/api/{resource}` | `GET /{resource}/` (query params se předávají) |
| GET | `/api/{resource}/{id}` | `GET /{resource}/{id}/` |
| POST | `/api/{resource}` | `PUT /{resource}/` (create) |
| PUT | `/api/{resource}/{id}` | `POST /{resource}/{id}/` (update) |
| DELETE | `/api/{resource}/{id}` | `DELETE /{resource}/{id}/` |
## Vytvoření firmy povinná pole
| Pole | Typ | Hodnoty |
|----------|--------|------------------------------------------------------------|
| `name` | string | název firmy |
| `rating` | enum | `A`, `B`, `C` |
| `state` | enum | `A_POTENTIAL`, `B_ACTUAL`, `C_DEFERRED`, `D_UNATTRACTIVE` |
| `role` | enum | `A_SUBSCRIBER`, `B_PARTNER`, `C_SUPPLIER`, `D_RIVAL` |
Volitelná: `regNumber` (IČO), `taxNumber` (DIČ), `taxPayer` (`YES`/`NO`),
`bankAccount`, `notice`, `owner`, `category`, `legalForm`, `paymentTerm`,
`turnover`, `territory`, `addresses`, `tags`, `customFields` a další dle
RAYNET `CompanyInsertDto`.
### Příklad těla `POST /company`
```json
{
"name": "ACME s.r.o.",
"rating": "A",
"state": "A_POTENTIAL",
"role": "A_SUBSCRIBER",
"regNumber": "12345678",
"taxNumber": "CZ12345678",
"taxPayer": "YES",
"addresses": [
{
"address": {
"name": "Sídlo klienta",
"street": "Francouzská 6167/5",
"city": "Ostrava",
"province": "Morava",
"zipCode": "708 00",
"country": "CZ"
},
"contactInfo": {
"email": "info@acme.cz",
"tel1": "+420 553 401 520",
"tel1Type": "recepce",
"www": "www.acme.cz"
}
}
],
"tags": ["import", "konektor"]
}
```
## Chybové stavy
| HTTP | Kdy |
|------|-----------------------------------------------------------|
| 400 | RAYNET odmítl data (nevalidní vstup) |
| 401 | chybný API klíč / email / instance (autentizace selhala) |
| 422 | nevalidní vstup (FastAPI validace nebo chybí povinné pole)|
| 502 | RAYNET nedostupný / chyba serveru / timeout |
## Použití konektoru přímo v Pythonu
```python
from app.raynet_client import RaynetClient, RaynetError
client = RaynetClient(api_key="...", email="user@firma.cz", instance_name="moje-instance")
try:
# pojmenovaná zkratka
company = client.create_company({
"name": "ACME s.r.o.", "rating": "A",
"state": "A_POTENTIAL", "role": "A_SUBSCRIBER",
})
# generické CRUD nad libovolnou entitou
leads = client.list_records("lead", {"limit": 50})
deal = client.get_record("businessCase", 123)
client.update_record("person", 42, {"lastName": "Novák"})
# speciální akce / vnořené zdroje
client.call("POST", f"/company/{company['id']}/lock")
except RaynetError as exc:
print("Chyba:", exc.message, exc.status_code)
```
`RaynetClient` drží `requests.Session` (Basic Auth + `X-Instance-Name`),
automaticky opakuje přechodné chyby (síť, 429, 5xx) a vyhazuje typované
výjimky `RaynetAuthError` / `RaynetValidationError` / `RaynetError`.
## Ověření po nasazení
```text
GET https://services.csbot.cz/apps/<app-id>/health
GET https://services.csbot.cz/apps/<app-id>/docs
```
Ve Swagger UI ověř, že `Try it out` volá endpointy přes `/apps/<app-id>` a že
jsou vyžadované hlavičky `X-Api-Key`, `X-Raynet-Email`, `X-Instance-Name`.
```