Summary
On Confluence Cloud, v2 cursor pagination builds an invalid URL for every page after the first. The relative _links.next path is concatenated onto a base URL that already contains the context path, so the second request goes to /wiki/wiki/api/v2/... and returns HTTP 404. When the client is pointed at the Atlassian API gateway (https://api.atlassian.com/ex/confluence/{cloud_id}, the form required for scoped API tokens), the second request additionally leaves the gateway host and the /ex/confluence/{cloud_id} prefix is lost.
Affected versions: 5.0.0, 5.0.1, 5.0.2 (the code path is new in the 5.x line; 4.0.7 is unaffected because get_all_spaces used the v1 space endpoint). Python 3.12.
Any Cloud call that paginates is affected. get_spaces / get_all_spaces is the easiest one to hit, because it paginates as soon as the site has more spaces than limit.
Reproduction
No credentials needed; the HTTP layer is stubbed and returns a realistic first page.
"""Confluence Cloud v2 cursor pagination builds a wrong next-page URL."""
import json, sys
import requests
from requests.models import Response
REQUESTS = []
def fake_request(self, method, url, **kw):
REQUESTS.append(url)
body = (
{"results": [{"id": "1", "name": "Space One"}],
"_links": {"base": "https://example.atlassian.net/wiki",
"next": "/wiki/api/v2/spaces?limit=1&cursor=CURSOR"}}
if len(REQUESTS) == 1 else {"results": [], "_links": {}}
)
r = Response()
r.status_code = 200
r._content = json.dumps(body).encode()
r.headers["Content-Type"] = "application/json"
r.url = url
return r
requests.sessions.Session.request = fake_request
from atlassian.confluence.cloud.cloud import ConfluenceCloud
for base in ["https://example.atlassian.net",
"https://api.atlassian.com/ex/confluence/CLOUD-ID"]:
REQUESTS.clear()
c = ConfluenceCloud(url=base, username="u", password="p")
c.get_spaces(limit=1)
print(base)
for i, u in enumerate(REQUESTS, 1):
print(f" page {i}: {u}")
Actual output
https://example.atlassian.net
page 1: https://example.atlassian.net/wiki/api/v2/spaces?limit=1
page 2: https://example.atlassian.net/wiki/wiki/api/v2/spaces?limit=1&cursor=CURSOR
https://api.atlassian.com/ex/confluence/CLOUD-ID
page 1: https://api.atlassian.com/ex/confluence/CLOUD-ID/api/v2/spaces?limit=1
page 2: https://example.atlassian.net/wiki/wiki/api/v2/spaces?limit=1&cursor=CURSOR
Expected output
page 2: https://example.atlassian.net/wiki/api/v2/spaces?limit=1&cursor=CURSOR
page 2: https://api.atlassian.com/ex/confluence/CLOUD-ID/api/v2/spaces?limit=1&cursor=CURSOR
Cause
atlassian/confluence_base.py, V2 branch of _get_paged:
base_url = response.get("_links", {}).get("base")
if base_url and next_url.startswith("/"):
# Construct the full URL using the base URL from the response
url = f"{base_url}{next_url}"
absolute = True
else:
...
Confluence returns _links.base as https://<site>.atlassian.net/wiki and _links.next as /wiki/api/v2/spaces?..., so string concatenation duplicates /wiki. The else branch has the mirror-image problem: it resolves the relative link against scheme://host only, which drops the gateway's /ex/confluence/{cloud_id} prefix.
Suggested fix
Cursor pagination only advances the query string, so the endpoint the caller already resolved can be reused instead of rebuilding a URL from the relative link. That keeps context paths and gateway prefixes intact in both deployments:
parsed_next = urlparse(next_url)
if parsed_next.scheme:
url = next_url
absolute = True
params = {}
else:
params = dict(parse_qsl(parsed_next.query))
trailing = False
With this change the reproduction above produces the expected output for both base URLs. I am happy to open a PR with the change and a regression test if the approach looks right to you.
Impact
This surfaced downstream in confluence-markdown-exporter, where a scheduled export broke as soon as atlassian-python-api 5.x was resolved: the client's connection check calls get_all_spaces(limit=1), the second page 404s, and the error is reported as an authentication failure.
Summary
On Confluence Cloud, v2 cursor pagination builds an invalid URL for every page after the first. The relative
_links.nextpath is concatenated onto a base URL that already contains the context path, so the second request goes to/wiki/wiki/api/v2/...and returns HTTP 404. When the client is pointed at the Atlassian API gateway (https://api.atlassian.com/ex/confluence/{cloud_id}, the form required for scoped API tokens), the second request additionally leaves the gateway host and the/ex/confluence/{cloud_id}prefix is lost.Affected versions: 5.0.0, 5.0.1, 5.0.2 (the code path is new in the 5.x line; 4.0.7 is unaffected because
get_all_spacesused the v1 space endpoint). Python 3.12.Any Cloud call that paginates is affected.
get_spaces/get_all_spacesis the easiest one to hit, because it paginates as soon as the site has more spaces thanlimit.Reproduction
No credentials needed; the HTTP layer is stubbed and returns a realistic first page.
Actual output
Expected output
Cause
atlassian/confluence_base.py, V2 branch of_get_paged:Confluence returns
_links.baseashttps://<site>.atlassian.net/wikiand_links.nextas/wiki/api/v2/spaces?..., so string concatenation duplicates/wiki. Theelsebranch has the mirror-image problem: it resolves the relative link againstscheme://hostonly, which drops the gateway's/ex/confluence/{cloud_id}prefix.Suggested fix
Cursor pagination only advances the query string, so the endpoint the caller already resolved can be reused instead of rebuilding a URL from the relative link. That keeps context paths and gateway prefixes intact in both deployments:
With this change the reproduction above produces the expected output for both base URLs. I am happy to open a PR with the change and a regression test if the approach looks right to you.
Impact
This surfaced downstream in
confluence-markdown-exporter, where a scheduled export broke as soon asatlassian-python-api5.x was resolved: the client's connection check callsget_all_spaces(limit=1), the second page 404s, and the error is reported as an authentication failure.