Skip to content

Commit ed19aa8

Browse files
csumana19Copilot
andauthored
Wire up rate limit headers via HTTP/REST client wrappers (#669)
* Add rate limit header wrapper for all API responses Introduce AdoHttpClient and AdoRestClient wrappers that automatically extract rate limit headers and attach them as a rateLimit property on response objects. This avoids needing to regenerate all API clients. - Add RateLimitUtils.ts with shared extractRateLimitHeaders function - Add AdoHttpClientBases.ts with AdoHttpClient and AdoRestClient wrappers - Update ClientApiBases.ts and WebApi.ts to use new wrappers - Add unit test for rate limit header extraction - Bump version to 15.1.4 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: only set rateLimit when headers present, handle collections, add 429 and no-header tests - Only assign target.rateLimit when at least one rate limit header exists - Attach rateLimit to response.result.value for collection responses - Add unit test for 429 error path with rateLimit on error object - Add unit test verifying rateLimit is undefined when no headers present - Remove redundant IRestResponse cast Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: bump to 15.2.0, attach rateLimit to response object, remove res.message duplication - Bump version to 15.2.0 (minor, per reviewer feedback) - Attach rateLimit to IRestResponse object for primitive result types - Remove redundant extractRateLimitHeaders on res.message in AdoHttpClient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary rateLimit attachment to response wrapper object All ADO APIs return JSON objects, so attaching to response.result is sufficient. No need for the confusing fallback on the IRestResponse itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4d78472 commit ed19aa8

8 files changed

Lines changed: 205 additions & 34 deletions

File tree

api/AdoHttpClientBases.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
import { extractRateLimitHeaders } from './RateLimitUtils';
5+
6+
import * as rm from 'typed-rest-client/RestClient';
7+
import * as hm from 'typed-rest-client/HttpClient';
8+
import { IHeaders, IHttpClientResponse } from 'typed-rest-client/Interfaces';
9+
10+
/**
11+
* AdoHttpClient that extracts rate limit headers and attaches them to the response object
12+
*/
13+
export class AdoHttpClient extends hm.HttpClient {
14+
public async request(verb: string, requestUrl: string, data: string | NodeJS.ReadableStream, headers: IHeaders): Promise<IHttpClientResponse> {
15+
const res = await super.request(verb, requestUrl, data, headers);
16+
17+
const resHeaders = res?.message?.headers;
18+
19+
extractRateLimitHeaders(resHeaders, res);
20+
21+
return res;
22+
}
23+
}
24+
25+
/**
26+
* AdoRestClient that extracts rate limit headers and attaches them to the response/result objects
27+
*/
28+
export class AdoRestClient extends rm.RestClient {
29+
protected async processResponse<T>(res: hm.HttpClientResponse, options: rm.IRequestOptions): Promise<rm.IRestResponse<T>> {
30+
const headers = res?.message?.headers;
31+
32+
try {
33+
const response = await super.processResponse(res, options);
34+
if (response && response.result && typeof response.result === 'object') {
35+
extractRateLimitHeaders(headers, response.result);
36+
// For collection responses (e.g., { count, value: [...] }), also attach to the array
37+
if (Array.isArray((response.result as any).value)) {
38+
extractRateLimitHeaders(headers, (response.result as any).value);
39+
}
40+
}
41+
return response as rm.IRestResponse<T>;
42+
} catch (err: any) {
43+
// Use the original response headers captured before super.processResponse,
44+
// because the base implementation may not populate err.responseHeaders
45+
// when the response body is not valid JSON (e.g., HTML error pages on 429).
46+
extractRateLimitHeaders(headers, err);
47+
if (err?.result && typeof err.result === 'object') {
48+
extractRateLimitHeaders(headers, err.result);
49+
}
50+
return Promise.reject(err);
51+
}
52+
}
53+
}

api/ClientApiBases.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import vsom = require('./VsoClient');
55
import VsoBaseInterfaces = require('./interfaces/common/VsoBaseInterfaces');
66
import serm = require('./Serialization');
7+
import AdoHttpClientBases = require('./AdoHttpClientBases');
8+
import { extractRateLimitHeaders } from './RateLimitUtils';
79
import * as rm from 'typed-rest-client/RestClient';
810
import * as hm from 'typed-rest-client/HttpClient';
911

@@ -20,8 +22,8 @@ export class ClientApiBase {
2022
constructor(baseUrl: string, handlers: VsoBaseInterfaces.IRequestHandler[], userAgent: string, options?: VsoBaseInterfaces.IRequestOptions) {
2123
this.baseUrl = baseUrl;
2224

23-
this.http = new hm.HttpClient(userAgent, handlers, options);
24-
this.rest = new rm.RestClient(userAgent, null, handlers, options);
25+
this.http = new AdoHttpClientBases.AdoHttpClient(userAgent, handlers, options);
26+
this.rest = new AdoHttpClientBases.AdoRestClient(userAgent, null, handlers, options);
2527

2628
this.vsoClient = new vsom.VsoClient(baseUrl, this.rest);
2729
this.userAgent = userAgent;
@@ -50,30 +52,6 @@ export class ClientApiBase {
5052
}
5153

5254
public extractRateLimitHeaders(headers: any, target: any): void {
53-
if (!headers || !target) {
54-
return;
55-
}
56-
57-
const rateLimit: VsoBaseInterfaces.RateLimit = {};
58-
59-
if (headers['x-ratelimit-resource']) {
60-
rateLimit.resource = headers['x-ratelimit-resource'];
61-
}
62-
if (headers['x-ratelimit-delay']) {
63-
rateLimit.delay = parseFloat(headers['x-ratelimit-delay']);
64-
}
65-
if (headers['x-ratelimit-limit']) {
66-
rateLimit.limit = parseInt(headers['x-ratelimit-limit'], 10);
67-
}
68-
if (headers['x-ratelimit-remaining']) {
69-
rateLimit.remaining = parseInt(headers['x-ratelimit-remaining'], 10);
70-
}
71-
if (headers['x-ratelimit-reset']) {
72-
rateLimit.reset = parseInt(headers['x-ratelimit-reset'], 10);
73-
}
74-
if (headers['retry-after']) {
75-
rateLimit.retryAfter = parseInt(headers['retry-after'], 10);
76-
}
77-
target.rateLimit = rateLimit;
55+
extractRateLimitHeaders(headers, target);
7856
}
7957
}

api/RateLimitUtils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
import VsoBaseInterfaces = require('./interfaces/common/VsoBaseInterfaces');
5+
6+
/**
7+
* Extracts rate limit headers from the provided headers and attaches them to the target object
8+
* @param headers - source headers
9+
* @param target - target object to attach rate limit info to
10+
*/
11+
export function extractRateLimitHeaders(headers: any, target: any): void {
12+
if (!headers || !target) {
13+
return;
14+
}
15+
16+
const rateLimit: VsoBaseInterfaces.RateLimit = {};
17+
let hasRateLimitHeader = false;
18+
19+
if (headers['x-ratelimit-resource']) {
20+
rateLimit.resource = headers['x-ratelimit-resource'];
21+
hasRateLimitHeader = true;
22+
}
23+
if (headers['x-ratelimit-delay']) {
24+
rateLimit.delay = parseFloat(headers['x-ratelimit-delay']);
25+
hasRateLimitHeader = true;
26+
}
27+
if (headers['x-ratelimit-limit']) {
28+
rateLimit.limit = parseInt(headers['x-ratelimit-limit'], 10);
29+
hasRateLimitHeader = true;
30+
}
31+
if (headers['x-ratelimit-remaining']) {
32+
rateLimit.remaining = parseInt(headers['x-ratelimit-remaining'], 10);
33+
hasRateLimitHeader = true;
34+
}
35+
if (headers['x-ratelimit-reset']) {
36+
rateLimit.reset = parseInt(headers['x-ratelimit-reset'], 10);
37+
hasRateLimitHeader = true;
38+
}
39+
if (headers['retry-after']) {
40+
rateLimit.retryAfter = parseInt(headers['retry-after'], 10);
41+
hasRateLimitHeader = true;
42+
}
43+
44+
if (hasRateLimitHeader) {
45+
target.rateLimit = rateLimit;
46+
}
47+
}

api/WebApi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import basicm = require('./handlers/basiccreds');
3737
import bearm = require('./handlers/bearertoken');
3838
import ntlmm = require('./handlers/ntlm');
3939
import patm = require('./handlers/personalaccesstoken');
40-
40+
import AdoHttpClientBases = require('./AdoHttpClientBases');
4141
import * as rm from 'typed-rest-client/RestClient';
4242
import vsom = require('./VsoClient');
4343
import lim = require("./interfaces/LocationsInterfaces");
@@ -172,7 +172,7 @@ export class WebApi {
172172
userAgent = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;
173173
}
174174
}
175-
this.rest = new rm.RestClient(userAgent, null, [this.authHandler], this.options);
175+
this.rest = new AdoHttpClientBases.AdoRestClient(userAgent, null, [this.authHandler], this.options);
176176
this.vsoClient = new vsom.VsoClient(defaultUrl, this.rest);
177177
}
178178

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "azure-devops-node-api",
33
"description": "Node client for Azure DevOps and TFS REST APIs",
4-
"version": "15.1.3",
4+
"version": "15.2.0",
55
"main": "./WebApi.js",
66
"types": "./WebApi.d.ts",
77
"scripts": {

test/package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/units/tests.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import os = require('os');
55
import vsom = require('../../_build/VsoClient');
66
import WebApi = require('../../_build/WebApi');
77
import * as rm from 'typed-rest-client/RestClient';
8-
import { ApiResourceLocation } from '../../_build/interfaces/common/VsoBaseInterfaces';
8+
import { ApiResourceLocation, RateLimit } from '../../_build/interfaces/common/VsoBaseInterfaces';
99
import semver = require('semver');
1010

1111
describe('VSOClient Units', function () {
@@ -316,6 +316,99 @@ describe('WebApi Units', function () {
316316
assert.equal(userAgent, myWebApi.rest.client.userAgent, 'User agent should be: ' + userAgent);
317317
});
318318

319+
it('AdoRestClient attaches rate limit headers to responses', async () => {
320+
// Arrange
321+
const userAgent: string = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;
322+
323+
nock('https://dev.azure.com', {
324+
reqheaders: {
325+
'accept': 'application/json',
326+
'user-agent': userAgent,
327+
},
328+
})
329+
.defaultReplyHeaders({
330+
'x-ratelimit-resource': 'core',
331+
'x-ratelimit-delay': '0.5',
332+
'x-ratelimit-limit': '1000',
333+
'x-ratelimit-remaining': '999',
334+
'x-ratelimit-reset': '1625078400',
335+
'retry-after': '1',
336+
})
337+
.get('/test')
338+
.reply(200, { success: true });
339+
340+
const myWebApi: WebApi.WebApi = new WebApi.WebApi('https://dev.azure.com', WebApi.getBasicHandler('user', 'password'));
341+
342+
// Act
343+
const response = await myWebApi.rest.get<{ success: boolean, rateLimit?: RateLimit }>('https://dev.azure.com/test');
344+
// Assert
345+
assert(response.result?.success === true, 'Response body should indicate success');
346+
assert(response.result?.rateLimit, 'Response should have rateLimit property');
347+
assert.equal(response.result.rateLimit?.resource, 'core', 'Rate limit resource should be "core"');
348+
assert.equal(response.result.rateLimit?.delay, 0.5, 'Rate limit delay should be 0.5');
349+
assert.equal(response.result.rateLimit?.limit, 1000, 'Rate limit limit should be 1000');
350+
assert.equal(response.result.rateLimit?.remaining, 999, 'Rate limit remaining should be 999');
351+
assert.equal(response.result.rateLimit?.reset, 1625078400, 'Rate limit reset should be 1625078400');
352+
assert.equal(response.result.rateLimit?.retryAfter, 1, 'Rate limit retryAfter should be 1');
353+
});
354+
355+
it('AdoRestClient attaches rate limit headers to error on 429', async () => {
356+
// Arrange
357+
const userAgent: string = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;
358+
359+
nock('https://dev.azure.com', {
360+
reqheaders: {
361+
'accept': 'application/json',
362+
'user-agent': userAgent,
363+
},
364+
})
365+
.defaultReplyHeaders({
366+
'x-ratelimit-resource': 'core',
367+
'x-ratelimit-delay': '5.0',
368+
'x-ratelimit-limit': '1000',
369+
'x-ratelimit-remaining': '0',
370+
'x-ratelimit-reset': '1625078400',
371+
'retry-after': '30',
372+
})
373+
.get('/blocked')
374+
.reply(429, { message: 'Too Many Requests' });
375+
376+
const myWebApi: WebApi.WebApi = new WebApi.WebApi('https://dev.azure.com', WebApi.getBasicHandler('user', 'password'));
377+
378+
// Act & Assert
379+
try {
380+
await myWebApi.rest.get<any>('https://dev.azure.com/blocked');
381+
assert.fail('Should have thrown on 429');
382+
} catch (err: any) {
383+
assert(err.rateLimit, 'Error should have rateLimit property');
384+
assert.equal(err.rateLimit.resource, 'core', 'Rate limit resource should be "core"');
385+
assert.equal(err.rateLimit.remaining, 0, 'Rate limit remaining should be 0');
386+
assert.equal(err.rateLimit.retryAfter, 30, 'Rate limit retryAfter should be 30');
387+
}
388+
});
389+
390+
it('AdoRestClient does not attach rateLimit when no rate limit headers present', async () => {
391+
// Arrange
392+
const userAgent: string = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;
393+
394+
nock('https://dev.azure.com', {
395+
reqheaders: {
396+
'accept': 'application/json',
397+
'user-agent': userAgent,
398+
},
399+
})
400+
.get('/no-ratelimit')
401+
.reply(200, { data: 'hello' });
402+
403+
const myWebApi: WebApi.WebApi = new WebApi.WebApi('https://dev.azure.com', WebApi.getBasicHandler('user', 'password'));
404+
405+
// Act
406+
const response = await myWebApi.rest.get<{ data: string, rateLimit?: RateLimit }>('https://dev.azure.com/no-ratelimit');
407+
// Assert
408+
assert(response.result?.data === 'hello', 'Response body should have data');
409+
assert.equal(response.result?.rateLimit, undefined, 'rateLimit should not be present when no rate limit headers');
410+
});
411+
319412
it('sets the user agent correctly when request settings are not specified', async () => {
320413
const myWebApi: WebApi.WebApi = new WebApi.WebApi('https://microsoft.com', WebApi.getBasicHandler('user', 'password'), undefined);
321414
const userAgent: string = `${nodeApiName}/${nodeApiVersion} (${osName} ${osVersion})`;

0 commit comments

Comments
 (0)