Skip to main content

Resilient JIRA Server data extraction at scale — seek pagination, reindex-aware recovery, three-tier resilient issue fetch.

Project description

jira-resilient

A Python client for JIRA Server that reliably extracts issues with thousands of issuelinks — the "hub" issues that consistently time out and break other clients. Built for ETL / data-warehouse workloads where missing data isn't an option.

PyPI Python License: MIT Tests

In any large JIRA Server install, a handful of "hub" issues accumulate enormous numbers of Implements / Tests / Relates links — easily into the thousands. A real-world example: a single issue with many thousands of issuelinks. The standard GET /issue/{key}?fields=*all for that issue returns a ~10 MB payload that takes JIRA 3+ minutes to serialize — well past every existing Python client's default timeout. Result: the issue is silently absent from the warehouse, no error, no retry that would help.

This library exists to solve that. The fix is a three-tier fetch that recognizes the timeout pattern and recovers data via split requests:

result = client.get_issue_resilient("HUB-1234")
# result.tier == "hub"    → fields=*all,-issuelinks fetched fast,
#                          issuelinks fetched separately with a long timeout
# result.issue            → fully assembled issue, all the links intact

Plus a few related reliability fixes the same code path needed along the way (seek-paginated /search, Lucene-reindex cursor handling, paginated changelog fallback, fail-fast-on-4xx in the retry loop). Documented further down.

Install

pip install jira-resilient        # or:  uv add jira-resilient

Quickstart

from jira_resilient import JiraClient

client = JiraClient(
    base_url="https://jira.example.com",
    pat="<personal-access-token>",
    verify="/path/to/ca-bundle.pem",   # or True for system CAs, False to skip
)

if not client.is_authenticated:
    raise SystemExit("auth failed")

# THE killer feature — resilient single-issue fetch that survives hub issues.
result = client.get_issue_resilient("HUB-1234")
print(result.tier)         # "full" | "hub" | "minimal" — log this; minimal is lossy
print(len(result.issue["fields"]["issuelinks"]))   # thousands

# Seek-paginated scan — survives 100K+ issue projects.
for page in client.search_seek("PROJ"):
    for issue in page.issues:
        ...

# Delta scan — resume from a saved (updated, key) cursor.
from datetime import datetime, timezone
cursor_ts  = datetime(2026, 5, 18, 7, 30, tzinfo=timezone.utc)
cursor_key = "PROJ-12345"
for page in client.search_seek("PROJ", after_ts=cursor_ts, after_key=cursor_key):
    ...

# Paginated changelog — for issues whose `expand=changelog` payload overflows the timeout.
history = client.get_changelog("HUB-1234")

# Minimal-payload key enumeration (for reconciliation against a warehouse).
keys = client.list_keys('project = "PROJ"')

Why this exists

Every JIRA Python client on PyPI today (jira, atlassian-python-api, pycontribs/jira) was built for interactive use — small queries, single issues with normal-sized payloads. They all fail in predictable ways on the data-warehouse workload, and none of them have fixes:

Problem Other clients jira-resilient
Hub issues with thousands of issuelinksfields=*all payload exceeds 120s timeout, request fails issue unrecoverable, silently absent from your data three-tier fetch: full*all,-issuelinks + separate links fetch with long timeout → minimal fields
100K+ issue projects — offset pagination is ~O(n²) on JIRA Server "limit your queries" (Atlassian's documented guidance) search_seek(updated, key) tuple cursor in JQL, startAt=0 every request, bounded per-page cost
Lucene reindex makes seek cursors silently regress n/a — no client implements seek monotonic after_ts floor + minute-advance fallback (the war story below)
Huge changelogs overflow expand=changelog request fails; history lost paginated /issue/{key}/changelog, auto-falling back to ?expand=changelog on JIRA Server (which 404s the paginated route)
4xx in the retry loop exponential backoff over a permission error wastes 15 min fail-fast on 4xx; only 429/5xx trigger backoff

The hub-issue problem, in detail

A "hub" issue in JIRA isn't a special type — it's any issue that ends up linked to a lot of other issues over its lifetime. Common patterns that produce them:

  • A parent epic with Implements links to every child that satisfies it
  • An end-to-end test plan that Tests every component it covers
  • A shared platform ticket that an entire feature area links back to
  • A defect tracker with Relates to every related ticket

In a project with a few thousand issues, you might have 5–10 hub issues out of the lot. In a project with 100K+ issues — common in long-lived enterprise installs — you can have hundreds, with link counts climbing into the low thousands.

Three reasons existing clients can't handle this:

  1. fields=*all returns everything inline. A multi-thousand-link issue is a ~10 MB JSON payload. JIRA's serializer is single-threaded per request and takes minutes; clients with a 60s or 120s timeout just see a ReadTimeoutError.
  2. There's no documented escape hatch. JIRA Server has no "give me this issue but skip the slow fields" endpoint. You have to know to request fields=*all,-issuelinks and then fetch issuelinks separately with a longer timeout — and even then, the issuelinks-only request can take 3+ minutes for the largest hubs.
  3. Retrying doesn't help. Exponential backoff over the same broken request just wastes time. You need a fundamentally different fetch pattern, not more attempts.

get_issue_resilient implements that pattern. The three tiers handle ~95% of issues at full fidelity (tier full), ~5% via the hub-split tier (tier hub, no data loss, ~3-4 minute total), and a small fraction via the last-resort minimal-field tier (tier minimal, lossy — description and custom fields are empty). The tier field on the result lets you log which path each issue took.

The Lucene reindex story

The bug that took a day to find, and why search_seek has the monotonic-after_ts guard.

JIRA Server occasionally runs a Lucene reindex. After the reindex, the indexed updated timestamp on many issues is set to the reindex time. The document's fields.updated is unaffected.

If you run a seek-paginated loop, advancing the cursor by fields.updated of the last issue on each page, you eventually hit a reindexed group: thousands of issues whose fields.updated is some old date (say, 2024) but whose indexed-updated is yesterday. Your next JQL says updated > "old-date". JIRA's matcher uses the indexed value, so it returns the whole reindexed group — and your cursor just went backward in time. Next request, even broader. Infinite loop, no error, just chewing through the same group forever.

The fix: after_ts is kept monotonically non-decreasingafter_ts = max(after_ts, new_ts). Combined with a minute-advance fallback that preserves after_key, same-Lucene-timestamp groups page through cleanly by key alone, and the cursor can't regress. See client.py:search_seek.

API reference

JiraClient(base_url, pat, *, verify=True, timeout=120, max_attempts=5)

Auth & server

Method Endpoint Notes
is_authenticated (prop) GET /myself Logs displayName on success
server_tz (prop) GET /serverInfo Server's local timezone, probed once + cached. JQL date literals are parsed in this TZ, not UTC; search_seek passes it to the JQL builder automatically. Falls back to UTC if the probe fails

Single-issue fetch

Method Endpoint Notes
get_issue_resilient(key) three-tier The killer feature. ResilientFetchResult(issue, tier)fullhub (*all,-issuelinks + a separate links fetch) → minimal
get_issue(key) three-tier Safe default — routes through get_issue_resilient, returns the issue dict
get_issue_raw(key, *, expand, fields, timeout, max_attempts) GET /issue/{key} Escape hatch, no fallback — direct control for fast-fail probes
get_issue_minimal(key) GET /issue/{key} Small field set, short timeout, no changelog
get_issuelinks(key, *, timeout=600) GET /issue/{key} Only issuelinks; long timeout for hub issues

Sub-entity reads — the bits search responses truncate or omit, for faithful extraction

Method Endpoint Notes
get_changelog(key, *, page_size=100) GET /issue/{key}/changelog Paginated; auto-falls back to ?expand=changelog on JIRA Server (which 404s the paginated route), cached per client
get_comments(key, *, page_size=50) GET /issue/{key}/comment Full comment history (search caps inline comments)
get_worklogs(key, *, page_size=100) GET /issue/{key}/worklog Full worklog history (search inlines ≤ 20)
get_remote_links(key) GET /issue/{key}/remotelink Confluence / GitHub / URL links — never in search responses
get_watchers(key) GET /issue/{key}/watchers Watcher identities; [] on 404; needs "View Voters and Watchers"
get_voters(key) GET /issue/{key}/votes Voter identities; [] on 404

Entity properties — list-then-dereference to {propertyKey: value}; ?expand=properties returns null on Server, so these dedicated sub-resources are the only way to read them. {} when absent.

Method Endpoint
get_issue_properties(key) GET /issue/{key}/properties
get_comment_properties(issue_key, comment_id) GET /issue/{key}/comment/{id}/properties
get_project_properties(project_key) GET /project/{key}/properties

Users · fields · listing · pagination

Method Endpoint Notes
get_user(*, username/key/account_id, expand="groups,applicationRoles") GET /user Resolved by the right param per deployment (Server: username/key; Cloud: accountId); {} on 404
list_fields() GET /field Full field catalog
list_keys(jql) POST /search (fields=key) Tiny payload; for reconciliation
search_paged(jql, *, page_size=50) POST /search (offset) Use sparingly — quadratic on large projects
search_seek(project_key, *, after_ts, after_key, extra_filter, page_size=20) POST /search (seek) Project-wide enumeration. Full scan (after_ts=None) pages by issue id (monotonic, can't loop); delta (after_ts set) by (updated, key) with Lucene-reindex recovery

Exceptions

from jira_resilient import (
    JiraResilientError,   # base of the hierarchy
    JiraAuthError,        # 401/403
    JiraParseError,       # 2xx response missing expected fields
    JiraFetchError,       # all retry attempts / fallback tiers exhausted
)

JiraJqlError (subclass of JiraResilientError, importable from jira_resilient.exceptions) is raised when JIRA rejects a query with HTTP 400 — it carries error_messages: list[str] from JIRA's response body so callers can pattern-match. search_seek uses it internally to decide whether a stale-cursor 400 is auto-recoverable.

requests.RequestException may still escape on conditions the library doesn't wrap. Catch JiraResilientError for library-raised failures, or Exception for everything.

JQL helpers

from jira_resilient import build_jql, build_seek_jql

Pure functions, no network calls — for callers that want to compose JQL outside of any request flow.

Non-goals

  • JIRA Cloud is not supported. Cloud uses /rest/api/3 and different paging semantics; this library is JIRA Server / Data Center only.
  • No async client. JiraClient is synchronous. An AsyncJiraClient may land in a future minor version.
  • Basic auth, OAuth, and JWT are not supported. Personal Access Token (Bearer header) only — that's what modern JIRA Server installs use.
  • No automatic field-name semantic mapping. customfield_10016 stays customfield_10016 in the response. Build your own mapping at the application layer if you need one.
  • No DB / warehouse integration. This is a JIRA client, not an ETL framework. Wire it up to your warehouse yourself.
  • Not a replacement for atlassian-python-api's full surface. This is a focused client for the data-extraction subset.

Compatibility

Version
Python 3.12+
JIRA Server / Data Center 8.6+ (for paginated /issue/{key}/changelog); older may work for non-changelog use
requests 2.31+

Development

git clone https://github.com/uofm-matt/jira-resilient
cd jira-resilient
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"
pytest

Tests run against mocked HTTP via responses — no real network. Run time: ~0.1s for the full suite.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

jira_resilient-0.4.3.tar.gz (67.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

jira_resilient-0.4.3-py3-none-any.whl (25.9 kB view details)

Uploaded Python 3

File details

Details for the file jira_resilient-0.4.3.tar.gz.

File metadata

  • Download URL: jira_resilient-0.4.3.tar.gz
  • Upload date:
  • Size: 67.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for jira_resilient-0.4.3.tar.gz
Algorithm Hash digest
SHA256 e061e8c702e8af7f97880540648164a90c4bf9cfbf0924467d48c826518ad0ac
MD5 c3e466e4421bcb1f5d00222725ac40a3
BLAKE2b-256 3264cc6d959b353c1d0c02b7bff119b4c7d3eea7c22fedda9a64b7b39d9d4b9b

See more details on using hashes here.

Provenance

The following attestation bundles were made for jira_resilient-0.4.3.tar.gz:

Publisher: publish.yml on uofm-matt/jira-resilient

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file jira_resilient-0.4.3-py3-none-any.whl.

File metadata

  • Download URL: jira_resilient-0.4.3-py3-none-any.whl
  • Upload date:
  • Size: 25.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for jira_resilient-0.4.3-py3-none-any.whl
Algorithm Hash digest
SHA256 d246bd31b7b95af48762c18f76b6fad7da67126eabdc722e9551e8e4aacf5e0f
MD5 3d3b8d5e467cd687ce38ae9bd837f72e
BLAKE2b-256 e7a8540e4a808f580bb9ce0b292ad5e728be92872e0a072a950d8543756b0ab5

See more details on using hashes here.

Provenance

The following attestation bundles were made for jira_resilient-0.4.3-py3-none-any.whl:

Publisher: publish.yml on uofm-matt/jira-resilient

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page