Skip to content

Commit 677daea

Browse files
committed
Version 0.6.0.0
1 parent 9e9752c commit 677daea

5 files changed

Lines changed: 219 additions & 50 deletions

File tree

Api/.NET API.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<AssemblyName>KoenZomers.Tado.Api</AssemblyName>
88
<RootNamespace>KoenZomers.Tado.Api</RootNamespace>
99
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
10-
<Version>0.5.4.0</Version>
10+
<Version>0.6.0.0</Version>
1111
<Authors>Koen Zomers</Authors>
1212
<Description>API in .NET 9 to communicate with a Tado home heating/cooling system</Description>
1313
<PackageProjectUrl>https://github.com/KoenZomers/TadoApi</PackageProjectUrl>

Api/Controllers/Http.cs

Lines changed: 78 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class Http : Base
2727
/// <summary>
2828
/// HttpClient to use for network communications towards the Tado API
2929
/// </summary>
30-
private HttpClient? TadoHttpClient;
30+
private readonly HttpClient? TadoHttpClient;
3131

3232
/// <summary>
3333
/// Access to the application configuration
@@ -59,6 +59,8 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
5959
/// <param name="maximumRetries">If provided, in case of a non 2xx response, it will retry the call at most the amount configured through this parameter. Optional, if not provided, it will endlessly retry. If <paramref name="retryIntervalIfFailed"/> has not been set, this is being ignored.</param>
6060
/// <param name="token">Optional token. If provided, it will be used to authenticate the request. If omitted, it will send the request anonymously.</param>
6161
/// <returns>Object of type T with the parsed response</returns>
62+
/// <exception cref="Exceptions.RequestThrottledException">Thrown when the request is getting throttled</exception>
63+
/// <exception cref="Exceptions.RequestThrottledException">Thrown when the request is getting throttled</exception>
6264
public async Task<T?> PostMessageGetResponse<T>(Uri uri, QueryStringBuilder queryBuilder, short? retryIntervalIfFailed = null, short? maximumRetries = null, Models.Authentication.Token? token = null)
6365
{
6466
ArgumentNullException.ThrowIfNull(uri);
@@ -86,35 +88,41 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
8688
request.Content = content;
8789

8890
retryCount++;
91+
HttpResponseMessage response;
92+
Stream responseContentStream;
8993
try
9094
{
91-
var response = await TadoHttpClient.SendAsync(request);
92-
var responseContentStream = await response.Content.ReadAsStreamAsync();
95+
response = await TadoHttpClient.SendAsync(request);
96+
responseContentStream = await response.Content.ReadAsStreamAsync();
97+
}
98+
catch (Exception ex)
99+
{
100+
throw new Exceptions.RequestFailedException(uri, ex);
101+
}
93102

94-
// Verify if the request was successful (response status 200-299)
95-
if (!response.IsSuccessStatusCode)
96-
{
97-
// Request was not successful
98-
if (!retryIntervalIfFailed.HasValue || (maximumRetries.HasValue && retryCount >= maximumRetries.Value))
99-
{
100-
// We should not retry or we have reached the maximum number of retries
101-
throw new Exceptions.RequestFailedException(uri);
102-
}
103-
104-
// Pause and retry
105-
Logger.LogDebug($"Request failed with status code {response.StatusCode} for URI {uri}. Retrying in {retryIntervalIfFailed.Value} seconds...");
106-
Thread.Sleep(TimeSpan.FromSeconds(retryIntervalIfFailed.Value));
107-
}
108-
else
103+
if (response.StatusCode == HttpStatusCode.TooManyRequests)
104+
{
105+
// Request was throttled
106+
throw new Exceptions.RequestThrottledException(uri, response);
107+
}
108+
else if (!response.IsSuccessStatusCode)
109+
{
110+
// Request was not successful
111+
if (!retryIntervalIfFailed.HasValue || (maximumRetries.HasValue && retryCount >= maximumRetries.Value))
109112
{
110-
// Request was successful
111-
var responseEntity = await JsonSerializer.DeserializeAsync<T>(responseContentStream);
112-
return responseEntity;
113+
// We should not retry or we have reached the maximum number of retries
114+
throw new Exceptions.RequestFailedException(uri);
113115
}
116+
117+
// Pause and retry
118+
Logger.LogDebug($"Request failed with status code {response.StatusCode} for URI {uri}. Retrying in {retryIntervalIfFailed.Value} seconds...");
119+
Thread.Sleep(TimeSpan.FromSeconds(retryIntervalIfFailed.Value));
114120
}
115-
catch (Exception ex)
121+
else
116122
{
117-
throw new Exceptions.RequestFailedException(uri, ex);
123+
// Request was successful (response status 200-299)
124+
var responseEntity = await JsonSerializer.DeserializeAsync<T>(responseContentStream);
125+
return responseEntity;
118126
}
119127
} while (true);
120128
}
@@ -127,6 +135,7 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
127135
/// <param name="expectedHttpStatusCode">The expected Http result status code. Optional. If provided and the webservice returns a different response, the return type will be NULL to indicate failure.</param>
128136
/// <param name="token">Optional token. If provided, it will be used to authenticate the request. If omitted, it will send the request anonymously.</param>
129137
/// <returns>Typed entity with the result from the webservice</returns>
138+
/// <exception cref="Exceptions.RequestThrottledException">Thrown when the request is getting throttled</exception>
130139
public async Task<T?> GetMessageReturnResponse<T>(Uri uri, HttpStatusCode? expectedHttpStatusCode = null, Models.Authentication.Token? token = null)
131140
{
132141
ArgumentNullException.ThrowIfNull(uri);
@@ -141,24 +150,30 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
141150
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
142151
}
143152

153+
HttpResponseMessage response;
144154
try
145155
{
146156
// Request the response from the webservice
147-
using var response = await TadoHttpClient.SendAsync(request);
148-
149-
if (!expectedHttpStatusCode.HasValue || expectedHttpStatusCode.HasValue && response != null && response.StatusCode == expectedHttpStatusCode.Value)
150-
{
151-
var responseContentStream = await response.Content.ReadAsStreamAsync();
152-
var responseEntity = await JsonSerializer.DeserializeAsync<T>(responseContentStream);
153-
return responseEntity;
154-
}
155-
return default;
157+
response = await TadoHttpClient.SendAsync(request);
156158
}
157159
catch (Exception ex)
158160
{
159-
// Request was not successful. throw an exception
161+
// Request was not successful, throw an exception
160162
throw new Exceptions.RequestFailedException(uri, ex);
161163
}
164+
165+
if (response.StatusCode == HttpStatusCode.TooManyRequests)
166+
{
167+
// Request was throttled
168+
throw new Exceptions.RequestThrottledException(uri, response);
169+
}
170+
else if (!expectedHttpStatusCode.HasValue || expectedHttpStatusCode.HasValue && response != null && response.StatusCode == expectedHttpStatusCode.Value)
171+
{
172+
var responseContentStream = await response.Content.ReadAsStreamAsync();
173+
var responseEntity = await JsonSerializer.DeserializeAsync<T>(responseContentStream);
174+
return responseEntity;
175+
}
176+
return default;
162177
}
163178

164179
/// <summary>
@@ -171,6 +186,7 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
171186
/// <param name="expectedHttpStatusCode">The expected Http result status code. Optional. If provided and the webservice returns a different response, the return type will be NULL to indicate failure.</param>
172187
/// <param name="token">Optional token. If provided, it will be used to authenticate the request. If omitted, it will send the request anonymously.</param>
173188
/// <returns>Typed entity with the result from the webservice</returns>
189+
/// <exception cref="Exceptions.RequestThrottledException">Thrown when the request is getting throttled</exception>
174190
public async Task<T?> SendMessageReturnResponse<T>(string bodyText, HttpMethod httpMethod, Uri uri, HttpStatusCode? expectedHttpStatusCode = null, Models.Authentication.Token? token = null)
175191
{
176192
ArgumentNullException.ThrowIfNull(uri);
@@ -195,24 +211,30 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
195211
request.Content = content;
196212
}
197213

214+
HttpResponseMessage response;
198215
try
199216
{
200217
// Request the response from the webservice
201-
using var response = await TadoHttpClient.SendAsync(request);
202-
203-
if (!expectedHttpStatusCode.HasValue || expectedHttpStatusCode.HasValue && response != null && response.StatusCode == expectedHttpStatusCode.Value)
204-
{
205-
var responseContentStream = await response.Content.ReadAsStreamAsync();
206-
var responseEntity = await JsonSerializer.DeserializeAsync<T>(responseContentStream);
207-
return responseEntity;
208-
}
209-
return default;
218+
response = await TadoHttpClient.SendAsync(request);
210219
}
211220
catch (Exception ex)
212221
{
213222
// Request was not successful. throw an exception
214223
throw new Exceptions.RequestFailedException(uri, ex);
215224
}
225+
226+
if (response.StatusCode == HttpStatusCode.TooManyRequests)
227+
{
228+
// Request was throttled
229+
throw new Exceptions.RequestThrottledException(uri, response);
230+
}
231+
else if (!expectedHttpStatusCode.HasValue || expectedHttpStatusCode.HasValue && response != null && response.StatusCode == expectedHttpStatusCode.Value)
232+
{
233+
var responseContentStream = await response.Content.ReadAsStreamAsync();
234+
var responseEntity = await JsonSerializer.DeserializeAsync<T>(responseContentStream);
235+
return responseEntity;
236+
}
237+
return default;
216238
}
217239

218240
/// <summary>
@@ -224,6 +246,7 @@ public Http(IHttpClientFactory httpClientFactory, IOptionsMonitor<Configuration.
224246
/// <param name="expectedHttpStatusCode">The expected Http result status code. Optional. If provided and the webservice returns a different response, the return type will be false to indicate failure.</param>
225247
/// <param name="token">Optional token. If provided, it will be used to authenticate the request. If omitted, it will send the request anonymously.</param>
226248
/// <returns>Boolean indicating if the request was successful</returns>
249+
/// <exception cref="Exceptions.RequestThrottledException">Thrown when the request is getting throttled</exception>
227250
public async Task<bool> SendMessage(string bodyText, HttpMethod httpMethod, Uri uri, HttpStatusCode? expectedHttpStatusCode = null, Models.Authentication.Token? token = null)
228251
{
229252
ArgumentNullException.ThrowIfNull(uri);
@@ -247,20 +270,27 @@ public async Task<bool> SendMessage(string bodyText, HttpMethod httpMethod, Uri
247270
request.Content = content;
248271
}
249272

273+
HttpResponseMessage response;
250274
try
251275
{
252276
// Request the response from the webservice
253-
using var response = await TadoHttpClient.SendAsync(request);
254-
if (!expectedHttpStatusCode.HasValue || expectedHttpStatusCode.HasValue && response != null && response.StatusCode == expectedHttpStatusCode.Value)
255-
{
256-
return true;
257-
}
258-
return false;
277+
response = await TadoHttpClient.SendAsync(request);
259278
}
260279
catch (Exception ex)
261280
{
262281
// Request was not successful. throw an exception
263282
throw new Exceptions.RequestFailedException(uri, ex);
264283
}
284+
285+
if (response.StatusCode == HttpStatusCode.TooManyRequests)
286+
{
287+
// Request was throttled
288+
throw new Exceptions.RequestThrottledException(uri, response);
289+
}
290+
else if (!expectedHttpStatusCode.HasValue || expectedHttpStatusCode.HasValue && response != null && response.StatusCode == expectedHttpStatusCode.Value)
291+
{
292+
return true;
293+
}
294+
return false;
265295
}
266296
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
namespace KoenZomers.Tado.Api.Exceptions
2+
{
3+
/// <summary>
4+
/// Exception thrown when a request is being throttled (HTTP 429 response)
5+
/// </summary>
6+
public class RequestThrottledException : Exception
7+
{
8+
/// <summary>
9+
/// Uri that was called
10+
/// </summary>
11+
public Uri Uri { get; private set; }
12+
13+
/// <summary>
14+
/// The rate limit policy name (e.g., "perday")
15+
/// </summary>
16+
public string? RateLimitPolicyName { get; private set; }
17+
18+
/// <summary>
19+
/// The quota limit for the rate limit policy (e.g., 20000 requests per day)
20+
/// </summary>
21+
public int? RateLimitQuota { get; private set; }
22+
23+
/// <summary>
24+
/// The time window for the rate limit policy in seconds (e.g., 86400 for daily)
25+
/// </summary>
26+
public int? RateLimitWindow { get; private set; }
27+
28+
/// <summary>
29+
/// The remaining requests allowed in the current window
30+
/// </summary>
31+
public int? RemainingRequests { get; private set; }
32+
33+
/// <summary>
34+
/// The time in seconds until the rate limit resets
35+
/// </summary>
36+
public int? ResetTimeSeconds { get; private set; }
37+
38+
/// <summary>
39+
/// Instantiates a new instance of the <see cref="RequestThrottledException"/> class.
40+
/// </summary>
41+
/// <param name="uri">Uri that was being called</param>
42+
/// <param name="httpResponseMessage">Http Response Message. Optional.</param>
43+
/// <param name="innerException">Exception raised while making the request. Optional.</param>
44+
public RequestThrottledException(Uri uri, HttpResponseMessage? httpResponseMessage = null, Exception? innerException = null) : base($"The request to {uri} failed because of throttling", innerException)
45+
{
46+
Uri = uri;
47+
48+
if (httpResponseMessage != null)
49+
{
50+
ParseRateLimitHeaders(httpResponseMessage);
51+
}
52+
}
53+
54+
/// <summary>
55+
/// Parses the RateLimit-Policy and RateLimit headers from the HTTP response
56+
/// </summary>
57+
/// <param name="httpResponseMessage">The HTTP response message containing the headers</param>
58+
private void ParseRateLimitHeaders(HttpResponseMessage httpResponseMessage)
59+
{
60+
// Parse RateLimit-Policy header: "perday";q=20000;w=86400
61+
if (httpResponseMessage.Headers.TryGetValues("RateLimit-Policy", out var policyValues))
62+
{
63+
var policyHeader = policyValues.FirstOrDefault();
64+
if (!string.IsNullOrEmpty(policyHeader))
65+
{
66+
ParseRateLimitPolicy(policyHeader);
67+
}
68+
}
69+
70+
// Parse RateLimit header: "perday";r=0;t=7082
71+
if (httpResponseMessage.Headers.TryGetValues("RateLimit", out var rateLimitValues))
72+
{
73+
var rateLimitHeader = rateLimitValues.FirstOrDefault();
74+
if (!string.IsNullOrEmpty(rateLimitHeader))
75+
{
76+
ParseRateLimit(rateLimitHeader);
77+
}
78+
}
79+
}
80+
81+
/// <summary>
82+
/// Parses the RateLimit-Policy header to extract policy name, quota, and window
83+
/// Format: "policy_name";q=quota;w=window_seconds
84+
/// </summary>
85+
/// <param name="policyHeader">The RateLimit-Policy header value</param>
86+
private void ParseRateLimitPolicy(string policyHeader)
87+
{
88+
var parts = policyHeader.Split(';');
89+
90+
// First part is the policy name (remove quotes)
91+
if (parts.Length > 0)
92+
{
93+
RateLimitPolicyName = parts[0].Trim('"');
94+
}
95+
96+
// Parse remaining parts for quota (q) and window (w)
97+
foreach (var part in parts.Skip(1))
98+
{
99+
var trimmedPart = part.Trim();
100+
if (trimmedPart.StartsWith("q=") && int.TryParse(trimmedPart.Substring(2), out var quota))
101+
{
102+
RateLimitQuota = quota;
103+
}
104+
else if (trimmedPart.StartsWith("w=") && int.TryParse(trimmedPart.Substring(2), out var window))
105+
{
106+
RateLimitWindow = window;
107+
}
108+
}
109+
}
110+
111+
/// <summary>
112+
/// Parses the RateLimit header to extract remaining requests and reset time
113+
/// Format: "policy_name";r=remaining;t=reset_seconds
114+
/// </summary>
115+
/// <param name="rateLimitHeader">The RateLimit header value</param>
116+
private void ParseRateLimit(string rateLimitHeader)
117+
{
118+
var parts = rateLimitHeader.Split(';');
119+
120+
// Parse parts for remaining requests (r) and reset time (t)
121+
foreach (var part in parts.Skip(1)) // Skip policy name
122+
{
123+
var trimmedPart = part.Trim();
124+
if (trimmedPart.StartsWith("r=") && int.TryParse(trimmedPart.Substring(2), out var remaining))
125+
{
126+
RemainingRequests = remaining;
127+
}
128+
else if (trimmedPart.StartsWith("t=") && int.TryParse(trimmedPart.Substring(2), out var resetTime))
129+
{
130+
ResetTimeSeconds = resetTime;
131+
}
132+
}
133+
}
134+
}
135+
}

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ This library compiled for .NET 9 will allow you to easily communicate with the T
99

1010
## Version History
1111

12+
0.6.0.0 - released September 17, 2025
13+
14+
- Added handling for throttling since Tado started to roll out their new throttling. More info [here](https://community.home-assistant.io/t/tado-rate-limiting-api-calls/928751). In the old code it would just return empty lists when throttled, now it will throw a specific exception which holds all the details about the thottling, such as when it will be reset and what your daily quota is based on your subscription with Tado..
15+
1216
0.5.4.0 - released July 10, 2025
1317

1418
- Fixed an issue with GetZoneState not working

appsettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"DefaultApiTimeoutSeconds": 30,
2020

2121
// The Tado API home ID to use for requests
22-
"TadoHomeId": 12345
22+
"TadoHomeId": 165523
2323
},
2424
"Logging": {
2525
"LogLevel": {

0 commit comments

Comments
 (0)