Metadata-Version: 2.5
Name: pjdev-armis-sdk
Version: 5.2.0
Summary: Async Python SDK for the Armis API
Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-armis-sdk/README.md
Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
Project-URL: Source, https://gitlab.purplejay.io/keystone/python
Author-email: Purple Jay LLC <developers@purplejay.io>
License-Expression: MIT
License-File: LICENSE.txt
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Python: >=3.12
Requires-Dist: httpx
Requires-Dist: loguru
Requires-Dist: pydantic-settings>=2.13.1
Requires-Dist: pydantic>=2.12.5
Provides-Extra: dev
Requires-Dist: ruff; extra == 'dev'
Provides-Extra: test
Requires-Dist: coverage; extra == 'test'
Requires-Dist: pytest; extra == 'test'
Requires-Dist: pytest-asyncio; extra == 'test'
Requires-Dist: respx; extra == 'test'
Description-Content-Type: text/markdown

# pjdev-armis-sdk

[![PyPI - Version](https://img.shields.io/pypi/v/pjdev-armis-sdk.svg)](https://pypi.org/project/pjdev-armis-sdk)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pjdev-armis-sdk.svg)](https://pypi.org/project/pjdev-armis-sdk)

-----

Async Python SDK for the [Armis](https://www.armis.com/) API, built on `httpx` and `pydantic`.

## Table of Contents

- [Installation](#installation)
- [Usage](#usage)
- [Retries and backoff](#retries-and-backoff)
- [License](#license)

## Installation

```console
pip install pjdev-armis-sdk
```

## Usage

Configure once at startup (reads from environment variables prefixed with `ARMIS_`):

```python
from pjdev_armis_sdk import config_service

config_service.init(
    instance_url="https://your-tenant.armis.com",
    secret_key="your-secret-key",
)
```

Then call any of the resource modules:

```python
from pjdev_armis_sdk import devices, search

# AQL search
result = await search.aql_search("in:devices type:MOBILE_PHONE")

# Lookup a single device by id
device = await devices.get_device(id=12345)
```

Auth (`Authorization` header with a temporary access token) is handled automatically
by the underlying `httpx.AsyncClient`. Tokens are refreshed on the fly when they expire
or when the API returns 401.

## Retries and backoff

Every endpoint call retries transient failures with capped, jittered exponential
backoff. Retry `N` waits `http_retry_delay_seconds * 2 ** (N - 1)` seconds, clamped
to `http_retry_max_delay_seconds` and then scaled by a random factor in `[0.5, 1.0]`.
A `Retry-After` header on a retried response is honored instead — both the
delay-seconds and HTTP-date forms — clamped to the same ceiling.

Config knobs (all readable as `ARMIS_`-prefixed environment variables):

| Setting | Default | Meaning |
| --- | --- | --- |
| `http_retry_max_count` | `5` | Maximum attempts per call. |
| `http_retry_delay_seconds` | `2` | Base delay; doubles each retry. |
| `http_retry_max_delay_seconds` | `60.0` | Ceiling for any single wait. |
| `http_retry_jitter` | `True` | Spread out callers that failed together. |

What is retried: any status **not** listed in a call's `status_codes_to_ignore`,
plus transport failures (`httpx.TransportError` — connect errors, read/write and
pool timeouts, protocol errors). `400`, `403` and `404` are treated as
deterministic and fail immediately.

**401 is retried, with one deliberate exception.** For ordinary endpoints a 401
means a stale access token, and it is worth retrying — note that
`ArmisAccessTokenAuth` has already re-authenticated and replayed the request once,
with no delay, before the retry layer ever sees it, so what retrying adds is
elapsed time for a transient condition to clear. Against the **token endpoint**
itself a 401 means the secret key is wrong, which no retry can fix, so
`access_token.get_access_token()` and failed token fetches inside the auth flow
fail fast on any 4xx and retry only on 429 or 5xx.

Consumers that need different behavior can override per call site without
changing the shared defaults:

```python
from pjdev_armis_sdk.api_utilities import async_retry_http

# Retry a status that this call site would otherwise treat as fatal.
@async_retry_http(status_codes_to_ignore=[400, 403], retry_status_codes=[409])
async def my_call() -> None: ...
```

`retry_status_codes` takes precedence over `status_codes_to_ignore`; passing only
the latter behaves exactly as it always has. It is an override layered on the base
rule, never an allowlist — it widens what is retried, never narrows it.

### Writes that are not safe to repeat

Creates, and actions like `_run_now/` that trigger work, are marked
`idempotent=False`. On those calls the SDK retries only failures that **prove the
request never landed**:

| | |
| --- | --- |
| **Never landed** — safe to retry | `ConnectError`, `ConnectTimeout`, `PoolTimeout` (no connection was ever established, so nothing was transmitted) and `401` (rejected at the authentication boundary before the handler runs) |
| **May have landed** — not retried | `ReadTimeout`, `WriteTimeout`, `ReadError`, `WriteError`, `RemoteProtocolError` (the request was on the wire and the response was lost) and every `4xx`/`5xx` other than `401` |

The question for a write is not "is this failure transient?" but **"could the
server have applied the write and still returned this?"** A `504` or a
`ReadTimeout` means the request arrived and the response was lost — the write may
well have landed, so repeating it creates a duplicate. Armis offers no idempotency
key, so nothing about the request lets the SDK work this out for itself; it has to
be declared at the call site.

Note this is not a read/write split. A connection refused mid-deploy is the
commonest transient failure there is, and it stays retryable on every endpoint
including creates, because a refused connection cannot have written anything.

Affected: `create_user`, `create_site`, `create_boundary`, `create_integration`,
`create_integration_v2`, `run_silk_integration`, `run_va_integration`,
`add_site_integration_id`, `add_site_network_equipment`,
`bulk_add_site_network_equipment`.

The last three are a **deliberate conservative guess**, not a known fact: they POST
an id into a collection, and if Armis stores that collection as a set they are
harmless to repeat and the flag costs retry resilience for nothing. The guess is
made on cost asymmetry — a wrong `False` costs availability, a wrong `True` costs
duplicate data — and confirmation from Armis that POSTing an already-present id is
a no-op would settle it.

Not affected, because repeating them is harmless: every read, the `update_*` /
`patch_*` calls (they set fields to given values), the `delete_*` calls,
`add_device_tags` / `remove_device_tags` (set semantics), and
`bulk_upsert_devices` (upsert keyed by `macAddress`).

### Upgrading

Which statuses retry is unchanged for callers that pass only `status_codes_to_ignore`.
Two timing changes are visible without any code change on your side:

- Transport failures other than `ConnectError` — read/write and pool timeouts,
  protocol errors — are now retried instead of propagating after one attempt. A
  call that used to fail fast on a `ReadTimeout` now consumes its retry budget and
  the wall-clock time that goes with it.
- Jitter is on by default, so a delay is 0.5x–1.0x of the computed value. At the
  default `http_retry_delay_seconds=2` the schedule was 2, 4, 8, 16 and is now
  1–2, 2–4, 4–8, 8–16. Set `http_retry_jitter=False` for the exact old schedule.
- **Creates no longer survive a transient `5xx` or `429`.** This is the change
  most likely to affect you, and it is a reduction in write resilience, not only a
  bug fix. The ten non-idempotent calls listed above previously retried those
  statuses up to `http_retry_max_count` times; they now fail on the first one. If
  you have code relying on `create_user` riding out a brief `503`, it will now
  raise where it used to succeed, and the caller has to decide whether to retry.

  The trade is deliberate: a duplicate user is worse than a failed create the
  caller can retry on purpose. Note this closes a **pre-existing** hole as well as
  a recent one — a `504` on a create could already produce duplicate rows before
  this release, because any status outside `status_codes_to_ignore` was retried.
  Connection failures are unaffected and still retry, since they cannot duplicate
  anything.

## License

`pjdev-armis-sdk` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
