Skip to content

MT-2107 Add persistent session support with automatic token renewal and retry#3

Open
filiptorphage-mjuk wants to merge 40 commits into
masterfrom
dev-add-persistent-sessions
Open

MT-2107 Add persistent session support with automatic token renewal and retry#3
filiptorphage-mjuk wants to merge 40 commits into
masterfrom
dev-add-persistent-sessions

Conversation

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator

@filiptorphage-mjuk filiptorphage-mjuk commented May 5, 2026

Overview

By default the library logs in and out on every single database operation, which means a full authentication round-trip each time. This PR adds opt-in support for sharing a session token across PHP requests, so only the first request pays the cost of logging in — every subsequent request on the same server reuses the cached token instead.

Persistent sessions are entirely opt-in via a new $sessionCache constructor parameter. The retry mechanism is also opt-in — without a session cache it requires explicitly calling setRetryOnAccessTokenInvalidation(true), and with one it activates automatically. Omitting both keeps all existing behaviour intact.


How to use it

Pass a cache backend to the constructor and the library handles the rest — storing the token after login, reusing it on subsequent requests, and automatically recovering if the token goes stale.

$fm = new FMDataAPI(
    solution: 'MyDatabase',
    user: 'admin',
    password: 'secret',
    host: 'filemaker.example.com',
    sessionCache: new ApcuSessionCache(),
);

// The first request logs in and caches the token.
// Subsequent requests from any PHP worker on this server reuse it.
$fm->startCommunication();
$records = $fm->layout('Contacts')->query();
$fm->endCommunication();
// endCommunication() renews the cache TTL instead of logging out.

startCommunication() and endCommunication() are optional — token reuse works without them. They become useful when batching multiple operations in a single request, since within a scope login() and logout() short-circuit without touching the cache on every call. endCommunication() also resets the 840-second cache TTL, which matters for longer-running scripts where you want the token to stay warm.

Custom backends just need to extend AbstractSessionCache (which itself implements SessionCacheInterface):

class RedisSessionCache extends AbstractSessionCache {
    public function get(string $key): ?string { ... }
    public function set(string $key, string $value, int $ttl): ?bool { ... }
    public function delete(string $key): ?bool { ... }
}

New files

  • AbstractSessionCache.php — public API. An abstract class with internal properties for managing the session cache used by FMDataAPI. This is what custom implementations will extend if custom cache implementations wants to be used.
  • ApcuSessionCache.php — public API. Ready-made APCu implementation. Throws if APCu isn't available.
  • SessionCacheInterface.php — public API. Three methods: get, set (with TTL), delete. These will need to be implemented if you want to extend AbstractSessionCache.

Changes to existing files

Review carefully

src/Supporting/CommunicationProvider.php — the core of this PR. Most of the new logic lives here.

  • New $retryOnAccessTokenInvalidation and $sessionStore properties. Constructor accepts PersistentSessionStore|null $sessionStore = null.
  • startCommunication() (new) — sets keepAuth to the return value of login(), so it's only ever true when login actually succeeded.
  • endCommunication() (new) — if the cached token matches ours, renews the TTL and skips the server logout. If tokens differ, another worker has replaced the cached token; ours is an orphan and gets DELETEd. Falls through to logout() when no cache is configured.
  • login() — checks the cache before attempting an HTTP login. After a fresh login, stores the token in the cache. Changed to call callRestAPIWithoutRetry internally.
  • logout() — three new guards: returns early if accessToken is null; returns early if the token matches the cache; wraps the DELETE in try/finally.
  • callRestAPI() / callRestAPIWithoutRetry() — the original callRestAPI is renamed to callRestAPIWithoutRetry and made private. The new callRestAPI wraps it with retry logic on 952/112, clearing the cache, re-logging in, and retrying once. The original exception is available via getPrevious().

Mechanical changes — safe to skim

  • FMDataAPI.php — new $sessionCache constructor parameter; startCommunication() and endCommunication() delegate to the provider; new setRetryOnAccessTokenInvalidation() method.
  • FileMakerLayout.phpstartCommunication() and endCommunication() delegate to the provider.
  • TestProvider.php — constructor updated to accept and forward PersistentSessionStore|null to the parent.

Behavioural changes for existing users

These apply regardless of whether a session cache is configured.

Please verify all of these.

  • logout() null guard — previously, calling logout() with no active session would fire a DELETE /sessions/ request with a null token in the URL. Before it always threw an exception (if throwExceptionInError was set). Now it immediately returns. This should never occur in the first place, and if accessToken is already null, then we should already be logged out.
  • logout() try/finallyaccessToken is now always nulled after a DELETE attempt, even if the request throws. Previously a thrown exception left it set.
  • startCommunication() login-failure fix — This one were divided into two files. In FMDataAPI.php, then keepAuth were never set to false in non-throwing mode. In FileMakerLayout.php then keepAuth were never set to false in case of a failed login at all. Now, in case of any error, then keepAuth is set to false.
  • endCommunication() — When a session cache is enabled, this method now only logs out sessions where the local accessToken does not match the cached session token. This is to ensure that one, and only one, session token is stored in the cache.
  • Retry on 952/112 — opt-in. Without a session cache, call setRetryOnAccessTokenInvalidation(true) to enable it. With a session cache it activates automatically, since a persistent session that can't recover from an expired token is effectively broken. The retry runs in a fresh session — session-scoped state like global fields set before the failure won't carry over.

Known limitations

Currently, when a session cache is used, the value stored in the key-value pair only has its TTL renewed upon calling endCommunication(). This is a deliberate choice as APCu is not atomic and this minimizes the risk of race conditions. This means that if an application does not call endCommunication() within 14 minutes from opening the session, then the session will be invalidated in the cache and a new session will be started on the next FileMaker Data API request.


Race conditions and stale tokens

If multiple workers miss the cache simultaneously (on cold start or after a 952 clears it), each logs in independently and gets its own token. Last writer wins the cache; the rest become orphans and idle out within FM Server's 15-minute timeout. endCommunication() mitigates this by explicitly DELETEing a token when it finds the cache has already moved on, but workers that crash mid-scope won't get that cleanup. If your FM Server has a low per-user session limit and you anticipate frequent simultaneous cold starts, consider warming the cache from a single entry point before traffic arrives.

Under sustained cache failure in endCommunication() with high concurrency, orphaned tokens could approach FileMaker's session cap (error 953).

Regarding tests

I have modified TestProvider to take in $sessionCache and replaced the callRestAPI() override to now override callRestAPIWithoutRetry() (as I renamed that function). However, I'm not sure how the tests in this repositories are used, and I'm not sure how to even run them, so I'll leave the testing to you (msyk). If you feel the need to add any tests to this, feel free to do so.

Summary by CodeRabbit

  • New Features
    • Optional persistent session token caching to improve performance and reduce authentication overhead
    • Configurable automatic retry mechanism when access tokens become invalidated
    • New session cache configuration methods for customizing cache key names and time-to-live settings

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

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

This PR adds persistent FileMaker session-token caching with TTL renewal and automatic single-retry on token invalidation. The feature introduces a session-cache abstraction layer with APCu backing, integrates cache-aware token lifecycle into the communication provider, and exposes configuration methods through the public API.

Changes

Session Cache and Token Reuse

Layer / File(s) Summary
Session cache interface contract
src/SessionCache/SessionCacheInterface.php
SessionCacheInterface defines the contract for caching implementations: get(), set(), and delete() methods for FileMaker tokens.
Abstract base class for cache implementations
src/SessionCache/AbstractSessionCache.php
AbstractSessionCache implements SessionCacheInterface with protected $cacheKey and $ttl properties, constructor with default 840-second TTL, and final setCacheKey() and setTtl() methods for library initialization.
APCu cache implementation
src/SessionCache/ApcuSessionCache.php
ApcuSessionCache extends AbstractSessionCache and uses APCu functions (apcu_fetch, apcu_store, apcu_delete) to store/retrieve/delete tokens; constructor validates APCu availability.
CommunicationProvider cache configuration
src/Supporting/CommunicationProvider.php
Constructor accepts optional AbstractSessionCache parameter; adds public $sessionCache, $retryOnAccessTokenInvalidation, and $resumeScopeAfterReauth properties; computes deterministic cache key from connection metadata via cacheKey().
Token lifecycle with cache awareness
src/Supporting/CommunicationProvider.php
login() attempts to reuse cached token before fetching new one and stores new tokens in cache; logout() skips server-side DELETE when cache owns the token; endCommunication() renews cached-token TTL without server logout; startCommunication() initializes cache key.
Retry logic for token invalidation
src/Supporting/CommunicationProvider.php
callRestAPI() detects FileMaker error codes 952 and 112 (token invalidation), clears cache/token state, re-authenticates, and retries once via callRestAPIWithoutRetry(); adds shouldRetryOnTokenError() and extractErrorCode() helpers; throws chained exceptions with original as previous.
Public API integration
src/FMDataAPI.php, src/Supporting/FileMakerLayout.php
FMDataAPI accepts optional sessionCache in constructor and forwards to provider; startCommunication() and endCommunication() delegate to provider methods; new setRetryOnAccessTokenInvalidation(), setSessionCacheKeyName(), and setSessionCacheTtl() configure provider state. FileMakerLayout delegates lifecycle to restAPI provider methods.
Test provider support
test/TestProvider.php
TestProvider constructor accepts optional sessionCache parameter and forwards to parent; callRestAPI() becomes protected callRestAPIWithoutRetry() to integrate with retry logic.
Dependency declaration
composer.json
Adds "suggest" section declaring optional ext-apcu extension for ApcuSessionCache.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A cache full of tokens, they hop and they stay,
Renewed with each call through the TTL's way,
When auth falls apart, we retry with care,
FileMaker sessions, persistent and fair! 🔐

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: adding persistent session support with automatic token renewal and retry functionality, which is the core objective of this PR.
Docstring Coverage ✅ Passed Docstring coverage is 83.93% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 dev-add-persistent-sessions

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

@filiptorphage-mjuk filiptorphage-mjuk marked this pull request as ready for review May 5, 2026 14:36
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR adds opt-in persistent session token caching to FMDataAPI, eliminating redundant FileMaker login/logout round-trips across PHP requests by sharing a token via a pluggable cache backend (APCu built-in, extensible via AbstractSessionCache). It also introduces an opt-in retry mechanism that transparently re-authenticates and replays a failed request when the server returns error 952 (invalid token) or 112 (window missing).

  • Session cache — a new $sessionCache constructor parameter accepts any AbstractSessionCache subclass; login() checks the cache before hitting the server, and endCommunication() renews the cached TTL instead of logging out when the token is still current.
  • Retry on invalidationcallRestAPI() now wraps callRestAPIWithoutRetry() with one retry pass; the scope flag (keepAuth) is correctly preserved or restored across the re-auth, and resumeScopeAfterReauth is cleared by endCommunication() to prevent scope bleed.
  • Behavioural fixeslogout() gains a null guard and a try/finally to always clear accessToken; startCommunication() in both FMDataAPI and FileMakerLayout now correctly sets keepAuth = false on login failure.

Confidence Score: 5/5

Safe to merge — all previously identified issues (cache key security, strict comparison cast, null guards, scope-flag bleed) are confirmed fixed in this revision.

The session caching logic, retry path, and scope-flag lifecycle are all consistent and correct. The one remaining note is a defensive gap in extractErrorCode() where an empty messages array from the FileMaker server would cause a PHP 8 TypeError, but this is a theoretical edge case given the API spec always includes at least one message element.

src/Supporting/CommunicationProvider.php — new extractErrorCode() helper could use an extra null guard on messages[0].

Important Files Changed

Filename Overview
src/Supporting/CommunicationProvider.php Core of the PR — adds session caching, retry-on-invalidation, and revised login/logout guards; all previously flagged issues (strict === on error codes, missing $user in cache key, null separator, null-guard on setCacheKey) are confirmed fixed; minor defensive gap in extractErrorCode() for empty messages array.
src/FMDataAPI.php Adds $sessionCache constructor parameter, delegates startCommunication/endCommunication to the provider, and adds setRetryOnAccessTokenInvalidation, setSessionCacheKeyName, and setSessionCacheTtl with proper null guards.
src/SessionCache/AbstractSessionCache.php Clean base class providing $cacheKey and $ttl to subclasses via final setters called by the library; $cacheKey is intentionally uninitialized (typed property, set before first use) with clear documentation.
src/SessionCache/ApcuSessionCache.php Straightforward APCu implementation; correctly checks availability at construction time, uses apcu_fetch with $success flag to distinguish missing keys from falsy values.
src/SessionCache/SessionCacheInterface.php Clean interface defining the three required cache operations; correctly documents that direct implementation is not recommended — callers should extend AbstractSessionCache instead.
src/Supporting/FileMakerLayout.php Mechanical delegation of startCommunication/endCommunication to the provider; also fixes the pre-existing bug where login failure didn't set keepAuth = false.
test/TestProvider.php Updated to accept $sessionCache and overrides callRestAPIWithoutRetry instead of the old callRestAPI, correctly adapting to the renamed method.
composer.json Adds a suggest entry for ext-apcu correctly scoped to the built-in ApcuSessionCache backend; previous non-existent PSR class references are no longer present.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant FMDataAPI
    participant CommunicationProvider
    participant SessionCache
    participant FileMakerServer

    Caller->>FMDataAPI: startCommunication()
    FMDataAPI->>CommunicationProvider: startCommunication()
    CommunicationProvider->>CommunicationProvider: login()
    CommunicationProvider->>SessionCache: get()
    alt Cache hit
        SessionCache-->>CommunicationProvider: token
        CommunicationProvider->>CommunicationProvider: "accessToken = cached token"
    else Cache miss
        SessionCache-->>CommunicationProvider: null
        CommunicationProvider->>FileMakerServer: POST /sessions (Basic auth)
        FileMakerServer-->>CommunicationProvider: 200 + token
        CommunicationProvider->>SessionCache: set(token, ttl)
    end
    CommunicationProvider-->>FMDataAPI: "keepAuth = true"

    Caller->>FMDataAPI: layout('X').query()
    FMDataAPI->>CommunicationProvider: callRestAPI(...)
    CommunicationProvider->>CommunicationProvider: callRestAPIWithoutRetry(...)
    CommunicationProvider->>FileMakerServer: GET /layouts/X/records
    alt 952 or 112 error and retry enabled
        FileMakerServer-->>CommunicationProvider: 952 Invalid token
        CommunicationProvider->>SessionCache: delete()
        CommunicationProvider->>CommunicationProvider: login() fresh
        CommunicationProvider->>FileMakerServer: POST /sessions
        FileMakerServer-->>CommunicationProvider: 200 + new token
        CommunicationProvider->>SessionCache: set(new token, ttl)
        CommunicationProvider->>FileMakerServer: GET /layouts/X/records retry
        FileMakerServer-->>CommunicationProvider: 200 + records
    else Success
        FileMakerServer-->>CommunicationProvider: 200 + records
    end

    Caller->>FMDataAPI: endCommunication()
    FMDataAPI->>CommunicationProvider: endCommunication()
    CommunicationProvider->>SessionCache: get()
    alt Token matches cache
        SessionCache-->>CommunicationProvider: same token
        CommunicationProvider->>SessionCache: set(token, ttl) renew TTL
        CommunicationProvider->>CommunicationProvider: "accessToken = null, return"
    else Token replaced by another worker
        SessionCache-->>CommunicationProvider: different token
        CommunicationProvider->>FileMakerServer: DELETE /sessions/stale-token
    end
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/Supporting/CommunicationProvider.php:1040-1048
If the FileMaker server ever returns a response where `messages` is an empty array, `$this->responseBody->messages[0]` evaluates to `null`, and the subsequent `property_exists(null, 'code')` call throws a `TypeError` in PHP 8 (`Argument #1 must be of type object|string, null given`). Adding an `is_object` guard keeps the method safe against malformed responses.

```suggestion
    private function extractErrorCode(): int
    {
        $errorCode = -1;
        if (is_object($this->responseBody) && property_exists($this->responseBody, 'messages')) {
            $result = $this->responseBody->messages[0] ?? null;
            $errorCode = is_object($result) && property_exists($result, 'code') ? intval($result->code) : -1;
        }
        return $errorCode;
    }
```

Reviews (21): Last reviewed commit: "Enforced strict types declaration in Apc..." | Re-trigger Greptile

Comment thread src/Supporting/CommunicationProvider.php
Comment thread src/Supporting/CommunicationProvider.php
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Supporting/CommunicationProvider.php`:
- Around line 305-309: The current TTL-renewal block in CommunicationProvider
(checking $this->sessionStore and $this->accessToken) unconditionally clears
$this->accessToken after calling PersistentSessionStore::set(), which can orphan
a server session if set() returns false; change the logic to check the boolean
return of $this->sessionStore->set($this->accessToken) and only null out
$this->accessToken and return when set() returns true, and if set() returns
false perform a fallback logout (call $this->logout() or the existing
server-side session invalidation path) instead of dropping the token so a
transient cache failure does not leak an active session.
- Around line 998-1003: The extracted error code from
responseBody->messages[0]->code is currently left as-is (string) which prevents
strict numeric comparisons in shouldRetryOnTokenError(); update
extractErrorCode() to cast the value to an int (e.g., (int) $result->code or
intval($result->code)) before assigning/returning so that extractErrorCode()
returns a true int (still defaulting to -1 when absent) and the strict checks in
shouldRetryOnTokenError() (=== 952 / === 112) work correctly.
- Around line 985-991: The current retry decision returns true for error codes
952 and 112 which causes unsafe automatic retries for non-idempotent verbs;
change the logic so only 952 triggers automatic retry (replace "return
$errorCode === 952 || $errorCode === 112;" with a check that returns true only
for 952), and if you want to support 112 make it opt-in: add a configuration
flag (e.g., $this->allowRetry112) and only consider 112 when that flag is true
AND the request is idempotent (use or add an isIdempotentRequest($method) helper
called from callRestAPI()/retry decision). Ensure you reference
extractErrorCode() where the error is read and the callRestAPI() path that
triggers retries so the gating logic is applied consistently.
- Around line 570-576: The calls to callRestAPIWithoutRetry() from login() and
logout() are invoking a private method which prevents TestProvider from
overriding it to mock /sessions; change the visibility of the
callRestAPIWithoutRetry() method from private to protected so TestProvider can
override it, then update any subclass overrides (e.g., in TestProvider) to
provide mocked responses for authentication endpoints; ensure references in
login(), logout(), and any other callers (lines near the calls to
callRestAPIWithoutRetry()) remain unchanged but now dispatch to the protected
method.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f7ca6e4-c153-4104-9e91-2db99ee1e347

📥 Commits

Reviewing files that changed from the base of the PR and between 5dd7901 and 8e3b442.

📒 Files selected for processing (7)
  • src/FMDataAPI.php
  • src/PersistentSession/ApcuSessionCache.php
  • src/PersistentSession/PersistentSessionStore.php
  • src/PersistentSession/SessionCacheInterface.php
  • src/Supporting/CommunicationProvider.php
  • src/Supporting/FileMakerLayout.php
  • test/TestProvider.php

Comment thread src/Supporting/CommunicationProvider.php Outdated
Comment thread src/Supporting/CommunicationProvider.php
Comment thread src/Supporting/CommunicationProvider.php
Comment thread src/Supporting/CommunicationProvider.php Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
src/Supporting/CommunicationProvider.php (3)

570-576: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

callRestAPIWithoutRetry() is private, blocking test mocking of /sessions endpoints.

login() (line 570) and logout() (line 610) both call callRestAPIWithoutRetry() directly. Because the method is private, TestProvider cannot override it, so test mode hits real authentication endpoints while other API calls are mocked, breaking test isolation.

This is still unresolved from the previous review cycle.

Also applies to: 608-613

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Supporting/CommunicationProvider.php` around lines 570 - 576, The private
visibility of callRestAPIWithoutRetry prevents TestProvider from overriding it
and causes real /sessions calls during tests; change the method declaration of
callRestAPIWithoutRetry from private to protected so subclasses (e.g.,
TestProvider) can mock/override it, then keep login() and logout() calling
callRestAPIWithoutRetry as before (no call-site changes needed) and run tests to
confirm session endpoints are now intercepted by the test subclass.

305-309: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

endCommunication(): TTL-renewal path silently orphans the server session on cache failure.

If sessionStore->set() returns false (or null) the token is nulled and the method returns, leaving a live server-side session with no cache entry pointing to it.
This is still unresolved from the previous review cycle.

🛡️ Proposed fix
         if ($this->sessionStore !== null && $this->accessToken !== null) {
             if ($this->sessionStore->get() === $this->accessToken) {
-                $this->sessionStore->set($this->accessToken); // renew TTL
-                $this->accessToken = null;
-                return;
+                $renewed = $this->sessionStore->set($this->accessToken); // renew TTL
+                if ($renewed !== false) {
+                    $this->accessToken = null;
+                    return;
+                }
+                // Cache write failed; fall through and logout to avoid orphaning the server session.
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Supporting/CommunicationProvider.php` around lines 305 - 309, In
endCommunication() the TTL-renewal branch calls
$this->sessionStore->set($this->accessToken) but then unconditionally nulls
$this->accessToken and returns, which orphaned server sessions if set() fails;
change this to check the return value of $this->sessionStore->set(...): if it
returns truthy proceed to null $this->accessToken and return, otherwise do not
null the token, log or surface the failure (via process logger or throw) and
leave $this->accessToken intact so future cleanup can retry; reference the
endCommunication() method and the calls to $this->sessionStore->get() and
$this->sessionStore->set() when making the change.

985-991: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Error 112 retry still fires on non-idempotent verbs.

callRestAPI() wraps POST, PATCH, and DELETE as well as GET. Error 112 ("Window is missing") is not a Data API auth-invalidation signal like 952, so retrying it for mutating verbs is unsafe — the server-side outcome is already ambiguous.

This is still unresolved from the previous review cycle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Supporting/CommunicationProvider.php` around lines 985 - 991, The current
auth-retry check always treats error code 112 as an auth-invalid signal, causing
unsafe retries for mutating verbs; update the logic so error 952 still always
returns true but error 112 only triggers a retry for safe/idempotent HTTP
methods (e.g., GET, HEAD, OPTIONS) used by callRestAPI(), by passing the HTTP
method (or reading the request method available in the current context) into the
check (the function using extractErrorCode() / the code that decides retries)
and returning true for 112 only when the method is idempotent; ensure
POST/PATCH/DELETE do not retry on 112.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Supporting/CommunicationProvider.php`:
- Around line 716-726: Initialize a $reauthed flag to false before the try, then
after the existing try/catch add a check that if $reauthed is false you rethrow
the original $firstAttempt exception so the original failure is not lost; keep
the existing logic that sets $reauthed = $this->login() and $this->keepAuth =
true when $resumeScope, and do not throw inside the try (to avoid circular
chaining) — use the post-try check to throw $firstAttempt when !$reauthed so
callers see the original error.

---

Duplicate comments:
In `@src/Supporting/CommunicationProvider.php`:
- Around line 570-576: The private visibility of callRestAPIWithoutRetry
prevents TestProvider from overriding it and causes real /sessions calls during
tests; change the method declaration of callRestAPIWithoutRetry from private to
protected so subclasses (e.g., TestProvider) can mock/override it, then keep
login() and logout() calling callRestAPIWithoutRetry as before (no call-site
changes needed) and run tests to confirm session endpoints are now intercepted
by the test subclass.
- Around line 305-309: In endCommunication() the TTL-renewal branch calls
$this->sessionStore->set($this->accessToken) but then unconditionally nulls
$this->accessToken and returns, which orphaned server sessions if set() fails;
change this to check the return value of $this->sessionStore->set(...): if it
returns truthy proceed to null $this->accessToken and return, otherwise do not
null the token, log or surface the failure (via process logger or throw) and
leave $this->accessToken intact so future cleanup can retry; reference the
endCommunication() method and the calls to $this->sessionStore->get() and
$this->sessionStore->set() when making the change.
- Around line 985-991: The current auth-retry check always treats error code 112
as an auth-invalid signal, causing unsafe retries for mutating verbs; update the
logic so error 952 still always returns true but error 112 only triggers a retry
for safe/idempotent HTTP methods (e.g., GET, HEAD, OPTIONS) used by
callRestAPI(), by passing the HTTP method (or reading the request method
available in the current context) into the check (the function using
extractErrorCode() / the code that decides retries) and returning true for 112
only when the method is idempotent; ensure POST/PATCH/DELETE do not retry on
112.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dbbce53c-9f59-47df-b5b7-5d1f0376f892

📥 Commits

Reviewing files that changed from the base of the PR and between 8e3b442 and 45c3445.

📒 Files selected for processing (1)
  • src/Supporting/CommunicationProvider.php

Comment thread src/Supporting/CommunicationProvider.php
Comment thread src/Supporting/CommunicationProvider.php
@filiptorphage-mjuk filiptorphage-mjuk changed the title Add persistent session support with automatic token renewal and retry MT-2107 Add persistent session support with automatic token renewal and retry May 6, 2026
@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

1 similar comment
@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@coderabbitai

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@filiptorphage-mjuk, it looks like your message only contains a mention — could you share what you'd like help with? For example, I can:

  • Review the PR changes in detail
  • Answer questions about the implementation (e.g., the persistent session caching, retry logic, race conditions)
  • Suggest improvements to any part of the code
  • Generate tests for the new functionality
  • Explain any part of the diff

Let me know how I can help! 🐇

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

✅ 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/TestProvider.php (1)

95-103: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

validResponse() silent null return will cause a TypeError in retry test scenarios.

validResponse() returns null implicitly when no hash matches. The new retry path in CommunicationProvider::callRestAPI() calls callRestAPIWithoutRetry() a second time after re-login with a freshly issued token. That new token changes the Authorization header, producing a different SHA-1 hash that won't match any entry in $this->responses. The resulting null reaches line 72 ($this->curlInfo = $response['curlinfo']), throwing a TypeError in PHP 8.

No current test exercises the retry path (no 952/112 mock exists in buildResponses()), so this is latent — but it will break as soon as retry tests are added.

🛡️ Proposed fix: add a safe fallback return
     private function validResponse($input)
     {
         $hash = sha1(json_encode($input));
         foreach ($this->responses as $key => $value) {
             if ($hash === $key) {
                 return $value;
             }
         }
+        // No registered mock for this request. Return a neutral response to
+        // avoid a TypeError on array access in callRestAPIWithoutRetry().
+        return [
+            'response'        => '{"response":{},"messages":[{"code":"0","message":"OK"}]}',
+            'curlerror'       => 0,
+            'curlerrormessage'=> '',
+            'curlinfo'        => ['http_code' => 200],
+        ];
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/TestProvider.php` around lines 95 - 103, validResponse() can return null
when no hash matches causing a TypeError downstream; update validResponse (in
TestProvider::validResponse) to always return a safe fallback instead of
implicit null—either an array with the expected keys (e.g., 'curlinfo' and other
keys used by callRestAPI()/callRestAPIWithoutRetry) or throw a clear exception.
Ensure the fallback shape matches what callRestAPI reads (so
$response['curlinfo'] exists) or adjust tests in buildResponses to include retry
hashes (952/112) if you prefer explicit mocks; update only
TestProvider::validResponse to return that default response when no $hash
matches.
♻️ Duplicate comments (1)
src/Supporting/CommunicationProvider.php (1)

988-994: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Error 112 retry safety — ongoing discussion from previous review.

This was raised in the prior round (lines 988-994) and is explicitly unresolved: the maintainer deferred the decision to the library author. The core concern remains — error 112 ("Window is missing") is not a Data API token-invalidation code like 952, and retrying it unconditionally on non-idempotent verbs (POST/PATCH/DELETE) could cause duplicate writes if the server processed the request before returning the error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Supporting/CommunicationProvider.php` around lines 988 - 994, The current
boolean return treats FileMaker error 112 the same as token-invalid (952), which
can trigger unsafe automatic retries for non-idempotent verbs; update the method
that calls extractErrorCode() so that 952 remains always considered an
auth/refresh condition but 112 is only treated as retry-safe when the original
HTTP method is idempotent (e.g., GET, HEAD, OPTIONS, PUT) — if you can't
determine the request method from this class, remove 112 from the
automatic-retry path and surface/log it for manual handling; reference
extractErrorCode() and the enclosing method that returns the boolean to
implement this conditional check (or the alternative of dropping 112).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Supporting/CommunicationProvider.php`:
- Around line 719-729: The current retry catch path can leave $this->keepAuth
stuck as false when $this->login() returns false (no exception) causing scope
state corruption; update the retry logic in CommunicationProvider::callRestAPI
(the block that calls $this->login(), checks $reauthed and then calls
callRestAPIWithoutRetry) to explicitly restore $this->keepAuth to the original
$resumeScope value whenever reauth fails (i.e., when $reauthed is false and no
exception was thrown) so a failed re-login does not silently drop the caller's
startCommunication()/endCommunication() scope; ensure you reference the existing
$resumeScope, $reauthed, $this->keepAuth and continue to call or skip
callRestAPIWithoutRetry exactly as before.

---

Outside diff comments:
In `@test/TestProvider.php`:
- Around line 95-103: validResponse() can return null when no hash matches
causing a TypeError downstream; update validResponse (in
TestProvider::validResponse) to always return a safe fallback instead of
implicit null—either an array with the expected keys (e.g., 'curlinfo' and other
keys used by callRestAPI()/callRestAPIWithoutRetry) or throw a clear exception.
Ensure the fallback shape matches what callRestAPI reads (so
$response['curlinfo'] exists) or adjust tests in buildResponses to include retry
hashes (952/112) if you prefer explicit mocks; update only
TestProvider::validResponse to return that default response when no $hash
matches.

---

Duplicate comments:
In `@src/Supporting/CommunicationProvider.php`:
- Around line 988-994: The current boolean return treats FileMaker error 112 the
same as token-invalid (952), which can trigger unsafe automatic retries for
non-idempotent verbs; update the method that calls extractErrorCode() so that
952 remains always considered an auth/refresh condition but 112 is only treated
as retry-safe when the original HTTP method is idempotent (e.g., GET, HEAD,
OPTIONS, PUT) — if you can't determine the request method from this class,
remove 112 from the automatic-retry path and surface/log it for manual handling;
reference extractErrorCode() and the enclosing method that returns the boolean
to implement this conditional check (or the alternative of dropping 112).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 65626517-062c-4a1b-80c1-3e6c9c52d370

📥 Commits

Reviewing files that changed from the base of the PR and between 45c3445 and cceb0f4.

📒 Files selected for processing (2)
  • src/Supporting/CommunicationProvider.php
  • test/TestProvider.php

Comment thread src/Supporting/CommunicationProvider.php
@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

@filiptorphage-mjuk filiptorphage-mjuk requested a review from jomla97 May 6, 2026 14:27
… not when the actual command fails (since it's only in the login method that `keepAuth` is being set.
@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

Comment thread src/Supporting/CommunicationProvider.php Outdated
@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai Would adding this block to the case where login() fetches from the cache fix the issue of keepAuth becoming stale?

                if ($this->resumeScopeAfterReauth) {
                    $this->keepAuth = true;
                    $this->resumeScopeAfterReauth = false;
                }

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Yes, that fix is correct and necessary. Here's the exact scenario that exposes the gap:

  1. You're inside a scope (keepAuth = true)
  2. callRestAPI() gets a 952 — it saves resumeScope = true, sets keepAuth = false, calls login()
  3. login() throws (e.g. network error during the re-auth HTTP call)
  4. callRestAPI() catches it, sets resumeScopeAfterReauth = true, re-throws
  5. Some outer handler catches the exception
  6. Later, user triggers another operation → login() is called again
  7. This time the cache has a fresh token → hits the early-return path and returns true... but never checks resumeScopeAfterReauth
  8. keepAuth stays false even though we're still inside the original scope

The fix belongs at the cache-hit return:

if ($cached !== null) {
    $this->accessToken = $cached;
    if ($this->resumeScopeAfterReauth) {
        $this->keepAuth = true;
        $this->resumeScopeAfterReauth = false;
    }
    return true;
}

The same block already exists in the HTTP-login success path — it just needs to be mirrored here. Without it, resumeScopeAfterReauth silently leaks: it stays true permanently and will incorrectly re-enable keepAuth on the next unrelated retry cycle.

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

✅ 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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@composer.json`:
- Around line 21-23: Reword the suggest message for ext-apcu to make it clear
the extension is optional and only needed when using ApcuSessionCache: update
the value under "suggest" (key "ext-apcu") in composer.json to a phrasing such
as "Optional: required only if you use ApcuSessionCache" or similar, referencing
ext-apcu and ApcuSessionCache so users understand it is not mandatory.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 02a7b43d-4638-4492-8b09-074e03ca76b2

📥 Commits

Reviewing files that changed from the base of the PR and between 238e891 and 51d9f09.

📒 Files selected for processing (4)
  • composer.json
  • src/FMDataAPI.php
  • src/SessionCache/ApcuSessionCache.php
  • src/Supporting/CommunicationProvider.php

Comment thread composer.json
@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

✅ 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.

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai What do I have to do to get the confidence score up to 5 of 5?

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

Here are the concrete issues keeping the confidence score below 5/5:


1. callRestAPI() retries even on a successful first call (bug)

When throwExceptionInError is false, a 952 error doesn't throw — $firstAttempt stays null — but shouldRetryOnTokenError() still reads the stale responseBody and can return true, causing the retry block to run unnecessarily. The fix is to short-circuit before checking:

        $firstAttempt = null;
        try {
            $this->callRestAPIWithoutRetry($params, $isAddToken, $method, $request, $addHeader, $isSystem, $directPath);
        } catch (Exception $e) {
            $firstAttempt = $e;
        }

+        if ($firstAttempt === null) {
+            return;
+        }
+
        if (!$this->shouldRetryOnTokenError()) {
            if ($firstAttempt !== null) {
                throw $firstAttempt;
            }
            return;
        }

2. resumeScopeAfterReauth leaks dirty state (bug)

In the retry failure paths inside callRestAPI():

if (!$this->login()) {
    $this->resumeScopeAfterReauth = $resumeScope; // BUG: persists true if was in keepAuth scope
    return;
}

If $resumeScope is true and login fails, resumeScopeAfterReauth is left as true. On the next unrelated request, login() will consume it and incorrectly set keepAuth = true. Both failure branches should use false:

        try {
            if (!$this->login()) {
-                $this->resumeScopeAfterReauth = $resumeScope;
+                $this->resumeScopeAfterReauth = false;
                return;
            }
        } catch (Exception $e) {
-            $this->resumeScopeAfterReauth = $resumeScope;
+            $this->resumeScopeAfterReauth = false;
            throw new Exception($e->getMessage(), $e->getCode(), $firstAttempt);
        }

3. No tests for any of the new behaviour

The retry logic, endCommunication() token-mismatch path, resumeScopeAfterReauth state transitions, and the session cache interaction are all completely untested. Even a basic mock of AbstractSessionCache (similar to how TestProvider mocks the HTTP layer) would catch the two bugs above and provide a regression safety net for future changes.


4. ApcuSessionCache.php is missing declare(strict_types=1)

Both AbstractSessionCache.php and SessionCacheInterface.php have it. ApcuSessionCache.php doesn't, which is inconsistent and means implicit type coercions are permitted in that file.


Summary of what to fix:

Priority Issue
High Add if ($firstAttempt === null) return; early in callRestAPI()
High Set $this->resumeScopeAfterReauth = false (not $resumeScope) on retry failure
High Add unit tests covering the new retry and cache paths
Low Add declare(strict_types=1) to ApcuSessionCache.php

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

  1. This is a hard no. responseBody is never stale. Besides, your "fix" breaks the logic of the application. On an error 952, then a retry should occur, you are making it straight up return early instead.
  2. This is the only place in the code where they can even be set to true, setting them to false removes their whole purpose. Besides, as the name suggest, "resumeScopeAfterReauth" should signal that is the next login attempt will set keepAuth to true or not, just like it does.
  3. This application does not use tests at all in the traditional way, and I have made this clear in the PR description.
  4. I'll add that.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

You're right on all three points — I apologize for the incorrect comments.

  1. responseBody is set inside callRestAPIWithoutRetry before the exception is thrown (at the json_decode assignment), so by the time shouldRetryOnTokenError() reads it, it reflects the actual server response from the failed attempt. My suggestion would have caused a return early on 952 instead of triggering the retry — that's clearly broken.

  2. resumeScopeAfterReauth is consumed in login() to restore keepAuth after a successful re-login. Setting it to false before that happens would prevent the scope from ever being resumed after a reauthentication — defeating its entire purpose. The pattern is sound.

  3. Missed that in the PR description. Noted.

  4. Acknowledged, thanks.

Tip: You can give me more context with [.greptile/rules.md](https://www.greptile.com/docs/code-review/custom-standards) so I get this right next time.

@filiptorphage-mjuk
Copy link
Copy Markdown
Collaborator Author

@greptileai

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.

2 participants