Async Client and Composable Flows in the Python SDK
Use AsyncAlertOps inside async with and await every op, and write multi-step helpers once as @flow generators that yield .lazy() calls so they run unchanged on both the sync and the async client.
Overview
Relevant for Developers using asyncio or writing reusable helpers
AsyncAlertOps has exactly the same surface as AlertOps: the same resources, ops and helpers. The difference is that every call returns an awaitable and .iter() returns an async iterator. The helpers work on both clients because they're written as flows: generator functions that describe the requests to make without making them. The client runs each step, synchronously or asynchronously, and sends the result back into the generator.
Key benefits:
- Same code on both clients — every built-in helper, from groups.find to the whole semantic layer, is one flow that runs on AlertOps and AsyncAlertOps.
- Your own helpers — subclass Resource, decorate a generator with @flow, and you get the same dual behaviour.
- Normal error handling — a failed step raises inside your generator, so try/except works as usual.
Using the async client
Relevant for Developers calling AlertOps from asyncio code
Create the client inside a running event loop, preferably with async with so the connection pool closes on exit.
import asyncio
from alertops import AsyncAlertOps
async def main():
async with AsyncAlertOps() as ao: # same config rules as AlertOps()
tz = await ao.account.time_zone() # helper flow: awaitable
group = await ao.groups.find("NOC") # helper flow: awaitable
if group:
members = await ao.groups.members.list(group.group_id) # plain op: awaitable
print(tz, group.group_name, len(members))
async for g in ao.groups.list.iter(): # every page, async iterator
print(g.group_id, g.group_name)
policies = await ao.escalation_policies.list.all() # AOList of every item
asyncio.run(main())
Async client methods
| Method | HTTP | What it does |
| AsyncAlertOps(api_key=None, *, http_client=None, **settings) | none | Same arguments as AlertOps. http_client= takes an httpx.AsyncClient. |
| async with AsyncAlertOps() as ao | none | Closes the connection pool on exit. |
| await ao.aclose() | none | Closes the pool explicitly (the sync client's close()). |
| await ao.<resource>.<op>(...) | the op's method | Runs one request. |
| async for x in ao.<resource>.<op>.iter(...) | once per page | Streams every item across pages. |
| await ao.<resource>.<op>.all(...) | once per page | Collects every item into an AOList. |
| await ao.<resource>.<helper>(...) | several | Runs a helper flow. |
| await ao.request(method, path, base=…) | any | The escape hatch, awaited. |
Create the async client on the loop that uses it
The underlying httpx.AsyncClient belongs to the event loop it first runs on. The live test suite builds a fresh AsyncAlertOps per test for this reason. Don't create one at import time and reuse it across separate asyncio.run() calls.
Independent calls can run concurrently with asyncio.gather, because each op is an ordinary coroutine:
async with AsyncAlertOps() as ao:
group, user = await asyncio.gather(ao.groups.get(12345), ao.users.get(101))
Retries, Retry-After handling and error mapping behave exactly as on the sync client. Keep concurrency modest so you don't trigger 429 throttling.
What a flow is
Relevant for Developers who want to understand how the helpers work
A flow is a generator decorated with @flow. It doesn't do any I/O itself (it's "sans-IO"). Instead it yields a description of the next step, and the client's runner executes that step and sends the result back:
| You yield | The runner executes | You receive |
| op.lazy(*args, **kw) | one request | the parsed result |
| op.lazy_all(*args, **kw) | every page of a paginated op | an AOList of all items |
| other_flow.lazy(*args, **kw) | a nested flow | its return value |
| a list or tuple of the above | each step in order | a list of results |
| any other value | nothing | the same value back |
When a step raises (for example NotFoundError), the runner throws the exception into your generator at the yield, so a try/except around the yield catches it. If you don't catch it, it propagates to the caller as usual. The flow's return value is what the caller gets.
On AlertOps, calling a flow runs it to completion and returns the value. On AsyncAlertOps, calling the same flow returns a coroutine that you await.
This is how the built-in groups.find and groups.ensure are written:
@flow
def find(self, name):
rows = yield self.list.lazy(search=name, limit=100)
for g in rows:
if str(g.get("group_name", "")).lower() == name.lower():
return g
return None
@flow
def ensure(self, group_name, **fields):
existing = yield self.find.lazy(group_name) # nested flow
if existing is not None:
return existing, False
created = yield self.create.lazy(group_name=group_name, **fields)
return created, True
Write your own flow
Relevant for Developers packaging multi-step logic
- Subclass Resource.
- Decorate a generator method with @flow.
- Reach other resources through self._client and yield .lazy(...) calls, never direct calls.
- Instantiate your resource with a client: MyOps(ao).
from alertops import AlertOps, AsyncAlertOps, NotFoundError, Resource, flow
class OnCallOps(Resource):
@flow
def group_size(self, name):
"""Number of members in the group called name (0 if there is no such group)."""
g = yield self._client.groups.find.lazy(name) # nested flow
if g is None:
return 0
members = yield self._client.groups.members.list.lazy(g.group_id) # one request
return len(members)
@flow
def group_name_or_none(self, group_id):
"""Error forwarding: a 404 raised by the step lands in this try/except."""
try:
g = yield self._client.groups.get.lazy(group_id)
except NotFoundError:
return None
return g.group_name
@flow
def user_names(self, search):
"""lazy_all follows pagination and hands back every item."""
rows = yield self._client.users.list.lazy_all(search=search)
return [u.user_name for u in rows]
@flow
def sizes(self, names):
"""Yielding a list runs each step in order and returns a list of results."""
return (yield [self.group_size.lazy(n) for n in names])
ao = AlertOps()
print(OnCallOps(ao).group_size("NOC")) # sync: returns the value
async def main():
async with AsyncAlertOps() as aao:
print(await OnCallOps(aao).group_size("NOC")) # async: same code, awaited
A Resource subclass can also declare its own endpoints with Op, which then get .lazy(), .iter() and .all() like built-in ops:
from alertops import Op, Resource, flow
class Priorities(Resource):
list = Op("GET", "/app/priority_types")
@flow
def names(self):
rows = yield self.list.lazy()
return [r["name"] for r in rows]
Priorities(ao).names() # ['Critical', 'High', 'Medium', 'Low', 'No Priority']
Yield lazy calls; never call ops directly inside a flow
Inside a flow, self._client.groups.get(5) executes immediately on the sync client but returns an un-awaited coroutine on the async client, so the flow breaks there. Always write yield self._client.groups.get.lazy(5). For the same reason, don't put blocking work such as time.sleep() in a flow: on the async client it would block the event loop.
Yielded lists run sequentially
A yielded list is executed step by step, in order, on both clients. That keeps flows deterministic and easy to reason about. For concurrency, use asyncio.gather on the async client outside the flow.
@flow raises TypeError("@flow functions must be generators") if the function has no yield.
Built-in helper flows worth knowing
Relevant for Developers looking for an existing helper before writing one
Every helper below is a flow, so it works on both clients. docs/REFERENCE.md lists all of them under each resource's "Helpers" line.
| Method | HTTP | What it does |
| groups.find(name) / groups.ensure(group_name, **fields) | GET, POST | Case-insensitive lookup; get-or-create returning (group, created). |
| groups.members.add_users(group_id, user_ids, roles=None) | GET, POST | Appends users in order, each with roles (default ["Primary"]). |
| users.find_by_username(user_name) | GET | The user or None (handles both the 404 and the 400 answer). |
| users.create_with_email(user_name, first_name, last_name, email_address, time_zone) | POST | User whose only contact method is Email-Official, 24x7. |
| users.roles.assign(user_id, role) | GET, POST | Adds a role by label or id. |
| schedules.create_shift(group_id, schedule_name, users, time_zone=None) | GET, POST | Creates a shift, filling user names from the group and resolving an IANA zone. |
| schedules.update_fields(group_id, schedule_id, **changes) | GET, PUT | Read, merge, write the whole shift back. |
| escalation_policies.update_options(escalation_policy_id, **changes) | GET, PUT | Read-merge-write of the options record. |
| escalation_policies.member_roles.upsert(escalation_policy_id, member_role_type, **fields) | GET, POST, PUT | Creates a role or updates its timings. |
| response_plays.members.set_sequence(response_play_id, members) | several | Replaces the play's members in order. |
| inbound.create_api(name, source=None, escalation_policy_id=None) | POST, GET | Custom API integration, returned with its api_url. |
| inbound.clone_unique(integration_id, name, source=None) | POST | Clones an API integration with its own source. |
| ingest.post_to_integration(integration_id, payload) | GET, POST | Reads the integration's api_url and posts an alert payload. |
| heartbeats.create(name, interval_min, escalation_policy_id=None) / heartbeats.ping(heartbeat_id) | POST | Creates and pings a heartbeat. |
| workflows.build(name, conditions=(), actions=(), enabled=False) | several | Creates a workflow, adds conditions and actions, optionally enables it. |
| maintenance_windows.schedule(description, integration_ids, start, end) | POST | Window from datetimes in the account time zone. |
| alerts.list_recent(days=1, view_all=True, all_pages=False) | GET | Alerts from the last days days, with viewBy="A" and an inclusive end date. |
| alerts.resolve(alert_id, user, resolution="Resolved") | GET, POST | Closes, or cancels alerts that can't be closed. |
| admin.audit_trail.between(date_from, date_to) | GET | Every audit row between two dates, all pages (uses lazy_all). |
| status_hub.pages.patch(status_page_id, **fields) | GET, PUT | Read-overlay-write of a status page. |
| status_hub.incidents.resolve(incident_id, message="Resolved") | POST | Posts a Resolved update. |
| auth.act_on_link(link) | GET, POST | Runs the action behind a notification link (Acknowledge, Assign, Escalate, Close). |
| lookups.can_add(item_name) / lookups.time_zone_name(zone) | GET | Plan-limit check; IANA zone to AlertOps display name. |
| semantic.apply_blueprint(bp) / semantic.rollback(report) | many | Builds a whole on-call stack with read-back and automatic rollback. |
Most *_if_exists, update, modify and patch helpers are read-merge-write wrappers that exist because many AlertOps PUTs replace the whole record.
Best Practices
Relevant for Anyone writing helpers or async services
Flows
- Write a helper as a flow whenever it makes more than one request, so it serves both clients.
- Keep flows pure: compute, yield lazy calls, return a value. Do I/O, sleeps and logging of side effects outside.
- Catch specific exceptions around the yield that can fail (NotFoundError for "already gone").
- Compose: yield other_flow.lazy(...) rather than copying its steps.
Async
- Open one AsyncAlertOps per event loop with async with.
- Use asyncio.gather for independent reads and keep the fan-out small to stay under rate limits.
- Use async for … in op.iter() for large lists rather than await op.all().
Configuration Examples
Relevant for Starting templates for common patterns
Example 1 — Get or create a group and add members (sync and async)
group, created = ao.groups.ensure("sdk-example-noc")
ao.groups.members.add_users(group.group_id, [101, 102], roles=["Primary"])
async with AsyncAlertOps() as aao:
group, created = await aao.groups.ensure("sdk-example-noc")
await aao.groups.members.add_users(group.group_id, [101, 102], roles=["Primary"])
Example 2 — A cleanup flow that tolerates missing objects
from alertops import NotFoundError, Resource, flow
class Cleanup(Resource):
@flow
def remove_groups(self, group_ids):
removed = 0
for gid in group_ids:
try:
yield self._client.groups.delete.lazy(gid)
removed += 1
except NotFoundError:
pass # already gone
return removed
Cleanup(ao).remove_groups([12345, 12346])
Example 3 — Concurrent reads with the async client
import asyncio
from alertops import AsyncAlertOps
async def snapshot():
async with AsyncAlertOps() as ao:
groups, policies, users = await asyncio.gather(
ao.groups.list.all(), ao.escalation_policies.list.all(), ao.lookups.users())
return len(groups), len(policies), len(users)
print(asyncio.run(snapshot()))
Troubleshooting
Relevant for Anyone debugging async code or custom flows
"coroutine was never awaited" or a flow returns a coroutine object
- On AsyncAlertOps, every op and every flow returns a coroutine. Add await.
- Inside a flow you called an op directly instead of yielding .lazy(...). Change self._client.x.y(...) to yield self._client.x.y.lazy(...).
TypeError: @flow functions must be generators
- The decorated function has no yield. Make at least one step a yield, or drop @flow and write a plain function.
RuntimeError about the event loop, or "Event loop is closed"
- The async client was created on one loop and used on another. Create it inside the coroutine that uses it, with async with.
Calling a helper returns a generator or a Call object instead of data
- The method is a plain generator, not a @flow-decorated one, so no runner executed it. Decorate it with @flow and call it through a Resource instance bound to a client (MyOps(ao).helper()).
- You called .lazy(...) outside a flow. .lazy() only describes a step; call the op or flow directly instead.
Exception from a step isn't caught
- The try must wrap the yield itself. Exceptions are thrown into the generator at the yield of the step that failed.
Related Articles
- Getting Started with the AlertOps Python SDK
- Calling the API from Python: Parameters, Pagination and Errors
- Planning On-Call Setups with the AlertOps Semantic Layer
- Testing and Verifying the Python SDK
Need help?
Email support@alertops.com or contact your AlertOps representative.