Automating Jira Issue Creation from Correlated Faults
Teams that run their network operations out of Jira hit a specific class of failure when they automate issue creation from a correlation engine. The REST v3 create endpoint is unforgiving about field shape: a custom field addressed by name instead of its customfield_10xxx id is rejected outright, a description sent as a plain string instead of an Atlassian Document Format node fails schema validation, and a naive create-on-every-event loop opens a fresh issue for each retry and each re-fired alarm. During a storm that means an engineer wakes up to two hundred near-identical issues instead of one, MTTA collapses under manual de-duplication, and the SLA clock on the real transport fault runs unchecked. This reference builds a strict, idempotent, rate-aware Jira issue-creation path in async Python so that one correlated fault produces exactly one issue, even across retries and replays.
Schema alignment and taxonomy anchor
This is the Jira-specific write path within ITSM Ticket Creation, the serialization stage of the Ticket Routing & ITSM Automation reference pipeline. That parent stage owns the general fault-to-payload contract; this page owns the narrower job of turning a correlated incident into a well-formed REST v3 issue body and creating it exactly once. The input is the canonical incident object emitted by the correlation engine, carrying the same dedup_key, severity, and provenance defined in Event Schema Design — so the Jira path never re-derives root cause, it only re-serializes it.
Because the input contract is shared, the Jira write is a sibling of the ServiceNow incident payload path and the BMC Remedy integration. All three accept the same correlated fault; only the field names, the transport, and the idempotency mechanism differ. Jira has no server-side dedup key equivalent to ServiceNow’s correlation_id, so idempotency here is enforced client-side: a deterministic label derived from the fault’s dedup_key, checked with a search before every create.
The Pydantic V2 issue model
The model serializes a correlated fault into the REST v3 create body. Custom fields are addressed by their id, the description is emitted as an Atlassian Document Format node, and the deterministic dedup label is computed from the fault’s dedup_key so it is identical on every attempt.
import hashlib
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, ConfigDict, field_validator
class Priority(str, Enum):
P1 = "Highest"
P2 = "High"
P3 = "Medium"
P4 = "Low"
P5 = "Lowest"
# Map correlation-engine field names onto tenant custom-field ids once,
# so a renamed field is a one-line change rather than scattered strings.
CUSTOM_FIELD_MAP = {
"fault_domain": "customfield_10142",
"sla_tier": "customfield_10143",
"root_cause_ci": "customfield_10144",
}
def dedup_label(dedup_key: str) -> str:
# Deterministic, JQL-safe label: no spaces, stable across retries.
digest = hashlib.sha1(dedup_key.encode()).hexdigest()[:16]
return f"corr-{digest}"
def adf(text: str) -> dict[str, Any]:
# Minimal Atlassian Document Format paragraph node.
return {
"type": "doc", "version": 1,
"content": [{"type": "paragraph",
"content": [{"type": "text", "text": text}]}],
}
class JiraIssueFields(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
summary: str = Field(min_length=1, max_length=255)
description: dict[str, Any]
priority_name: Priority
labels: list[str] = Field(min_length=1)
project_key: str = Field(min_length=1)
issue_type: str = "Incident"
custom: dict[str, str] = Field(default_factory=dict)
@field_validator("labels")
@classmethod
def labels_are_jql_safe(cls, v: list[str]) -> list[str]:
for label in v:
if " " in label:
raise ValueError(f"Jira labels cannot contain spaces: {label}")
return v
def to_rest_v3(self) -> dict[str, Any]:
"""Render the create body Jira REST v3 expects."""
fields: dict[str, Any] = {
"project": {"key": self.project_key},
"issuetype": {"name": self.issue_type},
"summary": self.summary,
"description": self.description,
"priority": {"name": self.priority_name.value},
"labels": self.labels,
}
fields.update(self.custom) # custom fields already keyed by id
return {"fields": fields}
def build_issue(fault: dict[str, Any]) -> JiraIssueFields:
label = dedup_label(fault["dedup_key"])
custom = {CUSTOM_FIELD_MAP[k]: str(fault[k])
for k in CUSTOM_FIELD_MAP if k in fault}
return JiraIssueFields(
summary=fault["summary"][:255],
description=adf(fault["detail"]),
priority_name=Priority[fault["priority"]],
labels=[label, "auto-correlated"],
project_key=fault["project_key"],
custom=custom,
)Keying custom fields through a single CUSTOM_FIELD_MAP keeps the tenant-specific id churn in one place, and the dedup_label is what makes the whole path idempotent — it is derived only from the stable dedup_key, so the label is deterministic across every retry and replay. That derivation is the client-side analogue of the shared discipline in Idempotency Keys for Retry-Safe Ticket Creation.
Async create with search-first idempotency and rate-aware retry
Jira has no server-side dedup key, so idempotency is enforced by searching for the label before creating. The dispatcher below runs a non-blocking JQL search first, returns the existing key on a hit, and only creates on a miss — with retries that honor the Retry-After header the REST API returns under rate limiting.
import asyncio
import logging
logger = logging.getLogger("jira_dispatch")
_RETRYABLE = {429, 500, 502, 503, 504}
async def find_existing(client, project_key: str, label: str) -> str | None:
jql = f'project = "{project_key}" AND labels = "{label}"'
resp = await client.post("/rest/api/3/search",
json={"jql": jql, "maxResults": 1,
"fields": ["key"]})
resp.raise_for_status()
issues = resp.json().get("issues", [])
return issues[0]["key"] if issues else None
async def create_issue(client, fields: JiraIssueFields,
dlq: asyncio.Queue, max_attempts: int = 5) -> str | None:
label = fields.labels[0]
# Search-first: never create if the correlated fault already has an issue.
existing = await find_existing(client, fields.project_key, label)
if existing:
logger.info("dedup hit, skipping create", extra={"key": existing})
return existing
body = fields.to_rest_v3()
for attempt in range(1, max_attempts + 1):
try:
resp = await client.post("/rest/api/3/issue", json=body)
if resp.status_code < 300:
return resp.json()["key"]
if resp.status_code not in _RETRYABLE:
await dlq.put({"body": body, "status": resp.status_code,
"detail": resp.text})
return None
# Honor the server's rate-limit hint when present.
delay = float(resp.headers.get("Retry-After",
min(2 ** attempt * 0.1, 5.0)))
except (asyncio.TimeoutError, ConnectionError) as exc:
logger.info("transient create failure", extra={"err": str(exc)})
delay = min(2 ** attempt * 0.1, 5.0)
await asyncio.sleep(delay) # non-blocking backoff
await dlq.put({"body": body, "status": "exhausted"})
return NoneThis hooks into the async pipeline as a bounded consumer identical to the other ITSM targets: a worker pulls a correlated incident, calls build_issue, then create_issue, and yields between items so a throttled Jira tenant never stalls the correlation engine. A narrow race remains — two workers can both miss the search and both create — which is why the label is also a permanent property of the issue, letting a nightly reconciliation collapse any rare twin. The bounded-consumer shape reuses the backpressure design from Backpressure with Bounded asyncio Queues.
Mitigation and hardening
- Search-first deduplication. The deterministic label is queried before every create, so a retry after an ambiguous timeout finds the issue it already opened and returns its key rather than opening a twin. Never derive the label from a timestamp or a send-time UUID.
- Custom-field rejection. A 400 naming an unknown
customfield_idis a mapping defect, not a transient fault. Route it to the dead-letter queue with the response body, alert when the reject rate exceeds 0.5 percent over five minutes, and open a corrective task rather than replaying a request that can never succeed. - Rate-aware backoff. Under a storm the REST API returns 429 with a
Retry-Afterheader; honor it exactly, cap concurrency with a bounded semaphore, and let the upstream queue absorb the burst so high-severity issues are never shed. - Reconciliation for the create race. Because two workers can rarely both miss the search, treat the persistent label as the source of truth and run a periodic reconciliation that merges duplicate labels — the same reconciliation posture used across Ticket Deduplication.
Operational hardening notes
Keep one pooled async client so the search and the create reuse a warm connection instead of paying a handshake per issue. Because every create is preceded by a search, batch the search where the API allows a single JQL labels IN (...) query across a storm’s worth of faults, then create only the misses — this cuts the request count roughly in half during a burst. Set a tight per-request timeout of one to two seconds so a hung call becomes a fast, safe retry rather than a stalled worker, and cap max_attempts so a degraded tenant drains to the dead-letter queue instead of pinning the pool. Held this way, one correlated fault yields one Jira issue within the same sub-2-second creation budget the routing fabric targets, and the duplicate rate stays effectively zero outside the rare, reconciled create race.
Frequently Asked Questions
Why search before every create instead of trusting a server-side dedup key?
Jira REST v3 has no server-side deduplication anchor equivalent to a ServiceNow correlation id, so idempotency has to be enforced client-side. A deterministic label derived from the stable fault dedup key is queried before every create; a hit returns the existing issue key and skips creation, so a retry after an ambiguous timeout never opens a twin.
How do I map correlation fields onto Jira custom fields safely?
Address custom fields by their customfield id, never by display name, because the create endpoint rejects unknown names. Keep the id mapping in one dictionary so a renamed or re-provisioned field is a single-line change, and validate that labels contain no spaces since JQL cannot match a label with a space in it.
What handles Jira rate limiting during an alarm storm?
The create path honors the Retry-After header the REST API returns with a 429, backing off exactly as instructed, and caps concurrency with a bounded semaphore. The upstream bounded queue absorbs the burst so the backoff never blocks the event loop and high-severity issues are not shed to make room for noise.
Can two workers still create duplicate issues?
A narrow race exists where two workers both miss the search and both create. Because the deterministic label is a permanent property of every issue, a periodic reconciliation can detect and merge the rare twin, so duplicates are caught after the fact rather than reaching an engineer as separate tickets.
Related
- Up to: ITSM Ticket Creation — the serialization stage that maps correlated faults to ticket payloads
- ServiceNow Incident Payloads for Correlated Network Faults — the same fault serialized for the ServiceNow Table API with server-side dedup
- BMC Remedy Ticket Integration for Telecom Fault Routing — entry-form field mapping and reconciliation IDs for the Remedy interface
- Idempotency Keys for Retry-Safe Ticket Creation — the deterministic dedup-key construction the Jira label is built from