> ## Documentation Index
> Fetch the complete documentation index at: https://playbook.smedleygroup.com/llms.txt
> Use this file to discover all available pages before exploring further.

# SDK Conventions

> The shared rules every Smedley Group Python SDK follows: construction, retries, errors, diagnostics, testing and releases.

## Overview

`sg-vivenu-sdk` and `sg-intacct-xml-sdk` follow the same conventions. A new SDK must follow them too. When an SDK has to differ, its README says so.

> [!NOTE]
>
> Written in October 2026. This page is the canonical contract. Both SDKs follow it on `main`; the fixes made after sg-vivenu-sdk v0.8.0 and sg-intacct-xml-sdk v0.5.0 are listed under **Unreleased** in each changelog until their next release. Each repository's `docs/agents.md` summarises the contract with that SDK's specifics, and a test in each repository fails if its behaviour drifts.

## Construction

| Rule | Example |
| - | - |
| Clients are named `<Vendor>Client` and `Async<Vendor>Client`. | `VivenuClient`, `AsyncIntacctClient` (an alias of Intacct's `AsyncClient`) |
| The constructor takes explicit keyword arguments. | `VivenuClient(api_key=..., environment="staging")` |
| `from_env(prefix=...)` reads environment variables. It never loads dotenv files. | `IntacctClient.from_env(prefix="INTACCT_")` |
| Values read from the environment are stripped of surrounding whitespace. | A trailing newline from a mounted secret is ignored. |
| Missing required variables raise `ConfigurationError`, naming the variables but never their values. | `Set VIVENU_UK_API_KEY before using from_env()` |
| Use clients as context managers. An injected `http_client` stays owned by the caller. | `with VivenuClient.from_env() as client:` |

Use a different `prefix` for each hub, company or entity, for example `VIVENU_UK_` and `VIVENU_DE_`. When credentials come from a secret manager, pass them to the constructor instead.

## Retries

Both SDKs take `retry_policy=RetryPolicy(...)` with the same fields.

| Field | Meaning |
| - | - |
| `max_attempts` | Attempts including the first request. |
| `base_delay` | Backoff before the second attempt, in seconds. It doubles each attempt. |
| `max_delay` | Cap on any single wait. A `Retry-After` above it stops retrying instead of waiting. |
| `jitter` | Randomises each wait between zero and the backoff, so concurrent callers spread out. |
| `rate_limited_writes` | Also resends writes rejected with HTTP 429. Off by default. |

Defaults differ by vendor (see [Where the SDKs Differ](#where-the-sdks-differ)). vivenu allows 3 attempts by default. Intacct does not retry unless you pass a policy.

When a retry wait cannot fit within the remaining request deadline, the SDK stops retrying instead of waiting past the deadline.

> [!WARNING]
>
> By default, neither SDK resends a write unless it knows the write did not run. `rate_limited_writes=True` is the one opt-in exception: it resends writes rejected with HTTP 429, and neither vendor documents that a rate-limited write was not applied, so a resend can duplicate it. Intacct resends with `uniqueid=true`, so Intacct rejects a duplicate function; vivenu has no equivalent. See [Where the SDKs Differ](#where-the-sdks-differ) for each SDK's exact cases.

## Errors

Every SDK error derives from one base class (`VivenuError`, `IntacctError`) and carries:

| Attribute | Meaning |
| - | - |
| `request_id` | The vendor or SDK correlation ID, when one exists. |
| `outcome_unknown` | `True` only when the request may have changed state at the vendor and the SDK cannot tell. Reconcile before you retry. |

`outcome_unknown` is `False` for anything the SDK detects before sending, for requests that only read, and for known rejections. Intacct's legacy `readMore` is the exception among reads, because a lost response may already have advanced its cursor. An HTTP 429 on a write is not a known rejection in either SDK. Once any attempt of a write may have run, for example a write retried after HTTP 429, every later error of that request reports `True`. That way one handler can decide whether a failed write needs reconciliation:

```python theme={null}
try:
    client.execute_write(operation)
except IntacctError as error:
    if error.outcome_unknown:
        reconcile(error.request_id)
    raise
```

Other shared rules:

* Exception messages never contain payload values, customer data or credentials. Vendor detail is on attributes, such as vivenu's `APIError.message` or Intacct's `GatewayError.errors`.
* `DeadlineExceededError` subclasses `TransportError`. A deadline that expires before anything is sent reports `outcome_unknown=False`.
* Both SDKs decide whether a request was sent from httpcore's trace stages: a request counts as sent once its headers start going out. A failure or cancellation before that reports `outcome_unknown=False`. A transport that reports no stages, such as a mock or a custom transport, counts as sent. So does an httpx `PoolTimeout`, because httpcore reports no stages while a request waits for a pooled connection. Size `httpx.Limits` for your write concurrency, especially when several clients share one `httpx.Client`.
* Hitting a pagination page limit raises `PaginationLimitError`, which reports `outcome_unknown=False`.

## Diagnostics

Pass `on_request=callback` to observe every HTTP attempt. The callback receives a `RequestEvent` with these shared fields. Intacct also emits events that are not HTTP attempts; see [Where the SDKs Differ](#where-the-sdks-differ).

| Field | Meaning |
| - | - |
| `attempt` | 1 for the first attempt. |
| `elapsed_seconds` | Time spent on the attempt. |
| `status_code` | HTTP status, when a response arrived. |
| `request_id` | Correlation ID, when known. |
| `outcome` | `success`, `http_error`, `transport_error`, `deadline_exceeded`, or an SDK-specific value. |
| `retry_delay` | Wait before the next attempt, or `None` when not retrying. |
| `outcome_unknown` | Same rule as errors. |

Events never contain credentials, payloads or response records. Keep callbacks fast, and do not call back into the same client.

## HTTP

* SDK-owned HTTP clients ignore proxy environment variables. To use a proxy, custom transport or instrumentation, pass your own `http_client`.
* Redirects are never followed.

## Testing

Each SDK ships a public `testing` module, so applications can test offline without writing a fake server:

| | vivenu | Intacct |
| - | - | - |
| Offline client | `MockVivenuClient`, `AsyncMockVivenuClient` | `fake_client()`, `fake_async_client()` |
| Check every queued response was used | `assert_all_consumed()` | `assert_all_consumed()` |
| Synthetic data | `TicketFactory`, `EventFactory`, `TransactionFactory`, `sign_webhook()` | record and error builders |

Mocks never reach the network. Unregistered requests fail.

## Versions and Releases

* `_version.py` is the single source of the package version, `__version__` and the User-Agent.
* `CHANGELOG.md` keeps an `Unreleased` section. A release renames it to the version.
* Releases are git tags `v<version>` with a GitHub release, created by the repository's **Release** workflow. Dispatch it from `main` with the version and `create_release` turned on. With `create_release` off, it only builds and checks.
* The SDKs are private and are never published to public PyPI.
* Consumers pin a tag (see [Install From a Release Tag](/software/developer/sdks#install-from-a-release-tag)).

## Repository Checks

Every SDK repository runs at least these checks in CI on Python 3.11 to 3.14. An SDK can add more; vivenu also checks its generated code, public API baseline and docs build.

* `ruff check` and `ruff format --check`
* `mypy` in strict mode on the package
* strict `mypy` and `pyright` on consumer usage files in `tests/typing`
* the test suite
* a build, plus a smoke test of the installed wheel

Workflow actions are pinned to commit SHAs, and CI runs with a read-only token.

## Where the SDKs Differ

Each README lists its deliberate differences. The main ones:

| Area | vivenu | Intacct |
| - | - | - |
| Default retries | `max_attempts=3`, used for reads and for writes the trace shows were never sent | `max_attempts=1` (no retries) |
| HTTP errors on a write | 4xx responses are known rejections, except 429. 429 and 5xx are unknown | Any non-200 status is unknown. Function errors inside a 200 response are known. Operation errors without function results, and incomplete batches, are unknown |
| Read statuses retried | 429, 500, 502, 503, 504 and transport failures | 429, 502, 503, 504 and transport failures, for a single repeatable read sent by a helper method (`query`, `read`, `read_by_query`, `lookup`, `inspect`, `read_by_name`). Raw `execute()` calls only retry with `retry_read=True`. `readMore` is never retried |
| Automatic write resends | Connection failures where the trace shows nothing was sent; 429 with `rate_limited_writes` | One resend after Intacct rejects the session, when no function ran and the client has company login credentials; 429 with `rate_limited_writes` for requests sent with `uniqueid=true`. Never after a transport error |
| Blank environment values | A set but blank `<prefix>ENVIRONMENT` raises `ConfigurationError` | A blank optional `LOCATION_ID` or `CLIENT_ID` counts as unset |
| Success after a rate-limited write | The success event reports `outcome_unknown=True`: vivenu has no idempotency key, so the earlier attempt may also have been applied | Known: the resend uses `uniqueid=true`, so Intacct rejects it if the first attempt ran |
| Unparseable `Retry-After` | Falls back to the normal backoff | Stops retrying |
| Deadline and responses | A response that has arrived is processed, even after the deadline. A body still being read when the deadline passes raises `DeadlineExceededError` | The deadline covers every body read. A body not fully read in time raises `DeadlineExceededError` |
| `request_id` | The vendor's `x-request-id` or `x-vivenu-request-id` header, or `None` when the response has neither | The request control ID, generated by the SDK or supplied by you (for example `prepare_write(..., request_id=...)`), or `""` when the failure happened before a request was built |
| Invalid arguments | Built-in `ValueError` or `TypeError`. With `validate_requests=True`, payloads that break the vendor contract raise `RequestValidationError`, a `VivenuError` | Built-in `ValueError` or `TypeError` |
| Extra hook events | None | A retry wait interrupted by cancellation or the deadline, and write recovery, also emit events, marked by a `phase` field. Recovery events have `attempt=0`. Ordinary retry waits are reported on the attempt's own event through `retry_delay` |

## Related Documentation

| Page | Relevance |
| - | - |
| [Python SDKs](/software/developer/sdks) | What each SDK covers and how to install a release |
| [Configuring the New Hub Region in GKL API](/software/third-party/vivenu/hub-deployment/development/configure-new-hub-region) | Per-hub credentials that `from_env(prefix=...)` reads |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.