Skip to content

Commit 5b4bb51

Browse files
committed
Implemented Token-Refresh strategy
1 parent f09b51e commit 5b4bb51

2 files changed

Lines changed: 143 additions & 28 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ This channel contains values polled from SENEC App-API.
307307
### **WORK IN PROGRESS**
308308
- API uses its own backoff settings when polling. You can only configure delay between polls. Instead we are using strategy used by: AWS SDK, Google Cloud SDK, Stripe API client, Kubernetes controllers or Distributed message brokers to prevent: retry storms, thundering herd, burst collapse after outage recovery, adapter lockups or permanent dead loops. This leads to: IF (SENEC API down for 2 hours, or Token refresh fails 20 times, or 429 rate limiting kicks in, or Internet drops temporarily) ? (Never dies, never overlaps, never floods API, always recovers)
309309
- API polling no longer honors retries-setting. It will just keep backing off exponentially if errors persist -> we keep trying until you stop the adapter.
310+
- Using Token-Refresh strategy. No unnecessary logins anymore.
310311
- 401 won't throw warning anymore
311312
- ReAuth shouldn't stop polling anymore
312313

main.js

Lines changed: 142 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,13 @@ class Senec extends utils.Adapter {
114114
maxConcurrency: 6,
115115
});
116116

117-
this.refreshPromise = null;
118117
this.currentToken = null;
119-
this.authRetryCount = 0;
118+
this.refreshToken = null;
119+
this.tokenExpiresAt = 0;
120+
121+
this.timerTokenRefresh = null;
122+
this.tokenFailureCount = 0;
123+
this.refreshPromise = null;
120124

121125
this.apiPollRunning = false;
122126
this.lastHeavyUpdate = 0;
@@ -137,6 +141,7 @@ class Senec extends utils.Adapter {
137141

138142
try {
139143
await this.checkConfig();
144+
140145
if (this.config.lala_use) {
141146
this.log.info("Usage of lala.cgi (local) configured.");
142147
await this.initPollSettings();
@@ -148,22 +153,25 @@ class Senec extends utils.Adapter {
148153
} else {
149154
this.log.warn("Usage of lala.cgi (local) not configured. Only polling SENEC App API if configured.");
150155
}
156+
151157
if (this.config.api_use) {
152158
this.log.info("Usage of SENEC App API configured.");
153-
apiConnected = await this.senecLogin();
159+
apiConnected = await this.startTokenManager();
154160
if (apiConnected != null) {
155-
await this.pollSenecApi(0);
161+
await this.pollSenecApi();
156162
}
157163
} else {
158164
this.log.warn(
159165
"Usage of SENEC App API not configured. Only polling appliance via local network if configured.",
160166
);
161167
}
168+
162169
if (lalaConnected || apiConnected) {
163170
this.setState("info.connection", true, true);
164171
} else {
165172
this.log.error("Neither local connection nor API connection configured. Please check config!");
166173
}
174+
167175
if (this.config.control_active) {
168176
this.log.info("Active appliance control (local) activated!");
169177
await this.subscribeStatesAsync("control.*"); // subscribe on all state changes in control.
@@ -262,6 +270,9 @@ class Senec extends utils.Adapter {
262270
if (this.timerAPI) {
263271
clearTimeout(this.timerAPI);
264272
}
273+
if (this.timerTokenRefresh) {
274+
clearTimeout(this.timerTokenRefresh);
275+
}
265276
this.log.info("cleaned everything up...");
266277
this.setState("info.connection", false, true);
267278
callback();
@@ -489,6 +500,26 @@ class Senec extends utils.Adapter {
489500
}
490501
}
491502

503+
async startTokenManager() {
504+
try {
505+
// No refresh token at all → full login
506+
if (!this.refreshToken) {
507+
this.log.info("🔐 No refresh token present. Performing full login...");
508+
const token = await this.senecLogin();
509+
return !!token;
510+
}
511+
512+
// We have a refresh token → try refresh
513+
this.log.info("🔐 Trying initial token refresh...");
514+
await this.refreshTokenSingleFlight();
515+
return true;
516+
} catch (error) {
517+
this.log.warn(`⚠️ Initial refresh failed. Falling back to full login... ${error.message}`);
518+
const token = await this.senecLogin();
519+
return !!token;
520+
}
521+
}
522+
492523
async senecLogin() {
493524
this.log.info("🔄 Start Senec API Login Flow...");
494525
jar = new CookieJar();
@@ -508,7 +539,7 @@ class Senec extends utils.Adapter {
508539
const pageRes = await api_client.get(`${CONFIG.authUrl}?${authParams}`, { jar });
509540
let actionUrl = extractFormAction(pageRes.data);
510541
if (!actionUrl) {
511-
throw new Error("Login-Formular URL nicht gefunden.");
542+
throw new Error("Login-Form URL not found.");
512543
}
513544

514545
const postForm = (url, data) =>
@@ -553,8 +584,8 @@ class Senec extends utils.Adapter {
553584
if (!redirectLocation) {
554585
throw new Error(
555586
loginRes.status === 200
556-
? "Login fehlgeschlagen (Kein Redirect)."
557-
: `Login unerwarteter Status: ${loginRes.status}`,
587+
? "Login failed: no redirect."
588+
: `Login unexpected State: ${loginRes.status}`,
558589
);
559590
}
560591

@@ -578,41 +609,113 @@ class Senec extends utils.Adapter {
578609
);
579610

580611
this.currentToken = tokenRes.data.access_token;
581-
this.log.info("✅ API Login erfolgreich.");
612+
this.refreshToken = tokenRes.data.refresh_token;
613+
const expiresIn = tokenRes.data.expires_in || 600; // fallback 10 min
614+
this.tokenExpiresAt = Date.now() + expiresIn * 1000;
615+
616+
this.log.info("✅ API Login successful.");
617+
this.scheduleTokenRefresh();
582618
return this.currentToken;
583619
} catch (e) {
584620
this.log.error(`❌ Login Error: ${e.message}`);
585621
return null;
586622
}
587623
}
588624

625+
scheduleTokenRefresh() {
626+
if (!this.tokenExpiresAt || unloaded) {
627+
return;
628+
}
629+
630+
const safetyMargin = 60 * 1000; // refresh 60s before expiry
631+
const now = Date.now();
632+
633+
let delay = this.tokenExpiresAt - now - safetyMargin;
634+
if (delay < 5000) {
635+
delay = 5000; // minimum 5s
636+
}
637+
638+
if (this.timerTokenRefresh) {
639+
clearTimeout(this.timerTokenRefresh);
640+
}
641+
642+
if (!unloaded) {
643+
this.log.debug(`🔐 Scheduling token refresh in ${(delay / 1000).toFixed(0)}s`);
644+
this.timerTokenRefresh = setTimeout(() => {
645+
this.refreshTokenSingleFlight().catch((err) => {
646+
this.log.error(`Token background refresh failed: ${err.message}`);
647+
});
648+
}, delay);
649+
}
650+
}
651+
589652
async refreshTokenSingleFlight() {
653+
if (!this.refreshToken) {
654+
this.log.debug("No refresh token available — skipping refresh.");
655+
return this.senecLogin();
656+
}
657+
590658
if (this.refreshPromise) {
591-
this.log.debug("🔐 Waiting for ongoing token refresh...");
592659
return this.refreshPromise;
593660
}
594661

595662
this.refreshPromise = (async () => {
596663
try {
597-
this.log.info("🔐 Refreshing token...");
664+
this.log.debug("🔐 Refreshing API token...");
665+
666+
const response = await api_client.post(
667+
CONFIG.tokenUrl,
668+
new URLSearchParams({
669+
grant_type: "refresh_token",
670+
client_id: CONFIG.clientId,
671+
refresh_token: this.refreshToken,
672+
}),
673+
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } },
674+
);
598675

599-
const newToken = await this.senecLogin();
600-
if (!newToken) {
601-
throw new Error("Token refresh failed");
602-
}
676+
const data = response.data;
677+
678+
this.currentToken = data.access_token;
679+
this.refreshToken = data.refresh_token || this.refreshToken;
680+
681+
const expiresIn = data.expires_in || 600; // fallback 10min
682+
this.tokenExpiresAt = Date.now() + expiresIn * 1000;
603683

604-
this.currentToken = newToken;
605-
this.authRetryCount = 0;
684+
this.tokenFailureCount = 0;
606685

607-
return newToken;
686+
this.log.info(`✅ Token refreshed. Expires in ${expiresIn}s`);
687+
688+
this.scheduleTokenRefresh();
608689
} catch (err) {
609-
this.authRetryCount++;
690+
const status = err.response?.status;
691+
const errorCode = err.response?.data?.error;
692+
693+
this.log.error(`❌ Token refresh failed: ${err.message}`);
694+
695+
// If refresh token invalid → fallback to full login
696+
if (errorCode === "invalid_grant" || status === 400) {
697+
this.log.warn("⚠️ Refresh token invalid. Performing full login.");
698+
await this.senecLogin();
699+
return;
700+
}
701+
702+
this.tokenFailureCount++;
610703

611-
const delay = Math.min(60000, 2000 * Math.pow(2, this.authRetryCount));
704+
const baseDelay = 5000;
705+
let retryDelay = computeBackoffDelay(baseDelay, this.tokenFailureCount);
706+
retryDelay = Math.min(retryDelay, 60000);
612707

613-
this.log.error(`🔐 Token refresh failed. Backing off ${delay / 1000}s`);
708+
if (this.timerTokenRefresh) {
709+
clearTimeout(this.timerTokenRefresh);
710+
}
711+
712+
if (!unloaded) {
713+
this.log.warn(`🔁 Retrying token refresh in ${(retryDelay / 1000).toFixed(0)}s`);
714+
this.timerTokenRefresh = setTimeout(() => {
715+
this.refreshTokenSingleFlight().catch(() => {});
716+
}, retryDelay);
717+
}
614718

615-
await new Promise((res) => setTimeout(res, delay));
616719
throw err;
617720
} finally {
618721
this.refreshPromise = null;
@@ -755,6 +858,12 @@ class Senec extends utils.Adapter {
755858
*/
756859
async apiGet(url, config = {}) {
757860
return this.apiQueue.add(async () => {
861+
// Proactive expiry check
862+
if (Date.now() >= this.tokenExpiresAt - 30000) {
863+
this.log.debug("🔐 Token close to expiry. Refreshing before request...");
864+
await this.refreshTokenSingleFlight();
865+
}
866+
758867
const maxAttempts = 3;
759868
for (let attempt = 0; attempt < maxAttempts; attempt++) {
760869
try {
@@ -1091,10 +1200,12 @@ class Senec extends utils.Adapter {
10911200
await this.evalPoll(obj, "", "");
10921201

10931202
retry = 0;
1094-
if (unloaded) {
1095-
return;
1203+
if (!unloaded) {
1204+
this.timer = setTimeout(() => this.pollSenecLocal(isHighPrio, retry), interval);
1205+
this.log.debug(
1206+
`⏱ Next local poll (highPrio=${isHighPrio}) scheduled in ${(interval / 1000).toFixed(0)}s`,
1207+
);
10961208
}
1097-
this.timer = setTimeout(() => this.pollSenecLocal(isHighPrio, retry), interval);
10981209
} catch (error) {
10991210
if (retry == this.config.retries && this.config.retries < 999) {
11001211
this.log.error(
@@ -1112,10 +1223,13 @@ class Senec extends utils.Adapter {
11121223
error
11131224
})`,
11141225
);
1115-
this.timer = setTimeout(
1116-
() => this.pollSenecLocal(isHighPrio, retry),
1117-
interval * this.config.retrymultiplier * retry,
1118-
);
1226+
if (!unloaded) {
1227+
const delay = interval * this.config.retrymultiplier * retry;
1228+
this.timer = setTimeout(() => this.pollSenecLocal(isHighPrio, retry), delay);
1229+
this.log.debug(
1230+
`⏱ Next local poll (highPrio=${isHighPrio}) scheduled in ${(delay / 1000).toFixed(0)}s`,
1231+
);
1232+
}
11191233
}
11201234
}
11211235
}

0 commit comments

Comments
 (0)