User Guide

This is a topical guide to general API client usage. Module Reference has in-depth documentation on client classes and methods.

Installation

If pip is available, it can be installed via:

pip install pdpyras

Alternately, if the dependencies (Requests and “deprecation” Python libraries) have been installed locally, one can download pdpyras.py into the directory where it will be used.

Authentication

The first step is to construct a session object. The first argument to the constructor is the secret to use for accessing the API:

import pdpyras

# REST API v2:
session = pdpyras.APISession(API_KEY)

# REST API v2 with an OAuth2 access token:
session_oauth = pdpyras.APISession(OAUTH_TOKEN, auth_type='oauth2')

# Events API v2:
events_session = pdpyras.EventsAPISession(ROUTING_KEY)

# A special session class for the change events API (part of Events API v2):
change_events_session = pdpyras.ChangeEventsAPISession(ROUTING_KEY)

Session objects, being descendants of requests.Session, can also be used as context managers. For example:

with pdpyras.APISession(API_KEY) as session:
    do_application(session)

The From header

If the REST API v2 session will be used for API endpoints that require a From header, such as those that take actions on incidents, and if it is using an account-level API key (created by an administrator via the “API Access Keys” page in the “Integrations” menu), the user must also supply the default_from keyword argument. Otherwise, a HTTP 400 response will result when making requests to such endpoints.

Otherwise, if using a user’s API key (created under “API Access” in the “User Settings” tab of the user’s profile), the user will be derived from the key itself and default_from is not necessary.

Using Non-US Service Regions

If your PagerDuty account is in the EU or other service region outside the US, set the url attribute according to the documented API Access URLs, i.e. for the EU:

# REST API
session.url = 'https://api.eu.pagerduty.com'
# Events API:
events_session.url = 'https://events.eu.pagerduty.com'

Basic Usage Examples

REST API v2

Making a request and decoding the response: obtaining a resource’s contents and having them represented as a dictionary object using three different methods:

# Using get:
response = session.get('/users/PABC123')
user = None
if response.ok:
    user = response.json()['user']

# Using jget (return the full body after decoding):
user = session.jget('/users/PABC123')['user']

# Using rget (return the response entity after unwrapping):
user = session.rget('/users/PABC123')

# >>> user
# {"type": "user", "email": "user@example.com", ... }

Using pagination: iter_all, iter_cursor, list_all and dict_all can be used to obtain results from a resource collection:

# Print each user's email address and name:
for user in session.iter_all('users'):
    print(user['id'], user['email'], user['name'])

Pagination with query parameters: set the params keyword argument, which is converted to URL query parameters by Requests:

# Get a list of all services with "SN" in their name:
services = session.list_all('services', params={'query': 'SN'})

# >>> services
# [{'type':'service', ...}, ...]

Searching resource collections: use find to look up a resource exactly matching a string using the query parameter on an index endpoint:

# Find the user with email address "jane@example35.com"
user = session.find('users', 'jane@example35.com', attribute='email')

# >>> user
# {'type': 'user', 'email': 'jane@example35.com', ...}

Updating a resource: use the json keyword argument to set the body:

# Assuming there is a variable "user" defined that is a dictionary
# representation of a PagerDuty user, i.e. as returned by rget or find:

# (1) using put directly:
updated_user = None
response = session.put(user['self'], json={
    'user': {
        'type':'user',
        'name': 'Jane Doe'
    }
})
if response.ok:
    updated_user = response.json()['user']

# (2) using rput:
#   - The URL argument can be the dictionary representation
#   - The json argument doesn't have to include the "user" wrapper dict
try:
    updated_user = session.rput(user, json={
        'type':'user',
        'name': 'Jane Doe'
    })
except PDClientError:
    updated_user = None

Idempotent create/update:

# Create a user if one doesn't already exist based on the dictionary object
# user_data, using the 'email' key as the uniquely identifying property,
# and update it if it exists and differs from user_data:
user_data = {'email': 'user123@example.com', 'name': 'User McUserson'}
updated_user = session.persist('users', 'email', user_data, update=True)

Using multi-valued set filters: set the value in the params dictionary at the appropriate key to a list. Square brackets will then be automatically appended to the names of list-type-value parameters as necessary. Ordinarily (and in pdpyras versions prior to 4.4.0) one must include [] at the end of the paramter name to denote a set type filter. For example:

# Query all open incidents assigned to a user
incidents = session.list_all(
    'incidents',
    params={
      'user_ids[]':['PHIJ789'], # (Necessary in < 4.4.0, compatible with >= 4.4.0)
      'statuses':['triggered', 'acknowledged'] # (>= 4.4.0)
    }
)
# API calls will look like the following:
# GET /incidents?user_ids%5B%5D=PHIJ789&statuses%5B%5D=triggered&statuses%5B%5D=acknowledged&offset=0&limit=100

Performing multi-update: for endpoints that support it only, i.e. PUT /incidents:

# Acknowledge all triggered incidents assigned to a user:
incidents = session.list_all(
    'incidents',
    params={'user_ids':['PHIJ789'],'statuses':['triggered']}
)
for i in incidents:
    i['status'] = 'acknowledged'
updated_incidents = session.rput('incidents', json=incidents)

Events API v2

Trigger and resolve an alert, getting its deduplication key from the API, using EventsAPISession:

dedup_key = events_session.trigger("Server is on fire", 'dusty.old.server.net')
# ...
events_session.resolve(dedup_key)

Trigger an alert and acknowledge it using a custom deduplication key:

events_session.trigger("Server is on fire", 'dusty.old.server.net',
    dedup_key='abc123')
# ...
events_session.acknowledge('abc123')

Submit a change event using a ChangeEventsAPISession instance:

change_events_session.submit("new build finished at latest HEAD",
    source="automation")

Generic Client Features

Generally, all of the features of requests.Session are available to the user as they would be if using the Requests Python library directly, since pdpyras.PDSession and its subclasses for the REST/Events APIs are descendants of it.

The get, post, put and delete methods of REST/Events API session classes are similar to the analogous functions in requests.Session. The arguments they accept are the same and they all return requests.Response objects.

Any keyword arguments passed to the j* or r* methods will be passed through to the analogous method in Requests, though in some cases the arguments (i.e. json) are first modified.

For documentation on any generic HTTP client features that are available, refer to the Requests documentation.

URLs

The first argument to most of the session methods is the URL. However, there is no need to specify a complete API URL. Any path relative to the root of the API, whether or not it includes a leading slash, is automatically normalized to a complete API URL. For instance, one can specify users/PABC123 or /users/PABC123 instead of https://api.pagerduty.com/users/PABC123.

One can also pass the full URL of an API endpoint and it will still work, i.e. the self property of any object can be used, and there is no need to strip out the API base URL.

The r* (and j* methods as of version 5), i.e. pdpyras.APISession.rget, can also accept a dictionary object representing an API resource or a resource reference in place of a URL, in which case the URL at its self key will be used as the request target.

Query Parameters

As with Requests, there is no need to compose the query string (everything that will follow ? in the URL). Simply set the params keyword argument to a dictionary, and each of the key/value pairs will be serialized to the query string in the final URL of the request:

first_dan = session.rget('users', params={
    'query': 'Dan',
    'limit': 1,
    'offset': 0,
})
# GET https://api.pagerduty.com/users?query=Dan&limit=1&offset=0

To specify a multi-value parameter, i.e. include[], set the argument to a list. As of v4.4.0, if a list is given, and the key name does not end with [] (which is required for all such multi-valued parameters in REST API v2), then [] will be automatically appended to the parameter name.

# If there are 82 services with name matching "foo" this will return all of
# them as a list:
foo_services = session.list_all('services', params={
    'query': 'foo',
    'include': ['escalation_policies', 'teams'],
    'limit': 50,
})
# GET https://api.pagerduty.com/services?query=foo&include%5B%5D=escalation_policies&include%5B%5D=teams&limit=50&offset=0
# GET https://api.pagerduty.com/services?query=foo&include%5B%5D=escalation_policies&include%5B%5D=teams&limit=50&offset=50
# >>> foo_services
# [{"type": "service" ...}, ... ]

Requests and Responses

To set the request body in a post or put request, pass as the json keyword argument an object that will be JSON-encoded as the body.

To obtain the response from the API, if using plain get, post, put or delete, use the returned requests.Response object. That object’s json() method will return the result of JSON-decoding the response body (it will typically of type dict). Other metadata such as headers can also be obtained:

response = session.get('incidents')
# The UUID of the API request, which can be supplied to PagerDuty Customer
# Support in the event of server errors (status 5xx):
print(response.headers['x-request-id'])

If using the j* methods, i.e. pdpyras.APISession.jget, the return value will be the full body of the response from the API after JSON-decoding, and the json keyword argument is not modified.

When using the r* methods, the json keyword argument is modified before sending to Requests, if necessary, to encapsulate the body inside an entity wrapper. The response is the decoded body after unwrapping, if the API endpoint returns wrapped entities. For more details, refer to Entity Wrapping.

Data types

Main article: Types

Note these analogues in structure between the JSON schema and the object in Python:

  • If the data type documented in the schema is “object”, then the corresponding type of the Python object will be dict.

  • If the data type documented in the schema is array, then the corresponding type of the Python object will be list.

  • Generally speaking, the data type in the decoded object is according to the design of the json Python library.

For example, consider the example structure of an escalation policy as given in the API reference page for GET /escalation_policies/{id} (“Get an escalation policy”).. To access the name of the second target in level 1, assuming the variable ep represents the unwrapped escalation policy object:

ep['escalation_rules'][0]['targets'][1]['summary']
# "Daily Engineering Rotation"

To add a new level, one would need to create a new escalation rule as a dictionary object and then append it to the escalation rules property. Using the example given in the API reference page:

new_rule = {
    "escalation_delay_in_minutes": 30,
    "targets": [
        {
            "id": "PAM4FGS",
            "type": "user_reference"
        },
        {
            "id": "PI7DH85",
            "type": "schedule_reference"
        }
    ]
}
ep['escalation_rules'].append(new_rule)
# Save changes:
session.rput(ep, json=ep)

Resource schemas

Main article: Resource Schemas

The details of any given resource’s schema can be found in the request and response examples from the PagerDuty API Reference pages for the resource’s respective API, as well as the page documenting the resource type itself.

Entity Wrapping

See also: Wrapped Entities. Most of PagerDuty’s REST API v2 endpoints respond with their content wrapped inside of another object with a single key at the root level of the (JSON-encoded) response body, and/or require the request body be wrapped in another object that contains a single key. Endpoints with such request/response schemas are said to wrap entities.

Wrapped-entity-aware Functions

The following methods will automatically extract and return the wrapped content of API responses, and wrap request entities for the user as appropriate:

Classic Patterns

Typically (but not for all endpoints), the key (“wrapper name”) is named after the last or second to last node of the URL’s path. The wrapper name is a singular noun for an individual resource or plural for a collection of resources. As of v5.0.0, the above methods support endpoints where that pattern does not apply. In versions prior to v5.0.0, they may only be used on APIs that follow these conventions, and will run into KeyError when used on endpoints that do not.

Special Cases

On endpoints that do not wrap entities, however, the results for a given r* method would be the same if using the equivalent j* method. This is necessary to avoid discarding features of the response schema.

The configuration that this client uses to decide if entity wrapping is enabled for an endpoint or not is stored in the module variable pdpyras.ENTITY_WRAPPER_CONFIG and generally follows this rule: If the endpoint’s response body or expected request body contains only one property that points to all the content of the requested resource, entity wrapping is enabled for the endpoint. The only exception is for resource collection endpoints that support pagination, where response bodies have additional pagination control properties like more but only one content-bearing property that wraps the collection of results.

This rule also applies to endpoints like POST /business_services/{id}/subscribers where the response is wrapped differently than the request. One can still pass the content to be wrapped via the json argument without the subscribers wrapper, while the return value is the list representing the content inside of the subscriptions wrapper in the response, and there is no need to incorporate any particular endpoint’s wrapper name into the implementation.

Some endpoints are unusual in that the request must be wrapped but the response is not wrapped or vice versa, i.e. creating Schedule overrides (POST /schedules/{id}/overrides) or to create a status update on an incient (POST /incidents/{id}/status_updates). In all such cases, the user still does not need to account for this, as the content will be returned and the request entity is wrapped as appropriate. For instance:

created_overrides = session.rpost('/schedules/PGHI789/overrides', json=[
    {
        "start": "2023-07-01T00:00:00-04:00",
        "end": "2023-07-02T00:00:00-04:00",
        "user": {
            "id": "PEYSGVA",
            "type": "user_reference"
        },
        "time_zone": "UTC"
    },
    {
        "start": "2023-07-03T00:00:00-04:00",
        "end": "2023-07-01T00:00:00-04:00",
        "user": {
            "id": "PEYSGVF",
            "type": "user_reference"
        },
        "time_zone": "UTC"
    }
])
# >>> created_overrides
# [
#     {'status': 201, 'override': {...}},
#     {'status': 400, 'errors': ['Override must end after its start'], 'override': {...}}
# ]

Pagination

The method pdpyras.APISession.iter_all returns an iterator that yields results from an endpoint that returns a wrapped collection of resources. By default it will use classic, a.k.a. numeric pagination. If the endpoint supports cursor-based pagination, it will use pdpyras.APISession.iter_cursor to iterate through results instead. The methods pdpyras.APISession.list_all and pdpyras.APISession.dict_all will request all pages of the collection and return the results as a list or dictionary, respectively.

Pagination functions require that the API endpoint being requested have entity wrapping enabled, and respond with either a more or cursor property indicating how and if to fetch the next page of results.

For example:

# Example: Find all users with "Dav" in their name/email (i.e. Dave/David)
# in the PagerDuty account:
for dave in session.iter_all('users', params={'query':"Dav"}):
    print("%s <%s>"%(dave['name'], dave['email']))

# Example: Get a dictionary of all users, keyed by email, and use it to
# find the ID of the user whose email is ``bob@example.com``
users = session.dict_all('users', by='email')
print(users['bob@example.com']['id'])

# Same as above, but using ``find``:
bob = session.find('users', 'bob@example.com', attribute='email')
print(bob['id'])

Performance and Completeness of Results

Because HTTP requests are made synchronously and not in multiple threads, requesting all pages of data will happen one page at a time and the functions list_all and dict_all will not return until after the final HTTP response. Simply put, the functions will take longer to return if the total number of results is higher.

Moreover, if these methods are used to fetch a very large volume of data, and an error is encountered when this happens, the partial data set will be discarded when the exception is raised. To make use of partial results, use pdpyras.APISession.iter_all, perform actions on each result yielded, and catch/handle exceptions as desired.

Updating, creating or deleting while paginating

If performing page-wise write operations, i.e. making persistent changes to the PagerDuty application state immediately after fetching each page of results, an erroneous condition can result if there is any change to the resources in the result set that would affect their presence or position in the set. For example, creating objects, deleting them, or changing the attribute being used for sorting or filtering.

This is because the contents are updated in real time, and pagination contents are recalculated based on the state of the PagerDuty application at the time of each request for a page of results. Therefore, records may be skipped or repeated in results if the state changes, because the content of any given page will change accordingly. Note also that changes made from other processes, including manual edits through the PagerDuty web application, can have the same effect.

To elaborate: let’s say that each resource object in the full list is a page in a notebook. Classic pagination with limit=100 is essentially “go through 100 pages, then repeat starting with the 101st page, then with the 201st, etc.” Deleting records in-between these 100-at-a-time pagination requests would be like tearing out pages after reading them. At the time of the second page request, what was originally the 101st page before starting will shift to become the first page after tearing out the first hundred pages. Thus, when going to the 101st page after finishing tearing out the first hundred pages, the second hundred pages will be skipped over, and similarly for pages 401-500, 601-700 and so on. If attaching pages, the opposite happens: some results will be returned more than once, because they get bumped to the next group of 100 pages.

Multi-updating

Multi-update actions can be performed using rput. As of this writing, multi-update support includes the following endpoints:

For instance, to resolve two incidents with IDs PABC123 and PDEF456:

session.rput(
    "incidents",
    json=[
        {'id':'PABC123','type':'incident_reference', 'status':'resolved'},
        {'id':'PDEF456','type':'incident_reference', 'status':'resolved'},
    ],
)

In this way, a single API request can more efficiently perform multiple update actions.

It is important to note, however, that updating incidents requires using a user-scoped access token or setting the From header to the login email address of a valid PagerDuty user. To set this, pass it through using the headers keyword argument, or set the pdpyras.APISession.default_from property, or pass the email address as the default_from keyword argument when constructing the session initially.

Error Handling

For any of the methods that do not return requests.Response, when the API responds with a non-success HTTP status, the method will raise a pdpyras.PDClientError exception. This way, these methods can always be expected to return the same structure of data based on the API being used, and there is no need to differentiate between the response schema for a successful request and one for an error response.

The exception class has the requests.Response object as its response property whenever the exception pertains to a HTTP error. One can thus define specialized error handling logic in which the REST API response data (i.e. headers, code and body) are available in the catching scope.

For instance, the following code prints “User not found” in the event of a 404, prints out the user’s email if the user exists, raises the underlying exception if it’s any other HTTP error code, and prints an error otherwise:

try:
    user = session.rget("/users/PJKL678")
    print(user['email'])

except pdpyras.PDClientError as e:
    if e.response:
        if e.response.status_code == 404:
            print("User not found")
        else:
            raise e
    else:
        print("Non-transient network or client error")

Version 4.4.0 introduced a new error subclass, PDHTTPError, in which it can be assumed that the error pertains to a HTTP request and the response property is not None:

try:
    user = session.rget("/users/PJKL678")
    print(user['email'])
except pdpyras.PDHTTPError as e:
    if e.response.status_code == 404:
        print("User not found")
    else:
        raise e
except pdpyras.PDClientError as e:
    print("Non-transient network or client error")

Logging

When a session is created, a Logger object is created as follows:

  • Its level is unconfigured (logging.NOTSET) which causes it to defer to the level of the parent logger. The parent is the root logger unless specified otherwise (see Logging Levels).

  • The logger is initially not configured with any handlers. Configuring handlers is left to the discretion of the user (see logging.handlers)

  • The logger can be accessed and set through the property pdpyras.PDSession.log.

In v5.0.0 and later, the attribute pdpyras.PDSession.print_debug was introduced to enable sending debug-level log messages from the client to command line output. It is used as follows:

# Method 1: keyword argument, when constructing a new session:
session = pdpyras.APISession(api_key, debug=True)

# Method 2: on an existing session, by setting the property:
session.print_debug = True

# to disable:
session.print_debug = False

What this does is assign a logging.StreamHandler directly to the session’s logger and set the log level to logging.DEBUG. All log messages are then sent directly to sys.stderr. The default value for all sessions is False, and it is recommended to keep it that way in production systems.

Using a Proxy Server

To configure the client to use a host as a proxy for HTTPS traffic, update the proxies attribute:

# Host 10.42.187.3 port 4012 protocol https:
session.proxies.update({'https': '10.42.187.3:4012'})

HTTP Retry Configuration

Session objects support retrying API requests if they receive a non-success response or if they encounter a network error.

This behavior is configurable through the following properties:

Default Behavior

By default, after receiving a status 429 response, sessions will retry an unlimited number of times, increasing the wait time before retry each successive time. When encountering status 401 Unauthorized, the client will immediately raise pdpyras.PDClientError; this is a non-transient error caused by an invalid credential.

For all other success or error statuses, the underlying request method in the client will return the requests.Response object.

Exponential Cooldown

After each unsuccessful attempt, the client will sleep for a short period that increases exponentially with each retry.

Let:

Assuming ρ = 0:

tn = t0 an

If ρ is nonzero:

tn = a (1 + ρ rn) tn-1

Setting the retry property

The dictionary property pdpyras.PDSession.retry allows customization of HTTP retry limits on a per-HTTP-status basis. This includes the ability to override the above defaults for 401 and 429, although that is not recommended.

Each key in the dictionary represents a HTTP status, and its associated value is the number of times that the client will retry the request if it receives that status. Success statuses (2xx) will be ignored.

If a different error status is encountered on a retry, it won’t count towards the limit of the first status, but will be counted separately. However, the total overall number of attempts that will be made to get a success status is limited by pdpyras.PDSession.max_http_attempts. This will always supersede the maximum number of retries for any status defined in pdpyras.PDSession.retry if it is lower.

Low-level HTTP request functions in client classes, i.e. get, will return requests.Response objects when they run out of retries. Higher-level functions that require a success status response, i.e. pdpyras.APISession.list_all and pdpyras.EventsAPISession.trigger, will raise exceptions that include the response object when they encounter error status responses, but only after the configured retry limits are reached in the underlying HTTP request methods.

Example:

# This will take about 30 seconds plus API request time, carrying out four
# attempts with 2, 4, 8 and 16 second pauses between them, before finally
# returning the status 404 response object for the user that doesn't exist:
session.max_http_attempts = 4 # lower value takes effect
session.retry[404] = 5 # this won't take effect
session.sleep_timer = 1
session.sleep_timer_base = 2
response = session.get('/users/PNOEXST')

# Same as the above, but with the per-status limit taking precedence, so
# the total wait time is 62 seconds:
session.max_http_attempts = 6
response = session.get('/users/PNOEXST')