Skip to content

Confluence Cloud v2 cursor pagination builds an invalid next-page URL #1655

Description

@stn1slv

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions