bytedance/deer-flow · error · GitHubAppAuthError

Failed to mint installation token (status={resp.status_code}

Error message

Failed to mint installation token (status={resp.status_code} body={resp.text!r})

What it means

GitHubAppAuthError raised when POST /app/installations/{id}/access_tokens returns a non-201 status. The error embeds both the HTTP status and the raw response body, so GitHub's own error message (expired JWT, wrong installation id, suspended installation, rate limit) is visible in the exception text.

Source

Thrown at backend/app/gateway/github/app_auth.py:154


async def _request_new_installation_token(
    installation_id: int,
    *,
    client: httpx.AsyncClient | None = None,
) -> _CachedToken:
    """Hit ``POST /app/installations/{id}/access_tokens`` once."""
    headers = {
        "Authorization": f"Bearer {mint_app_jwt()}",
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
    }
    url = f"{_GITHUB_API_BASE}/app/installations/{installation_id}/access_tokens"

    async def _do(c: httpx.AsyncClient) -> _CachedToken:
        resp = await c.post(url, headers=headers, timeout=15.0)
        if resp.status_code != 201:
            raise GitHubAppAuthError(f"Failed to mint installation token (status={resp.status_code} body={resp.text!r})")
        data = resp.json()
        token = data["token"]
        # GitHub returns ISO8601 expires_at; we just bake in a 60-minute
        # life and let the leeway handle the rest. Trusting the wall
        # clock instead of parsing ISO is fine here.
        expires_at = time.time() + 60 * 60
        return _CachedToken(token=token, expires_at=expires_at)

    if client is None:
        async with httpx.AsyncClient() as c:
            return await _do(c)
    return await _do(client)


async def mint_installation_token(
    installation_id: int,
    *,
    client: httpx.AsyncClient | None = None,

View on GitHub (pinned to 1dd6ba1acb)

Solutions

  1. Read the embedded body in the exception message — GitHub states the exact reason ('Integration must have access to this installation', 'A JWT expiry/exp is required', etc.) and act on it directly
  2. Check server time (ntpd/systemd-timesyncd); JWTs are time-sensitive, skew > 60s fails with 401
  3. Verify installation_id matches an installation of THIS App (GET /app/installations with the App JWT) and that it is not suspended
  4. If the App key was rotated, redeploy with the new private key; if GitHub returned 5xx/rate-limit, back off and retry (token minting is cached for ~60 min so retries are rare)

Example fix

null
Defensive patterns

Strategy: retry

Validate before calling

null

Type guard

null

Try / catch

for attempt in range(3):
    try:
        token = await mint_installation_token(inst_id, client=client)
        break
    except GitHubAppAuthError as e:
        if 'status=5' in str(e) or 'status=403' in str(e):  # transient/rate-limit
            await asyncio.sleep(2 ** attempt); continue
        raise  # 401/404 style errors are config problems — do not retry

Prevention

When it happens

Trigger: Minting an installation token while: the App JWT is expired or has iat in the future (clock skew); the installation_id does not belong to this App; the installation was suspended or uninstalled; the private key/App id pair no longer matches the App; secondary rate limit hit.

Common situations: Server clock drift beyond the JWT leeway (GitHub rejects iat/exp within 60s windows); rotated the App's key but the deployed env still has the old one; installation removed by the repo owner while your integration still references it; App id and key from two different Apps after a re-create; GitHub API outage returning 5xx.

Related errors


AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14). Data as JSON: /api/errors/6ba209c3d3a35ebc. Report an issue: GitHub.