Skip to content

Commit 0b08fec

Browse files
author
mcdax
committed
fix: timezone issue
1 parent 42b47bd commit 0b08fec

9 files changed

Lines changed: 463 additions & 42 deletions

File tree

README.md

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ This client implements the official Comdirect REST API:
4646
- [On-Demand Token Refresh](#on-demand-token-refresh)
4747
- [Reauth Callback](#reauth-callback)
4848
- [Token Lifecycle](#token-lifecycle)
49+
- [Persistent Client Usage](#persistent-client-usage)
4950
- [Data Models](#data-models)
5051
- [Error Handling](#error-handling)
5152
- [Logging](#logging)
@@ -302,7 +303,7 @@ async def main():
302303
) as client:
303304

304305
# Authenticate (triggers Push-TAN on your smartphone)
305-
print("Authenticating... Please approve Push-TAN on your device")
306+
print("Authenticating...")
306307
await client.authenticate()
307308
print("✓ Authenticated!")
308309

@@ -1004,6 +1005,164 @@ Tokens expire every ~10 minutes (599s). The client automatically refreshes 120 s
10041005

10051006
---
10061007

1008+
## Persistent Client Usage
1009+
1010+
**The ComdirectClient should be kept alive (persistent) throughout your application's lifecycle for best functionality.**
1011+
1012+
### Why Keep the Client Alive?
1013+
1014+
The client has a **background token refresh task** that automatically refreshes tokens 120 seconds before they expire. This task runs as an asyncio background coroutine and is critical for maintaining uninterrupted access to the Comdirect API.
1015+
1016+
**If the client instance is destroyed:**
1017+
1018+
- The background refresh task is cancelled
1019+
- Tokens will expire after ~10 minutes (599 seconds)
1020+
- A new TAN approval will be required for your next session
1021+
- This defeats the purpose of automatic token refresh
1022+
1023+
### Best Practice: Create Once, Reuse Everywhere
1024+
1025+
```python
1026+
import asyncio
1027+
from comdirect_client.client import ComdirectClient
1028+
1029+
# Global client instance - created once at application startup
1030+
client: ComdirectClient | None = None
1031+
1032+
async def init_client():
1033+
"""Initialize the client once at startup."""
1034+
global client
1035+
client = ComdirectClient(
1036+
client_id="your_client_id",
1037+
client_secret="your_client_secret",
1038+
username="your_username",
1039+
password="your_password",
1040+
token_storage_path="/path/to/tokens.json", # Persist across restarts
1041+
)
1042+
1043+
# Authenticate once - requires TAN approval
1044+
await client.authenticate()
1045+
print("Client authenticated and ready!")
1046+
1047+
# The background refresh task is now running automatically
1048+
# Tokens will be refreshed 120s before expiry
1049+
1050+
async def get_balances():
1051+
"""Use the persistent client for API calls."""
1052+
if not client:
1053+
raise RuntimeError("Client not initialized")
1054+
return await client.get_account_balances()
1055+
1056+
async def get_transactions(account_id: str):
1057+
"""Another API call using the same client instance."""
1058+
if not client:
1059+
raise RuntimeError("Client not initialized")
1060+
return await client.get_transactions(account_id)
1061+
1062+
async def shutdown():
1063+
"""Clean up when application shuts down."""
1064+
if client:
1065+
await client.close()
1066+
```
1067+
1068+
### What Happens with Token Storage?
1069+
1070+
When you configure `token_storage_path`:
1071+
1072+
1. **First run**: Authenticate with TAN, tokens are saved to disk
1073+
2. **Subsequent runs**: Tokens are loaded from disk on client creation
1074+
3. **Background refresh starts automatically** when valid tokens are loaded
1075+
4. **No new TAN approval needed** as long as tokens haven't expired
1076+
1077+
```python
1078+
# Application startup
1079+
client = ComdirectClient(
1080+
...,
1081+
token_storage_path="/path/to/tokens.json",
1082+
)
1083+
1084+
# If valid tokens exist in storage:
1085+
# - Tokens are automatically loaded
1086+
# - Refresh task starts immediately
1087+
# - No authenticate() call needed!
1088+
1089+
if client.is_authenticated():
1090+
print("Tokens restored from storage - ready to use!")
1091+
balances = await client.get_account_balances()
1092+
else:
1093+
print("No valid tokens - TAN approval required")
1094+
await client.authenticate()
1095+
```
1096+
1097+
### Anti-Pattern: Creating New Client Per Request
1098+
1099+
**Do NOT do this** - it defeats the purpose of automatic token refresh:
1100+
1101+
```python
1102+
# BAD: Client is destroyed after each request
1103+
async def get_balance_bad():
1104+
async with ComdirectClient(...) as client:
1105+
await client.authenticate() # TAN approval needed
1106+
return await client.get_account_balances()
1107+
# Client destroyed here! Refresh task cancelled!
1108+
1109+
# After ~10 minutes, calling get_balance_bad() again requires new TAN approval
1110+
```
1111+
1112+
### Framework Integration Examples
1113+
1114+
**FastAPI / Starlette:**
1115+
1116+
```python
1117+
from fastapi import FastAPI
1118+
from contextlib import asynccontextmanager
1119+
from comdirect_client.client import ComdirectClient
1120+
1121+
client: ComdirectClient | None = None
1122+
1123+
@asynccontextmanager
1124+
async def lifespan(app: FastAPI):
1125+
global client
1126+
client = ComdirectClient(
1127+
...,
1128+
token_storage_path="/app/data/tokens.json",
1129+
)
1130+
if not client.is_authenticated():
1131+
await client.authenticate()
1132+
yield
1133+
await client.close()
1134+
1135+
app = FastAPI(lifespan=lifespan)
1136+
1137+
@app.get("/balances")
1138+
async def get_balances():
1139+
return await client.get_account_balances()
1140+
```
1141+
1142+
**Long-running Service:**
1143+
1144+
```python
1145+
async def main():
1146+
client = ComdirectClient(
1147+
...,
1148+
token_storage_path="tokens.json",
1149+
reauth_callback=lambda reason: print(f"Reauth needed: {reason}"),
1150+
)
1151+
1152+
if not client.is_authenticated():
1153+
await client.authenticate()
1154+
1155+
# Run indefinitely - tokens refresh automatically
1156+
while True:
1157+
balances = await client.get_account_balances()
1158+
print(f"Current balance: {balances[0].balance.value}")
1159+
await asyncio.sleep(3600) # Check every hour
1160+
1161+
asyncio.run(main())
1162+
```
1163+
1164+
---
1165+
10071166
## Data Models
10081167

10091168
All API responses are parsed into type-safe dataclasses defined in `comdirect_client.models`:

comdirect_api.feature

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,104 @@ Feature: Comdirect API Client Library
416416
And the library should provide method get_token_expiry() returning Optional[datetime]
417417
And all async methods should be properly typed with return types
418418

419+
# ============================================================================
420+
# Persistent Client Usage (Architecture)
421+
# ============================================================================
422+
423+
Scenario: Client should be kept alive for background token refresh
424+
Given the library is initialized with token_storage_path
425+
When the user completes authentication successfully
426+
Then the client should start a background asyncio refresh task
427+
And the background task should refresh tokens 120 seconds before expiry
428+
And the user should NOT destroy the client after each operation
429+
And the library should log "INFO: Token refresh task started"
430+
And consecutive API calls should reuse the same client instance
431+
And tokens should auto-refresh without user intervention
432+
433+
Scenario: Destroying client cancels background refresh
434+
Given the user has an authenticated client with running refresh task
435+
When the user calls client.close() or destroys the client
436+
Then the background refresh task should be cancelled
437+
And the library should log "INFO: Token refresh task cancelled"
438+
And tokens will expire after ~10 minutes without refresh
439+
And subsequent operations will require new TAN approval
440+
441+
Scenario: Token storage enables session recovery across restarts
442+
Given the library is initialized with token_storage_path="/path/to/tokens.json"
443+
And a previous session saved valid tokens to storage
444+
When a new client instance is created
445+
Then the library should automatically load tokens from storage
446+
And the library should log "INFO: Tokens restored from storage (expires: ...)"
447+
And the background refresh task should start automatically
448+
And no TAN approval should be required if tokens are still valid
449+
450+
# ============================================================================
451+
# Timezone-Aware Token Expiry (UTC)
452+
# ============================================================================
453+
454+
Scenario: Token expiry is stored with UTC timezone
455+
Given the library refreshes or obtains new tokens
456+
When the token expiry is calculated from expires_in
457+
Then the expiry datetime should be timezone-aware (UTC)
458+
And the library should use datetime.now(timezone.utc) for current time
459+
And token storage should persist expiry with timezone info (+00:00)
460+
461+
Scenario: Token expiry comparison uses UTC timezone
462+
Given the library has stored tokens with timezone-aware expiry
463+
When the library checks if tokens are expired
464+
Then the comparison should use datetime.now(timezone.utc)
465+
And tokens stored in local time zones should be handled correctly
466+
And the library should not be affected by container timezone settings
467+
468+
Scenario: Loading legacy tokens without timezone assumes UTC
469+
Given the library loads tokens from storage
470+
And the stored token_expiry has no timezone info (naive datetime)
471+
When the library parses the token_expiry
472+
Then the library should assume the datetime is in UTC
473+
And the library should add UTC timezone info to the datetime
474+
And token expiry comparisons should work correctly
475+
476+
# ============================================================================
477+
# TAN Status Callback
478+
# ============================================================================
479+
480+
Scenario: Register TAN status callback for real-time monitoring
481+
Given the library is initialized
482+
When the user registers a TAN status callback function via register_tan_status_callback()
483+
Then the callback should be stored internally
484+
And the callback should receive (status, data) parameters
485+
And status values should be: 'requested', 'pending', 'approved', 'timeout'
486+
487+
Scenario: TAN status callback invoked when TAN is requested
488+
Given a TAN status callback is registered
489+
When the library creates a TAN challenge (Step 3)
490+
Then the callback should be invoked with status='requested'
491+
And data should contain: tan_type, challenge_id, timeout_seconds
492+
And the library should log the TAN challenge creation
493+
494+
Scenario: TAN status callback invoked during polling
495+
Given a TAN status callback is registered
496+
And the library is polling for TAN approval
497+
When the TAN status is still PENDING
498+
Then the callback should be invoked with status='pending' every 10 seconds
499+
And data should contain: tan_type, timeout_seconds, elapsed_seconds, remaining_seconds
500+
501+
Scenario: TAN status callback invoked on approval
502+
Given a TAN status callback is registered
503+
And the library is polling for TAN approval
504+
When the TAN is approved by the user
505+
Then the callback should be invoked with status='approved'
506+
And data should contain: tan_type, elapsed_seconds
507+
And the library should proceed with session activation
508+
509+
Scenario: TAN status callback invoked on timeout
510+
Given a TAN status callback is registered
511+
And the library is polling for TAN approval
512+
When 60 seconds elapse without TAN approval
513+
Then the callback should be invoked with status='timeout'
514+
And data should contain: tan_type, timeout_seconds
515+
And the library should raise TANTimeoutError
516+
419517
# ============================================================================
420518
# Integration Scenarios
421519
# ============================================================================

0 commit comments

Comments
 (0)