Skip to content

fix: 401 token refresh silently skipped when refreshToken is null#325

Merged
xinhuagu merged 8 commits into
mainfrom
fix/324-401-refresh-silent-skip
Mar 21, 2026
Merged

fix: 401 token refresh silently skipped when refreshToken is null#325
xinhuagu merged 8 commits into
mainfrom
fix/324-401-refresh-silent-skip

Conversation

@xinhuagu

@xinhuagu xinhuagu commented Mar 21, 2026

Copy link
Copy Markdown
Owner

Summary

401 retry was silently skipped because refreshToken was null (config.json has apiKey but no refreshToken). Now:

  • ensureRefreshToken() re-reads from Keychain/credential file on 401
  • Logs warning when no refresh token available (was completely silent before)
  • Also picks up fresher access token from Keychain if not expired

Closes #324

Test plan

  • ./gradlew :aceclaw-llm:test passes
  • Compile verified

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved 401/OAuth handling: the client now re-reads stored credentials, updates in-memory tokens when appropriate, and chooses between retrying with a refreshed token, running an OAuth refresh, or skipping recovery with a clear warning.
  • New Features

    • Pluggable credential supplier to allow alternative providers for stored access/refresh tokens.
  • Tests

    • Added comprehensive tests for constructor validation, multiple credential-recovery scenarios (including partial updates), concurrent recovery, and repeated recovery calls.
  • Chores

    • Strengthened CI/pre-merge configuration and quality gates for reviews and testing.

The 401 retry path required refreshToken != null, but config.json
often has apiKey without refreshToken. When Keychain loading failed,
refreshToken stayed null and 401 recovery was silently skipped with
no log message.

Fix:
- Add ensureRefreshToken() that re-reads from Keychain/file on 401
- Log warning when no refresh token is available (was silent before)
- Also picks up fresher access token from Keychain if available

Closes #324

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

AnthropicClient now reloads credentials from a pluggable credential supplier on 401, performs synchronized credential recovery that may update accessToken and/or refreshToken or indicate refresh availability, exposes package-private test hooks, and adds comprehensive tests for recovery scenarios and concurrency. CI config (.coderabbit.yaml) was also tightened.

Changes

Cohort / File(s) Summary
OAuth token / runtime logic
aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java
Reworked 401 handling to call a synchronized recoverCredentials() using a pluggable credentialSupplier; added package-private constructor that accepts the supplier, accessTokenForTest() / refreshTokenForTest() accessors, and CredentialRecovery outcomes. (+115/-13)
Token recovery tests
aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java
New JUnit tests validating constructor argument checks and many recoverCredentials() paths (fresh access, refresh-available, no-recovery, partial updates, supplier exceptions, concurrency, repeat calls). (+216/-0)
CI / automation config
.coderabbit.yaml
Updated CodeRabbit automation and pre-merge quality gates: stricter checks (issue_assessment error, docstring/unit test rules), path filters, automatic label rules, PMD enabled, and new custom pre-merge checks for tests and API changes. (+85/-5)

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Anthropic as AnthropicClient
    participant Keychain as CredentialSupplier
    participant Auth as TokenEndpoint
    participant API as AnthropicAPI

    Client->>Anthropic: sendMessage()/streamMessage(request)
    Anthropic->>API: request with accessToken
    API-->>Anthropic: 401 Unauthorized
    Anthropic->>Anthropic: synchronized recoverCredentials()
    Anthropic->>Keychain: credentialSupplier.get()
    Keychain-->>Anthropic: Credential (accessToken?, refreshToken?, expiry?)
    alt Keychain provides valid accessToken (not expired)
        Anthropic->>API: retry with updated accessToken
        API-->>Client: successful response
    else refreshToken available
        Anthropic->>Auth: refreshAccessToken(refreshToken)
        Auth-->>Anthropic: new accessToken (+ optional refreshToken)
        Anthropic->>API: retry with new accessToken
        API-->>Client: successful response
    else no recovery possible
        Anthropic-->>Client: warn and return error (instruct restart / re-login)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #324 — fix: 401 token refresh silently skipped when refreshToken is null — This PR implements re-reading credentials on 401 and logs/recovery paths that address the described failure mode.
  • feat: proactive OAuth token refresh before expiry #323 — Alters AnthropicClient token-refresh and credential reloading logic; the supplier-based recovery and tests align with objectives to re-read keychain and handle refresh availability.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes configuration changes to .coderabbit.yaml that appear out of scope—adding pre-merge checks, labeling rules, and CI automation unrelated to the 401 token refresh fix. Consider moving .coderabbit.yaml changes to a separate PR focused on CI/review configuration improvements to keep this fix isolated and easier to review.
Docstring Coverage ⚠️ Warning Docstring coverage is 68.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main fix: addressing silent skipping of 401 token refresh when refreshToken is null, which is the primary issue being resolved.
Linked Issues check ✅ Passed All requirements from issue #324 are met: logging added for missing refreshToken, credential re-reading on 401, fresher access token pickup from Keychain, and comprehensive tests validating recovery outcomes.
Block Major Correctness And Security Risks ✅ Passed The PR properly handles 401 token refresh through synchronized credential recovery with three-state enum outcome handling, preventing race conditions and infinite loops. Comprehensive test coverage validates thread safety and error handling.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/324-401-refresh-silent-skip

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java`:
- Around line 421-437: The ensureRefreshToken() method currently returns early
when this.refreshToken is non-null and also requires cred.refreshToken() to be
non-null to update tokens, so it never checks the keychain for a potentially
fresher access token; change ensureRefreshToken() (and its use of
KeychainCredentialReader.read(), cred.refreshToken(), cred.accessToken(), and
cred.isExpired()) to always consult the keychain if available: call
KeychainCredentialReader.read() even when this.refreshToken != null, update
this.accessToken if cred.accessToken() is non-null and !cred.isExpired(), and
only set this.refreshToken when cred.refreshToken() is present; keep the method
returning true when either a valid in-memory refreshToken exists or a
refreshToken was loaded, but ensure accessToken is refreshed from the keychain
whenever possible.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 35ae219c-a71e-4fe9-8454-4be0cd75cb93

📥 Commits

Reviewing files that changed from the base of the PR and between 687fcad and 10ed41c.

📒 Files selected for processing (1)
  • aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java

Comment thread aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java Outdated
- Remove early return when refreshToken != null — Keychain may have
  been updated externally by Claude CLI since last check
- Independently pick up access token (if not expired) and refresh token
  (if present) without gating one on the other
- Return true only when refresh token is actually available

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xinhuagu

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

7 tests covering:
- OAuth mode detection from token prefix
- Null/blank access token rejection
- ensureRefreshToken with existing refresh token
- ensureRefreshToken with null refresh token (Keychain fallback)
- ensureRefreshToken called twice (no early return, checks Keychain both times)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xinhuagu

Copy link
Copy Markdown
Owner Author

@coderabbitai review — added AnthropicClientTokenRefreshTest with 7 unit tests covering ensureRefreshToken, constructor validation, and repeated-call behavior.

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

@xinhuagu Sure! I'll kick off a review of the PR, including the newly added AnthropicClientTokenRefreshTest with its 7 unit tests covering ensureRefreshToken, constructor validation, and repeated-call behavior.


🧠 Learnings used
Learnt from: CR
Repo: xinhuagu/AceClaw PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-21T00:26:46.991Z
Learning: Support two authentication modes in AnthropicClient: API key (sk-ant-api03-*) via x-api-key header and OAuth token (sk-ant-oat01-*) via Authorization header with beta flags
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Add pluggable credentialSupplier (package-private constructor) for
testing without real Keychain. Add try-catch around credential read
so Keychain failures don't crash 401 recovery.

12 tests covering all CodeRabbit requirements:
- ensureRefreshToken with existing refresh token (still checks Keychain)
- Keychain has fresher tokens → updates in-memory
- Null refresh token + Keychain has both → loads tokens
- Null refresh token + Keychain returns null → returns false
- Partial: Keychain has access only (no refresh) → updates access only
- Partial: Keychain has refresh only (expired access) → updates refresh only
- Keychain throws exception → returns false gracefully
- Concurrent calls (8 threads) → consistent state
- Called twice → checks Keychain both times (no caching)
- Constructor validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xinhuagu

Copy link
Copy Markdown
Owner Author

@coderabbitai review — added 12 comprehensive tests via pluggable credentialSupplier: existing token, fresher Keychain, null refresh, partial updates, Keychain failure, concurrent calls, no-caching. Also added try-catch in ensureRefreshToken for supplier exceptions.

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

@xinhuagu Sure! I'll kick off a review of the latest changes, including the 12 comprehensive tests via the pluggable credentialSupplier (existing token, fresher Keychain, null refresh, partial updates, Keychain failure, concurrent calls, no-caching) and the try-catch added in ensureRefreshToken for supplier exceptions.


🧠 Learnings used
Learnt from: CR
Repo: xinhuagu/AceClaw PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-21T00:26:46.991Z
Learning: Support two authentication modes in AnthropicClient: API key (sk-ant-api03-*) via x-api-key header and OAuth token (sk-ant-oat01-*) via Authorization header with beta flags
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java (2)

154-188: Test name overclaims concurrency guarantees.

ensureRefreshToken_concurrentCalls_noRaceCondition currently proves concurrent invocation and consistent final state, not absence of races in general. Consider renaming to reflect actual guarantees.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`
around lines 154 - 188, The test
ensureRefreshToken_concurrentCalls_noRaceCondition overstates its guarantee;
rename the JUnit test method to accurately reflect that it verifies concurrent
invocations produce a consistent final state rather than proving absence of all
race conditions—e.g., rename
ensureRefreshToken_concurrentCalls_consistentFinalState or
ensureRefreshToken_concurrentInvocations_consistentFinalState in
AnthropicClientTokenRefreshTest and update any references to that test name
(method identifier) so test runners and comments reflect the new, narrower
intent for methods createClient, ensureRefreshToken, accessTokenForTest, and
refreshTokenForTest.

96-103: Add an explicit warning-log assertion for the “no refresh token” path.

These tests validate return values and state, but not the warning visibility introduced by this PR objective. Please add at least one assertion that the warning is emitted when refresh is unavailable after 401 recovery.

Also applies to: 140-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`
around lines 96 - 103, Add an assertion that a warning-level log is emitted when
no refresh token is available: in the test
ensureRefreshToken_nullRefreshToken_keychainReturnsNull_returnsFalse (and the
sibling test at lines 140-149), capture the client/logger output created by
createClient and assert that calling ensureRefreshToken() both returns false and
produced a warning log entry indicating refresh was unavailable; use the
existing test logger/test appender or a mock to verify a warn() call (or that a
warning message containing “no refresh token” is logged) and keep the existing
assertions on refreshTokenForTest().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`:
- Around line 30-33: The test constructor_oauthToken_detectsOAuthMode currently
only asserts provider() which doesn't verify OAuth header selection; update the
test (constructor_oauthToken_detectsOAuthMode) to assert mode-specific behavior
from the created client by using createClient(OAUTH_TOKEN, REFRESH_TOKEN, () ->
null) and then either assert a dedicated auth-mode indicator (e.g.,
client.authMode() == AuthMode.OAUTH) or assert that the client will send an
Authorization header (not x-api-key) for OAuth tokens (for example via a method
that returns headers or a prepareRequest/authorizationHeader helper); adjust
assertions to validate Authorization header usage for tokens starting with
sk-ant-oat01-* or rename the test if you only want to keep the provider() check.

---

Nitpick comments:
In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`:
- Around line 154-188: The test
ensureRefreshToken_concurrentCalls_noRaceCondition overstates its guarantee;
rename the JUnit test method to accurately reflect that it verifies concurrent
invocations produce a consistent final state rather than proving absence of all
race conditions—e.g., rename
ensureRefreshToken_concurrentCalls_consistentFinalState or
ensureRefreshToken_concurrentInvocations_consistentFinalState in
AnthropicClientTokenRefreshTest and update any references to that test name
(method identifier) so test runners and comments reflect the new, narrower
intent for methods createClient, ensureRefreshToken, accessTokenForTest, and
refreshTokenForTest.
- Around line 96-103: Add an assertion that a warning-level log is emitted when
no refresh token is available: in the test
ensureRefreshToken_nullRefreshToken_keychainReturnsNull_returnsFalse (and the
sibling test at lines 140-149), capture the client/logger output created by
createClient and assert that calling ensureRefreshToken() both returns false and
produced a warning log entry indicating refresh was unavailable; use the
existing test logger/test appender or a mock to verify a warn() call (or that a
warning message containing “no refresh token” is logged) and keep the existing
assertions on refreshTokenForTest().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6f62933b-e2e9-4b48-b342-0630f7252e2b

📥 Commits

Reviewing files that changed from the base of the PR and between 219998b and b3f04cd.

📒 Files selected for processing (2)
  • aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java
  • aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java

Comment on lines +30 to +33
void constructor_oauthToken_detectsOAuthMode() {
var client = createClient(OAUTH_TOKEN, REFRESH_TOKEN, () -> null);
assertThat(client.provider()).isEqualTo("anthropic");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test intent mismatch: this does not actually verify OAuth mode detection.

At Line 30, the test name says OAuth mode is detected, but the assertion only checks provider(). That can pass even if auth header mode selection regresses. Please assert mode-specific behavior (or rename the test to match what it really checks).

Based on learnings "Support two authentication modes in AnthropicClient: API key (sk-ant-api03-) via x-api-key header and OAuth token (sk-ant-oat01-) via Authorization header with beta flags".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`
around lines 30 - 33, The test constructor_oauthToken_detectsOAuthMode currently
only asserts provider() which doesn't verify OAuth header selection; update the
test (constructor_oauthToken_detectsOAuthMode) to assert mode-specific behavior
from the created client by using createClient(OAUTH_TOKEN, REFRESH_TOKEN, () ->
null) and then either assert a dedicated auth-mode indicator (e.g.,
client.authMode() == AuthMode.OAUTH) or assert that the client will send an
Authorization header (not x-api-key) for OAuth tokens (for example via a method
that returns headers or a prepareRequest/authorizationHeader helper); adjust
assertions to validate Authorization header usage for tokens starting with
sk-ant-oat01-* or rename the test if you only want to keep the provider() check.

…fresh token

P1: Replace boolean ensureRefreshToken() with CredentialRecovery enum:
- ACCESS_TOKEN_UPDATED: Keychain had fresher access token → retry immediately
- REFRESH_AVAILABLE: has refresh token → do full OAuth refresh
- NO_RECOVERY: nothing available → log warning

Previously, getting a fresh access token from Keychain but no refresh
token meant the request still failed because the caller only checked
the boolean return.

Both sendMessage and streamMessage 401 paths now use switch on the
enum to handle all three cases.

14 tests covering all recovery paths, partial updates, failures,
concurrent access, and no-caching behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xinhuagu

Copy link
Copy Markdown
Owner Author

@coderabbitai review — replaced boolean ensureRefreshToken with CredentialRecovery enum (ACCESS_TOKEN_UPDATED/REFRESH_AVAILABLE/NO_RECOVERY). 401 paths now retry with fresh access token even without refresh token. 14 tests covering all recovery paths.

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

@xinhuagu Sure! I'll kick off a full review of the latest changes, including the CredentialRecovery enum refactor (ACCESS_TOKEN_UPDATED / REFRESH_AVAILABLE / NO_RECOVERY), the updated 401 retry paths that can now recover with a fresh access token even when no refresh token is present, and all 14 tests covering the new recovery paths.


🧠 Learnings used
Learnt from: CR
Repo: xinhuagu/AceClaw PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-21T00:26:46.991Z
Learning: Support two authentication modes in AnthropicClient: API key (sk-ant-api03-*) via x-api-key header and OAuth token (sk-ant-oat01-*) via Authorization header with beta flags
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…and-swap

CodeRabbit finding: read-compare-write of accessToken was non-atomic
even though fields are volatile. Concurrent 401 responses could see
stale previousAccessToken. Synchronized the method.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xinhuagu

Copy link
Copy Markdown
Owner Author

@coderabbitai review — synchronized recoverCredentials() to fix race condition on token compare-and-swap. The stale test comment refers to an old revision that no longer exists.

@coderabbitai

coderabbitai Bot commented Mar 21, 2026

Copy link
Copy Markdown

@xinhuagu Sure! I'll kick off a review of the latest changes, including the synchronized recoverCredentials() fix for the race condition on token compare-and-swap. I'll also take note that any stale test comment referencing the old revision is no longer applicable.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java (1)

178-201: Consider extracting duplicated 401 recovery switch logic.

The switch statement handling CredentialRecovery outcomes is nearly identical in sendMessage and streamMessage. Extracting a shared helper would reduce duplication and ensure consistent behavior if the recovery logic changes.

For example, a private method returning a boolean indicating whether retry should proceed:

private boolean handleRecoveryOutcome(CredentialRecovery recovery) {
    return switch (recovery) {
        case ACCESS_TOKEN_UPDATED -> {
            log.info("Access token updated from credential store, retrying...");
            yield true;
        }
        case REFRESH_AVAILABLE -> {
            log.info("OAuth token expired, attempting refresh...");
            yield refreshAccessToken();
        }
        case NO_RECOVERY -> {
            log.warn("OAuth token expired (401) but no recovery available; "
                    + "restart daemon or re-run /login to refresh credentials");
            yield false;
        }
    };
}

Also applies to: 242-262

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java`
around lines 178 - 201, The 401 recovery switch used in sendMessage and
streamMessage is duplicated—extract a private helper (e.g.,
handleRecoveryOutcome(CredentialRecovery)) that encapsulates the switch,
performs the same logging, calls refreshAccessToken() when needed, and returns a
boolean indicating whether to retry; then in both sendMessage and streamMessage
call recoverCredentials(), pass the result to handleRecoveryOutcome, and if it
returns true rebuild the request with buildRequest(...) and resend via
httpClient.send(...) to avoid duplicated switch logic and keep behavior
consistent.
aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java (1)

158-191: Consider wrapping executor in try-finally to prevent thread leaks on test failure.

If an assertion fails before executor.shutdown() is called, the thread pool won't be cleaned up properly. While this is minor for test code, it's good hygiene.

♻️ Suggested improvement
         int threads = 8;
         var latch = new CountDownLatch(1);
         ExecutorService executor = Executors.newFixedThreadPool(threads);
-
-        for (int i = 0; i < threads; i++) {
-            executor.submit(() -> {
-                try {
-                    latch.await();
-                    client.recoverCredentials();
-                } catch (InterruptedException e) {
-                    Thread.currentThread().interrupt();
-                }
-            });
+        try {
+            for (int i = 0; i < threads; i++) {
+                executor.submit(() -> {
+                    try {
+                        latch.await();
+                        client.recoverCredentials();
+                    } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                    }
+                });
+            }
+            latch.countDown();
+            executor.shutdown();
+            assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
+        } finally {
+            executor.shutdownNow();
         }
-        latch.countDown();
-        executor.shutdown();
-        assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`
around lines 158 - 191, The test
recoverCredentials_concurrentCalls_consistentState should ensure the
ExecutorService is always cleaned up: wrap the executor usage in a try-finally
around the loop and latch such that executor.shutdown() and a bounded
awaitTermination (and if needed executor.shutdownNow() when await times out) are
invoked in the finally block; this guarantees no thread-pool leak if an
assertion throws before shutdown. Use the existing executor variable and latch
in that try block and perform cleanup in finally.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java`:
- Around line 473-478: recoverCredentials currently assigns this.accessToken
when cred.accessToken() is non-null but does not check for blank, which can
violate the AnthropicClient constructor invariant that accessToken must be
non-null and non-blank; update the recoverCredentials logic (the branch that
checks cred.accessToken()) to also verify the token is not blank (e.g.,
!cred.accessToken().isBlank()) before setting this.accessToken, and add a
debug/warn log if a blank token is returned so the assignment is skipped and the
invariant is preserved.

---

Nitpick comments:
In `@aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java`:
- Around line 178-201: The 401 recovery switch used in sendMessage and
streamMessage is duplicated—extract a private helper (e.g.,
handleRecoveryOutcome(CredentialRecovery)) that encapsulates the switch,
performs the same logging, calls refreshAccessToken() when needed, and returns a
boolean indicating whether to retry; then in both sendMessage and streamMessage
call recoverCredentials(), pass the result to handleRecoveryOutcome, and if it
returns true rebuild the request with buildRequest(...) and resend via
httpClient.send(...) to avoid duplicated switch logic and keep behavior
consistent.

In
`@aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java`:
- Around line 158-191: The test
recoverCredentials_concurrentCalls_consistentState should ensure the
ExecutorService is always cleaned up: wrap the executor usage in a try-finally
around the loop and latch such that executor.shutdown() and a bounded
awaitTermination (and if needed executor.shutdownNow() when await times out) are
invoked in the finally block; this guarantees no thread-pool leak if an
assertion throws before shutdown. Use the existing executor variable and latch
in that try block and perform cleanup in finally.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 65d1c3dd-8b07-4aa3-a60d-882c2aa00fdb

📥 Commits

Reviewing files that changed from the base of the PR and between b3f04cd and 3002c8c.

📒 Files selected for processing (2)
  • aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java
  • aceclaw-llm/src/test/java/dev/aceclaw/llm/anthropic/AnthropicClientTokenRefreshTest.java

Comment on lines +473 to +478
if (cred != null) {
// Pick up fresher access token independently of refresh token availability
if (cred.accessToken() != null && !cred.isExpired()) {
this.accessToken = cred.accessToken();
log.debug("Updated access token from credential store");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add blank check for cred.accessToken() to preserve constructor invariant.

The constructor (line 130-131) validates that accessToken is neither null nor blank. However, recoverCredentials only checks for null before updating this.accessToken. If the credential supplier returns a blank access token, this would violate the class invariant.

🔧 Suggested fix
             if (cred != null) {
                 // Pick up fresher access token independently of refresh token availability
-                if (cred.accessToken() != null && !cred.isExpired()) {
+                if (cred.accessToken() != null && !cred.accessToken().isBlank() && !cred.isExpired()) {
                     this.accessToken = cred.accessToken();
                     log.debug("Updated access token from credential store");
                 }

As per coding guidelines: constructor validates "API key / access token must not be null or blank", so recovery should maintain the same invariant.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (cred != null) {
// Pick up fresher access token independently of refresh token availability
if (cred.accessToken() != null && !cred.isExpired()) {
this.accessToken = cred.accessToken();
log.debug("Updated access token from credential store");
}
if (cred != null) {
// Pick up fresher access token independently of refresh token availability
if (cred.accessToken() != null && !cred.accessToken().isBlank() && !cred.isExpired()) {
this.accessToken = cred.accessToken();
log.debug("Updated access token from credential store");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@aceclaw-llm/src/main/java/dev/aceclaw/llm/anthropic/AnthropicClient.java`
around lines 473 - 478, recoverCredentials currently assigns this.accessToken
when cred.accessToken() is non-null but does not check for blank, which can
violate the AnthropicClient constructor invariant that accessToken must be
non-null and non-blank; update the recoverCredentials logic (the branch that
checks cred.accessToken()) to also verify the token is not blank (e.g.,
!cred.accessToken().isBlank()) before setting this.accessToken, and add a
debug/warn log if a blank token is returned so the assignment is skipped and the
invariant is preserved.

- Add auto-labeling (security, breaking-change, needs-tests)
- Add path filters to skip non-essential files (docs, images, scripts)
- Add pre-merge checks: test coverage for new logic, API breaking changes
- Enable commit_status, fail_commit_status, PMD, docstrings, unit tests
- Restructure path_instructions for readability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.coderabbit.yaml:
- Around line 109-119: The `tools` and `finishing_touches` blocks are
incorrectly placed at the root and should be nested under `reviews`; move the
entire `tools:` (including `pmd:` and its `enabled`) and `finishing_touches:`
(including `docstrings:` and `unit_tests:`) sections so they are children of the
`reviews:` mapping (you can place them before or after `path_instructions`),
preserving proper YAML indentation and structure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8e7ce375-a35c-4dcc-be48-62e64edd8665

📥 Commits

Reviewing files that changed from the base of the PR and between 3002c8c and 9f809c2.

📒 Files selected for processing (1)
  • .coderabbit.yaml

Comment thread .coderabbit.yaml Outdated
Comment on lines +109 to +119
# --- Tools ---
tools:
pmd:
enabled: true

# --- Finishing Touches ---
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

tools and finishing_touches are at incorrect nesting level and will be ignored.

Per the schema, both tools and finishing_touches must be nested under reviews, not at the root level. The current placement will cause these configurations to have no effect since the schema uses additionalProperties: true at root (allows but ignores unknown keys).

🔧 Proposed fix to correct the nesting

Move these sections under reviews: (before or after path_instructions):

   path_instructions:
     # ... existing path_instructions ...

+  # --- Tools ---
+  tools:
+    pmd:
+      enabled: true
+
+  # --- Finishing Touches ---
+  finishing_touches:
+    docstrings:
+      enabled: true
+    unit_tests:
+      enabled: true

 # --- Chat Settings ---
 chat:
   auto_reply: true

-# --- Tools ---
-tools:
-  pmd:
-    enabled: true
-
-# --- Finishing Touches ---
-finishing_touches:
-  docstrings:
-    enabled: true
-  unit_tests:
-    enabled: true
-
 # --- Knowledge Base ---
 knowledge_base:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.coderabbit.yaml around lines 109 - 119, The `tools` and `finishing_touches`
blocks are incorrectly placed at the root and should be nested under `reviews`;
move the entire `tools:` (including `pmd:` and its `enabled`) and
`finishing_touches:` (including `docstrings:` and `unit_tests:`) sections so
they are children of the `reviews:` mapping (you can place them before or after
`path_instructions`), preserving proper YAML indentation and structure.

…g_touches)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@xinhuagu xinhuagu merged commit 36dc2bc into main Mar 21, 2026
3 checks passed
@xinhuagu xinhuagu deleted the fix/324-401-refresh-silent-skip branch March 21, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: 401 token refresh silently skipped when refreshToken is null

1 participant