Skip to content

Commit 07a00db

Browse files
committed
Add authentication error recovery to web command
When the web command encounters a 401 authentication error, it now: 1. Automatically runs `claude -p hi` to refresh the OAuth token 2. Retries the failed API request with the refreshed credentials 3. Only attempts recovery once to prevent infinite loops This eliminates the need for users to manually refresh their token when it expires during operation. Closes #40
1 parent 854a4f8 commit 07a00db

File tree

2 files changed

+270
-6
lines changed

2 files changed

+270
-6
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,18 @@ def get_api_headers(token, org_uuid):
565565
}
566566

567567

568+
def refresh_token():
569+
"""Refresh the Claude API token by running claude -p prompt.
570+
571+
This triggers Claude Code to refresh the OAuth token stored in the keychain.
572+
"""
573+
subprocess.run(
574+
["claude", "-p", "hi"],
575+
capture_output=True,
576+
timeout=60,
577+
)
578+
579+
568580
def fetch_sessions(token, org_uuid):
569581
"""Fetch list of sessions from the API.
570582
@@ -1900,6 +1912,9 @@ def web_cmd(
19001912
19011913
If SESSION_ID is not provided, displays an interactive picker to select a session.
19021914
"""
1915+
# Track if we've already attempted token refresh (to prevent infinite loops)
1916+
token_refreshed = False
1917+
19031918
try:
19041919
token, org_uuid = resolve_credentials(token, org_uuid)
19051920
except click.ClickException:
@@ -1910,9 +1925,26 @@ def web_cmd(
19101925
try:
19111926
sessions_data = fetch_sessions(token, org_uuid)
19121927
except httpx.HTTPStatusError as e:
1913-
raise click.ClickException(
1914-
f"API request failed: {e.response.status_code} {e.response.text}"
1915-
)
1928+
if e.response.status_code == 401 and not token_refreshed:
1929+
click.echo("Authentication failed, refreshing token...")
1930+
refresh_token()
1931+
token_refreshed = True
1932+
# Re-fetch token from keychain if we auto-detected it
1933+
new_token = get_access_token_from_keychain()
1934+
if new_token:
1935+
token = new_token
1936+
try:
1937+
sessions_data = fetch_sessions(token, org_uuid)
1938+
except httpx.HTTPStatusError as e2:
1939+
raise click.ClickException(
1940+
f"API request failed: {e2.response.status_code} {e2.response.text}"
1941+
)
1942+
except httpx.RequestError as e2:
1943+
raise click.ClickException(f"Network error: {e2}")
1944+
else:
1945+
raise click.ClickException(
1946+
f"API request failed: {e.response.status_code} {e.response.text}"
1947+
)
19161948
except httpx.RequestError as e:
19171949
raise click.ClickException(f"Network error: {e}")
19181950

@@ -1948,9 +1980,26 @@ def web_cmd(
19481980
try:
19491981
session_data = fetch_session(token, org_uuid, session_id)
19501982
except httpx.HTTPStatusError as e:
1951-
raise click.ClickException(
1952-
f"API request failed: {e.response.status_code} {e.response.text}"
1953-
)
1983+
if e.response.status_code == 401 and not token_refreshed:
1984+
click.echo("Authentication failed, refreshing token...")
1985+
refresh_token()
1986+
token_refreshed = True
1987+
# Re-fetch token from keychain if we auto-detected it
1988+
new_token = get_access_token_from_keychain()
1989+
if new_token:
1990+
token = new_token
1991+
try:
1992+
session_data = fetch_session(token, org_uuid, session_id)
1993+
except httpx.HTTPStatusError as e2:
1994+
raise click.ClickException(
1995+
f"API request failed: {e2.response.status_code} {e2.response.text}"
1996+
)
1997+
except httpx.RequestError as e2:
1998+
raise click.ClickException(f"Network error: {e2}")
1999+
else:
2000+
raise click.ClickException(
2001+
f"API request failed: {e.response.status_code} {e.response.text}"
2002+
)
19542003
except httpx.RequestError as e:
19552004
raise click.ClickException(f"Network error: {e}")
19562005

tests/test_generate_html.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,3 +1574,218 @@ def test_search_total_pages_available(self, output_dir):
15741574

15751575
# Total pages should be embedded for JS to know how many pages to fetch
15761576
assert "totalPages" in index_html or "total_pages" in index_html
1577+
1578+
1579+
class TestAuthenticationErrorRecovery:
1580+
"""Tests for 401 authentication error recovery in web command."""
1581+
1582+
def test_web_retries_after_401_on_fetch_sessions(
1583+
self, httpx_mock, monkeypatch, output_dir
1584+
):
1585+
"""Test that web command retries after 401 error when listing sessions."""
1586+
from click.testing import CliRunner
1587+
from claude_code_transcripts import cli
1588+
1589+
# Load sample session to mock API response
1590+
fixture_path = Path(__file__).parent / "sample_session.json"
1591+
with open(fixture_path) as f:
1592+
session_data = json.load(f)
1593+
1594+
# First call to /sessions returns 401, second call succeeds
1595+
httpx_mock.add_response(
1596+
url="https://api.anthropic.com/v1/sessions",
1597+
status_code=401,
1598+
json={
1599+
"type": "error",
1600+
"error": {
1601+
"type": "authentication_error",
1602+
"message": "Authentication failed",
1603+
},
1604+
},
1605+
)
1606+
httpx_mock.add_response(
1607+
url="https://api.anthropic.com/v1/sessions",
1608+
json={
1609+
"data": [
1610+
{
1611+
"id": "test-session-id",
1612+
"title": "Test Session",
1613+
"created_at": "2025-01-01T00:00:00Z",
1614+
}
1615+
]
1616+
},
1617+
)
1618+
httpx_mock.add_response(
1619+
url="https://api.anthropic.com/v1/session_ingress/session/test-session-id",
1620+
json=session_data,
1621+
)
1622+
1623+
# Track if refresh_token was called
1624+
refresh_called = []
1625+
1626+
def mock_refresh_token():
1627+
refresh_called.append(True)
1628+
1629+
monkeypatch.setattr("claude_code_transcripts.refresh_token", mock_refresh_token)
1630+
1631+
# Mock questionary.select to auto-select the first session
1632+
class MockSelect:
1633+
def __init__(self, *args, **kwargs):
1634+
pass
1635+
1636+
def ask(self):
1637+
return "test-session-id"
1638+
1639+
monkeypatch.setattr("questionary.select", MockSelect)
1640+
1641+
runner = CliRunner()
1642+
# NOTE: No session_id provided, so it will call fetch_sessions first
1643+
result = runner.invoke(
1644+
cli,
1645+
[
1646+
"web",
1647+
"--token",
1648+
"test-token",
1649+
"--org-uuid",
1650+
"test-org",
1651+
"-o",
1652+
str(output_dir),
1653+
],
1654+
)
1655+
1656+
assert result.exit_code == 0, f"Command failed: {result.output}"
1657+
assert len(refresh_called) == 1, "refresh_token should be called exactly once"
1658+
1659+
def test_web_retries_after_401_on_fetch_session(
1660+
self, httpx_mock, monkeypatch, output_dir
1661+
):
1662+
"""Test that web command retries after 401 error when fetching session."""
1663+
from click.testing import CliRunner
1664+
from claude_code_transcripts import cli
1665+
1666+
# Load sample session to mock API response
1667+
fixture_path = Path(__file__).parent / "sample_session.json"
1668+
with open(fixture_path) as f:
1669+
session_data = json.load(f)
1670+
1671+
# First call returns 401, second call succeeds
1672+
httpx_mock.add_response(
1673+
url="https://api.anthropic.com/v1/session_ingress/session/test-session-id",
1674+
status_code=401,
1675+
json={
1676+
"type": "error",
1677+
"error": {
1678+
"type": "authentication_error",
1679+
"message": "Authentication failed",
1680+
},
1681+
},
1682+
)
1683+
httpx_mock.add_response(
1684+
url="https://api.anthropic.com/v1/session_ingress/session/test-session-id",
1685+
json=session_data,
1686+
)
1687+
1688+
# Track if refresh_token was called
1689+
refresh_called = []
1690+
1691+
def mock_refresh_token():
1692+
refresh_called.append(True)
1693+
1694+
monkeypatch.setattr("claude_code_transcripts.refresh_token", mock_refresh_token)
1695+
1696+
runner = CliRunner()
1697+
result = runner.invoke(
1698+
cli,
1699+
[
1700+
"web",
1701+
"test-session-id",
1702+
"--token",
1703+
"test-token",
1704+
"--org-uuid",
1705+
"test-org",
1706+
"-o",
1707+
str(output_dir),
1708+
],
1709+
)
1710+
1711+
assert result.exit_code == 0, f"Command failed: {result.output}"
1712+
assert len(refresh_called) == 1, "refresh_token should be called exactly once"
1713+
1714+
def test_web_only_retries_once_after_401(self, httpx_mock, monkeypatch, output_dir):
1715+
"""Test that web command only retries once to prevent infinite loops."""
1716+
from click.testing import CliRunner
1717+
from claude_code_transcripts import cli
1718+
1719+
# Both calls return 401 - second 401 should not trigger another retry
1720+
httpx_mock.add_response(
1721+
url="https://api.anthropic.com/v1/session_ingress/session/test-session-id",
1722+
status_code=401,
1723+
json={
1724+
"type": "error",
1725+
"error": {
1726+
"type": "authentication_error",
1727+
"message": "Authentication failed",
1728+
},
1729+
},
1730+
)
1731+
httpx_mock.add_response(
1732+
url="https://api.anthropic.com/v1/session_ingress/session/test-session-id",
1733+
status_code=401,
1734+
json={
1735+
"type": "error",
1736+
"error": {
1737+
"type": "authentication_error",
1738+
"message": "Authentication failed",
1739+
},
1740+
},
1741+
)
1742+
1743+
# Track if refresh_token was called
1744+
refresh_called = []
1745+
1746+
def mock_refresh_token():
1747+
refresh_called.append(True)
1748+
1749+
monkeypatch.setattr("claude_code_transcripts.refresh_token", mock_refresh_token)
1750+
1751+
runner = CliRunner()
1752+
result = runner.invoke(
1753+
cli,
1754+
[
1755+
"web",
1756+
"test-session-id",
1757+
"--token",
1758+
"test-token",
1759+
"--org-uuid",
1760+
"test-org",
1761+
"-o",
1762+
str(output_dir),
1763+
],
1764+
)
1765+
1766+
# Should fail after retry fails
1767+
assert result.exit_code != 0
1768+
assert "401" in result.output
1769+
assert len(refresh_called) == 1, "refresh_token should only be called once"
1770+
1771+
def test_refresh_token_runs_claude_command(self, monkeypatch):
1772+
"""Test that refresh_token runs claude -p prompt."""
1773+
from claude_code_transcripts import refresh_token
1774+
import subprocess
1775+
1776+
# Track the command that was run
1777+
commands_run = []
1778+
1779+
def mock_run(cmd, **kwargs):
1780+
commands_run.append(cmd)
1781+
return subprocess.CompletedProcess(
1782+
args=cmd, returncode=0, stdout="", stderr=""
1783+
)
1784+
1785+
monkeypatch.setattr(subprocess, "run", mock_run)
1786+
1787+
refresh_token()
1788+
1789+
assert len(commands_run) == 1
1790+
assert commands_run[0][0] == "claude"
1791+
assert "-p" in commands_run[0]

0 commit comments

Comments
 (0)