Integration Best Practices: Lessons from the Field¶
Building enough OAA integrations surfaces the same handful of mistakes over and over. Each one passes local testing, passes schema validation, and then fails quietly — a null field here, a duplicate registration there, a retry that only covers half the HTTP calls. None of these will show up as an obvious error. They show up as a support ticket three weeks later asking why a field is empty in Veza.
This page collects those lessons, each with the failure mode and the fix.
LocalRole descriptions need custom_type, or they vanish silently¶
LocalRole only serializes a fixed set of built-in fields: id, name, permissions, roles, tags, custom_properties, and owners. description is not one of them.
Mapping a source field straight to destination_field: description looks correct and passes config validation:
field_mapping:
role_description:
destination_field: description # looks right, isn't
The value gets set on the in-memory object, then dropped when the role is serialized for push — no warning, no error, just a null description in Veza. The fix is to store it as a custom property instead, which does get serialized:
field_mapping:
role_description:
destination_field: description
custom_type: STRING
Before mapping any field to a template entity, confirm the destination field is actually one of the built-in fields for that entity type — not just a name that sounds plausible.
See also: What Are Veza Custom Properties, What Is the OAA Provider Object
Guard add_custom_permission() against duplicate registration¶
Custom permissions are typically registered from inside a per-record hook — one permission definition discovered per role or resource as the data streams through. That means the same permission name can come up more than once across records, or the hook can run again on a retry.
if name not in provider.custom_permissions:
provider.add_custom_permission(name, permissions=[...], apply_to_sub_resources=False)
Without the guard, re-registering the same permission name on a second pass raises rather than being a no-op. The guard costs one line and removes an entire class of intermittent failures tied to record ordering.
See also: How to Create User-to-Role Relationships
Backfill missing field_mapping keys with None, not "" or False¶
When a source record genuinely omits a key — the API didn’t return it for this record, not “returned it as empty” — that absence is itself a signal, and it’s a different signal than the field being present with a falsy value. A None means “we don’t know.” An empty string or False means “we know, and the answer is negative.”
Backfilling to None rather than a display-friendly default preserves that distinction downstream, for anything that checks a field’s presence rather than its truthiness:
for key in ('manager_id', 'department', 'is_active'):
row.setdefault(key, None)
Backfilling to "" or 0 instead collapses “unknown” and “known-empty” into the same value, and code that filters on truthiness (if row['department']:) will silently treat both cases the same way.
See also: How to Write a Fetch Function
Retry-harden every HTTP session, not just the paginated data fetch¶
It’s natural to mount a Retry-configured HTTPAdapter on the session used for the main paginated data pull, since that’s the one making the bulk of the requests and the one most likely to hit a rate limit. It’s easy to forget that the session used for authentication — fetching or refreshing a token — is a separate requests.Session (or a separate client entirely) that needs the same treatment.
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry)
auth_session.mount('https://', adapter)
data_session.mount('https://', adapter)
Without it, a transient 429 or 5xx during token refresh fails the entire run hard, even though the exact same class of error against the data endpoint would have retried and succeeded.
See also: How to Handle API Pagination
A consistent README structure pays off across many integrations¶
There’s no framework requirement here, but a predictable README shape makes it much faster to pick up an integration someone else wrote, or to pick up your own after six months away:
Overview — what system this integrates with, what entities it maps
Setup — how to install and configure
Environment Variables — every var from
required_env, with a one-line description eachUsage — run commands, using
uv runrather than a barepython3invocationMocks — how to run against mock data instead of the live API
Troubleshooting — known gotchas specific to this integration
References — links to the source API’s docs
# Usage
uv tool run --from oaa-runner oaa app run --config=config.yaml --save-json
Consistency across integrations matters more than any individual section’s content — someone jumping between five integrations shouldn’t have to relearn where things live each time.
See also: How to Bundle for OAA Runner
lazy_dict_table derives its columns from the first record only¶
lazy_dict_table() extracts its header row from the keys of the first record it sees, then reuses that fixed column set for every subsequent record. If a key is present on most rows but missing from the first one — or present on the first row but absent later — it silently disappears from the table entirely. No error, no warning, just a column that never makes it downstream.
This surfaces most often with heterogeneous record shapes from the same endpoint, e.g. a roleId field that service-account rows omit but human-user rows include:
def fetch(connection=None, source=None, **kwargs):
def data_generator():
for record in api.fetch_all():
yield record # if record 1 lacks `roleId`, no row will ever have it
return lazy_dict_table(data_generator())
The fix is to normalize keys before they reach lazy_dict_table — backfill every expected key with None on each record as it’s yielded, so the first record already carries the full column set regardless of which record happens to arrive first.
See also: What is lazy_dict_table()?, How to Write a Fetch Function
Custom property names must match ^[a-z][a-z_]*$¶
oaaclient enforces this regex on every property name passed to define_local_user_property(), define_resource_property(), and their siblings: lowercase only, must start with a letter, underscores as the only allowed separator — no digits, spaces, or other punctuation.
This is easy to miss when a property name is derived from a customer’s own free-text label rather than hardcoded — a custom field called “Cost Center” or “2FA Enabled” will be rejected outright if passed through as-is:
def slugify_property_name(label: str) -> str:
slug = re.sub(r'[^a-z_]', '_', label.lower())
return slug if slug[0].isalpha() else f"p_{slug}"
Slugify any dynamic or customer-provided label before it reaches a define_*_property() call. Hardcoded property names you write yourself are easy to get right the first time; the risk is entirely in field names discovered at runtime.
See also: What Are Veza Custom Properties
The mock framework can’t exercise retry, backoff, or header-based pagination¶
Three related gaps, all in the same place: maybe_mock_it() replaces api.get/api.post directly with a MagicMock, one layer above wherever a mounted HTTPAdapter’s Retry policy would normally run. A mocked call never reaches the underlying requests.Session, so retry/backoff logic is structurally unreachable in mock mode — it’s not that a mock can’t return a 429, it’s that the retry mechanism the 429 would trigger is bypassed entirely. Verify retry behavior against the live API in --no-push mode instead, never in mock mode.
Separately, MockResponse doesn’t expose a .headers attribute. Pagination logic that depends on response headers — a Link: <url>; rel="next" header for has-more detection, for example — can’t be exercised through the standard mock path. One workaround is a length-based has-more fallback used only in the mock branch, keeping the real header-based logic for live traffic:
if MOCK_RESULTS:
has_more = len(page_records) == page_size # headers unavailable in mock mode
else:
has_more = 'rel="next"' in resp.headers.get('Link', '')
And a third, unrelated-but-easy-to-hit gap in the same area: mock mode requires both --dry-run=True and --no-push=True together, not --dry-run=True alone. ProviderManagerMixin._get_or_create_provider() only skips the real Veza-credential check when --no-push=True is set; --dry-run alone still triggers a MISSING_AUTH: API key cannot be None before any mock response is ever consulted, even with dummy VEZA_URL/VEZA_API_KEY values in place.
See also: Mock Framework Reference, How to Mock API Responses for Local Development
Mock paths for parameterized endpoints need a concrete ID, not a template¶
@mock_response(path='/accounts/{account_id}/users') looks like it should match a real request to /accounts/acct-123/users, but it won’t — the main module resolves the templated path to a concrete URL before calling get(), while the decorator registers the literal template string. The two never line up, and the request raises OAAMockException.
The fix is to register the mock against a fixed literal ID instead of a template, and drive that same ID through config.yaml so both the mock and the “real” path resolve identically:
@mock_response(path='/accounts/mock-account-id/users')
def mock_account_users():
return {'users': [...]}
connections:
my_api:
params:
account_id: mock-account-id # overridden via env var for the live API
This pattern scales to endpoints with many valid IDs — register one mock per known ID and loop-register them if there are dozens, rather than trying to make the decorator match a pattern.
See also: Mock Framework Reference
required_env must be written as a YAML list, even for a single variable¶
required_env: MY_API_KEY — a bare string instead of a list — is not rejected by oaa app validate or any other check. oaa-runner’s own pydantic model iterates whatever it’s given with [v.strip() for v in value if v and v.strip()]; when value is a string, Python iterates it character-by-character, silently producing a “valid” list of single letters. The real required variable is gone, replaced by a list of individual characters, and nothing downstream catches it:
required_env:
- MY_API_KEY # correct — always a list, even for one variable
required_env: MY_API_KEY # wrong — validates cleanly, fails silently at runtime
Always use the list form, and double-check this specifically on review — it passes every automated check that exists.
See also: required_env Reference