Metadata-Version: 2.4
Name: Fabrisk
Version: 0.1.2
Summary: Official Python client for the FabRisk API
Author: 42 Advisors
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://fabrisk.42capital.io
Project-URL: Documentation, https://fabrisk.42capital.io/crypto
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Financial and Insurance Industry
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Operating System :: OS Independent
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27
Dynamic: license-file

# Fabrisk

Official Python client for the FabRisk API.

## Install

```bash
pip install fabrisk
```

FabRisk supports Python 3.11 through 3.14.

## Create an API key

A FabRisk administrator creates client accounts and their library keys in
**Admin → Users**. Ask your administrator for the generated key. The full key
is displayed only once.

## Use

```python
from fabrisk import FabriskClient

with FabriskClient(api_key="frk_live_...") as client:
    latest = client.latest_model_date()
    print(latest)
```

## Calling any operation

`FabriskClient.request()` is the canonical path and covers **every** supported
operation. `latest_model_date()`, `universe()`, `portfolio_risk()` and
`backtest()` are shortcuts for the four most frequent calls; the package
deliberately stops there rather than mirroring the whole catalog as methods.
Everything else is reached by name through `request()`, which is the supported
way to call it — not a workaround.

```python
from fabrisk import FabriskClient, available_operations

print(available_operations())

with FabriskClient(api_key="frk_live_...") as client:
    result = client.request(
        "portfolio_risk",
        json_body={"assets": ["BTC", "ETH"], "weights": [0.6, 0.4]},
    )
```

The package contains only the public client. It does not ship the FabRisk
administration, ETL, XL Bridge, internal models, or server route catalog.

## Errors

```
FabriskError                 base class -- catch this to catch everything
├── FabriskApiError          FabRisk answered with an error status
└── FabriskTransportError    no response: timeout, connection failure, redirect loop
```

`FabriskApiError` also inherits from `RuntimeError`, so code written against an
earlier release that catches `RuntimeError` keeps working.

Both carry `.status_code` (`None` for transport errors), `.request_id`, and
`.retryable`. `FabriskApiError` additionally carries `.code` and `.details` from
the FabRisk error envelope, plus the quota fields `.retry_after_seconds`,
`.rate_limit_limit`, `.rate_limit_remaining` and `.rate_limit_reset` (all `None`
when the response did not carry them). No `httpx` exception is ever propagated,
and no error message contains the API key, the URL, or a header.

```python
from fabrisk import FabriskApiError, FabriskClient, FabriskTransportError

try:
    result = client.request("universe")
except FabriskTransportError as exc:
    print("network problem, retryable:", exc.retryable)
except FabriskApiError as exc:
    print(exc.status_code, exc.code, exc.request_id)
```

## Retries and Retry-After

Overloaded responses are retried automatically, with the same policy the FabRisk
gateway uses against the same backend:

- retried when the status is 429, 502, 503, or 504, **or** when the JSON body
  sets `retryable` (directly or under `detail`);
- at most 2 extra attempts, which is also the hard cap on `max_retries=`;
- the delay is the body's `retry_after_seconds` if present, otherwise the
  `Retry-After` header, otherwise `0.4 × attempt` seconds — each capped at 5
  seconds. The body wins over the header on purpose: it comes from the overload
  gate that knows the real queue depth, whereas the header can be rewritten by
  an intermediate proxy;
- `timeout=` is a deadline for the **whole call**, retries and sleeps included.
  It is taken once when the call starts, and each attempt only gets what is left
  of it. A call with `timeout=30` returns or raises within about 30 seconds, no
  matter how many retries it made — and also when the response dribbles in one
  chunk at a time, which resets `httpx`'s per-read timeout indefinitely. When
  the deadline expires with no response at all you get
  `FabriskTransportError("FabRisk request timed out", retryable=True)`;
- every attempt reuses the same `X-Request-Id`, so retries of one logical call
  stay correlated in FabRisk's logs.

Pass `max_retries=0` to disable retrying and handle overload yourself.
Transport failures that the server can recover from — connection cut mid-flight
by a rolling deploy or a load balancer, read/write errors, timeouts, client-side
pool exhaustion — are replayed on the same counter and the same deadline. A
redirect loop or a client closed under an in-flight request is not: those raise
`FabriskTransportError` with `.retryable` false.

### Pacing a batch on a 429

```python
import time

from fabrisk import FabriskApiError

try:
    result = client.request("optimize_markowitz", json_body=payload)
except FabriskApiError as exc:
    if exc.status_code == 429:
        # The real delay the server asked for, uncapped -- the 5-second cap only
        # bounds how long this client blocks on its own.
        time.sleep(exc.retry_after_seconds or 60)
        print("quota", exc.rate_limit_remaining, "of", exc.rate_limit_limit,
              "resets at", exc.rate_limit_reset)
```

## Proxies and TLS interception

Environment proxy variables are ignored (`trust_env` is off), so a stray
`HTTPS_PROXY` can never silently capture your API key. Behind a corporate egress
proxy, name it explicitly:

```python
client = FabriskClient(api_key="frk_live_...", proxy="http://proxy.corp.example:3128")
```

If your network also intercepts TLS (Zscaler, Netskope, Palo Alto), add the
corporate root with `verify=`. `SSL_CERT_FILE` is *not* read, for the same
reason environment proxies are not:

```python
import ssl

context = ssl.create_default_context(cafile=r"C:\corp\root.pem")
client = FabriskClient(
    api_key="frk_live_...",
    proxy="http://proxy.corp.example:3128",
    verify=context,
)
```

`verify=` accepts `True` (the default, system trust store) or an
`ssl.SSLContext`. Anything else — including `verify=False` — is rejected with a
`ValueError`: certificate verification cannot be turned off. `proxy=` and
`transport=` are mutually exclusive and also raise `ValueError` together,
because `httpx` would resolve the proxy mount and silently ignore the transport.

## Size limits

Request and response bounds are independent. `max_response_bytes=` caps what the
client will read; `max_request_bytes=` caps the encoded JSON body it will send.
Both default to 32 MiB.

## Security

- The production API URL is fixed by default.
- Custom endpoints require both `base_url=` and
  `allow_custom_base_url=True`, because the API key will be sent to that host.
- Redirects are never followed.
- Remote cleartext HTTP is rejected.
- Responses are bounded to 32 MiB by default.
- Closing the client removes its in-memory credential reference.
- Administrator-generated client keys expire after 90 days by default.

Never commit an API key to source control. Load it from an environment variable
or a secret manager, rotate it if it may have been exposed, and close the client
when finished.

## Licence

Proprietary. Copyright (c) 2026 42 Advisors. All rights reserved. Use is
permitted only under an active FabRisk subscription or agreement. See `LICENSE`.
