Skip to content

Commit 7d72d7c

Browse files
committed
Merge branch '5-support-rate-limiting-headers' - Fix rate limiting implementation
Rate limits are now correctly enforced per certificate instead of per X-IBM-Client-Id
2 parents c2bc36b + 8530ed0 commit 7d72d7c

File tree

5 files changed

+173
-8
lines changed

5 files changed

+173
-8
lines changed

.openapi-generator/templates/ApiClient.mustache

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ class ApiClient extends \GuzzleHttp\Client
4545
protected bool $mockMode = false;
4646
private RateLimiter $rateLimiter;
4747
48+
/**
49+
* Path to the certificate file used for mTLS authentication.
50+
*/
51+
private string $certFilePath = '';
52+
53+
/**
54+
* Cached certificate fingerprint for rate limiting.
55+
*/
56+
private ?string $certFingerprint = null;
57+
4858
/**
4959
* Initialize the API client with configuration, certificate validation, and a rate limiter.
5060
*
@@ -87,6 +97,8 @@ class ApiClient extends \GuzzleHttp\Client
8797
}
8898
}
8999

100+
$this->certFilePath = $config['cert'][0];
101+
90102
if (\array_key_exists('debug', $config) === false) {
91103
$config['debug'] = \Ease\Shared::cfg('API_DEBUG', false);
92104
}
@@ -231,9 +243,65 @@ class ApiClient extends \GuzzleHttp\Client
231243
return substr(self::sourceString().'#'.time(), -59);
232244
}
233245

246+
/**
247+
* Calculate and return the SHA1 fingerprint of the certificate used for mTLS.
248+
*
249+
* This fingerprint is used as the client identifier for rate limiting,
250+
* as rate limits in the API are tied to the certificate, not to X-IBM-Client-Id.
251+
*
252+
* @throws \Exception if unable to read or parse the certificate
253+
*
254+
* @return string SHA1 fingerprint of the certificate in lowercase hex format
255+
*/
256+
public function getCertificateFingerprint(): string
257+
{
258+
if ($this->certFingerprint !== null) {
259+
return $this->certFingerprint;
260+
}
261+
262+
if (empty($this->certFilePath)) {
263+
throw new \Exception('Certificate file path not set');
264+
}
265+
266+
$certContent = file_get_contents($this->certFilePath);
267+
268+
if ($certContent === false) {
269+
throw new \Exception('Unable to read certificate file: '.$this->certFilePath);
270+
}
271+
272+
$certs = [];
273+
274+
if (openssl_pkcs12_read($certContent, $certs, $this->getConfig()['cert'][1]) === false) {
275+
throw new \Exception('Unable to parse PKCS12 certificate: '.openssl_error_string());
276+
}
277+
278+
if (!isset($certs['cert'])) {
279+
throw new \Exception('Certificate not found in PKCS12 file');
280+
}
281+
282+
$certResource = openssl_x509_read($certs['cert']);
283+
284+
if ($certResource === false) {
285+
throw new \Exception('Unable to read X509 certificate: '.openssl_error_string());
286+
}
287+
288+
$fingerprint = openssl_x509_fingerprint($certResource, 'sha1');
289+
290+
if ($fingerprint === false) {
291+
throw new \Exception('Unable to calculate certificate fingerprint: '.openssl_error_string());
292+
}
293+
294+
$this->certFingerprint = strtolower($fingerprint);
295+
296+
return $this->certFingerprint;
297+
}
298+
234299
/**
235300
* Send an HTTP request while enforcing and updating client rate limits.
236301
*
302+
* Rate limits are enforced per certificate (not per X-IBM-Client-Id).
303+
* The certificate fingerprint is used as the client identifier.
304+
*
237305
* @param \Psr\Http\Message\RequestInterface $request the HTTP request to send
238306
* @param array $options Request options to apply to the transfer. See \GuzzleHttp\RequestOptions.
239307
*
@@ -244,7 +312,9 @@ class ApiClient extends \GuzzleHttp\Client
244312
*/
245313
public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): \Psr\Http\Message\ResponseInterface
246314
{
247-
$this->rateLimiter->checkBeforeRequest($this->xIBMClientId);
315+
$certFingerprint = $this->getCertificateFingerprint();
316+
317+
$this->rateLimiter->checkBeforeRequest($certFingerprint);
248318
249319
$response = parent::send($request, $options);
250320
@@ -254,7 +324,7 @@ class ApiClient extends \GuzzleHttp\Client
254324
255325
if ($statusCode === 429) { // 429 Too Many Requests
256326
if ($this->rateLimiter->isWaitMode()) {
257-
$this->rateLimiter->checkBeforeRequest($this->xIBMClientId);
327+
$this->rateLimiter->checkBeforeRequest($certFingerprint);
258328
$response = parent::send($request, $options);
259329
260330
$this->updateRateLimitsFromResponse($response);
@@ -268,13 +338,21 @@ class ApiClient extends \GuzzleHttp\Client
268338
return $response;
269339
}
270340

341+
/**
342+
* Update rate limits from API response headers.
343+
*
344+
* Uses the certificate fingerprint as the client identifier for storing rate limits.
345+
*
346+
* @param \Psr\Http\Message\ResponseInterface $response the HTTP response containing rate limit headers
347+
*/
271348
private function updateRateLimitsFromResponse(\Psr\Http\Message\ResponseInterface $response): void
272349
{
273350
if ($response->hasHeader('x-ratelimit-remaining-second') && $response->hasHeader('x-ratelimit-remaining-day')) {
274351
$remainingSecond = (int) $response->getHeaderLine('x-ratelimit-remaining-second');
275352
$remainingDay = (int) $response->getHeaderLine('x-ratelimit-remaining-day');
276353
$timestamp = time();
277-
$this->rateLimiter->handleRateLimits($this->xIBMClientId, $remainingSecond, $remainingDay, $timestamp);
354+
$certFingerprint = $this->getCertificateFingerprint();
355+
$this->rateLimiter->handleRateLimits($certFingerprint, $remainingSecond, $remainingDay, $timestamp);
278356
}
279357
}
280358
}

WARP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ composer test\nnpm test
5252
- **Main Components**: Core functionality and modules
5353
- **Configuration**: Configuration files and environment variables
5454
- **Integration Points**: External services and dependencies
55+
- **Rate Limiting**: API rate limits are enforced per certificate (mTLS client certificate), not per X-IBM-Client-Id. The library automatically calculates the SHA1 fingerprint of the certificate and uses it as the client identifier for rate limit tracking.
5556

5657
## Common Tasks
5758

debian/changelog

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
php-vitexsoftware-rbczpremiumapi (1.5.0) UNRELEASED; urgency=medium
1+
php-vitexsoftware-rbczpremiumapi (1.5.1) UNRELEASED; urgency=medium
2+
3+
* Fix rate limiting to use certificate fingerprint instead of X-IBM-Client-Id
4+
- Rate limits are enforced per certificate as per API documentation
5+
- Added getCertificateFingerprint() method to calculate SHA1 fingerprint
6+
- Updated ApiClient to use certificate fingerprint for rate limit tracking
7+
8+
-- vitex <info@vitexsoftware.cz> Thu, 21 Nov 2025 14:54:00 +0100
9+
10+
php-vitexsoftware-rbczpremiumapi (1.5.0) unstable; urgency=medium
211

312
* Rate limiting support added
413

debian/composer.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"name": "deb/rbczpremiumapi",
33
"description": "An Core of PHP Framework for Ease of writing Applications (debianized)",
4-
"version": "1.4.1",
54
"authors": [
65
{
76
"name": "Vítězslav Dvořák",

lib/ApiClient.php

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ class ApiClient extends \GuzzleHttp\Client
4545
protected bool $mockMode = false;
4646
private RateLimiter $rateLimiter;
4747

48+
/**
49+
* Path to the certificate file used for mTLS authentication.
50+
*/
51+
private string $certFilePath = '';
52+
53+
/**
54+
* Cached certificate fingerprint for rate limiting.
55+
*/
56+
private ?string $certFingerprint = null;
57+
4858
/**
4959
* Initialize the API client with configuration, certificate validation, and a rate limiter.
5060
*
@@ -87,6 +97,8 @@ public function __construct(array $config = [])
8797
}
8898
}
8999

100+
$this->certFilePath = $config['cert'][0];
101+
90102
if (\array_key_exists('debug', $config) === false) {
91103
$config['debug'] = \Ease\Shared::cfg('API_DEBUG', false);
92104
}
@@ -231,9 +243,65 @@ public static function getxRequestId()
231243
return substr(self::sourceString().'#'.time(), -59);
232244
}
233245

246+
/**
247+
* Calculate and return the SHA1 fingerprint of the certificate used for mTLS.
248+
*
249+
* This fingerprint is used as the client identifier for rate limiting,
250+
* as rate limits in the API are tied to the certificate, not to X-IBM-Client-Id.
251+
*
252+
* @throws \Exception if unable to read or parse the certificate
253+
*
254+
* @return string SHA1 fingerprint of the certificate in lowercase hex format
255+
*/
256+
public function getCertificateFingerprint(): string
257+
{
258+
if ($this->certFingerprint !== null) {
259+
return $this->certFingerprint;
260+
}
261+
262+
if (empty($this->certFilePath)) {
263+
throw new \Exception('Certificate file path not set');
264+
}
265+
266+
$certContent = file_get_contents($this->certFilePath);
267+
268+
if ($certContent === false) {
269+
throw new \Exception('Unable to read certificate file: '.$this->certFilePath);
270+
}
271+
272+
$certs = [];
273+
274+
if (openssl_pkcs12_read($certContent, $certs, $this->getConfig()['cert'][1]) === false) {
275+
throw new \Exception('Unable to parse PKCS12 certificate: '.openssl_error_string());
276+
}
277+
278+
if (!isset($certs['cert'])) {
279+
throw new \Exception('Certificate not found in PKCS12 file');
280+
}
281+
282+
$certResource = openssl_x509_read($certs['cert']);
283+
284+
if ($certResource === false) {
285+
throw new \Exception('Unable to read X509 certificate: '.openssl_error_string());
286+
}
287+
288+
$fingerprint = openssl_x509_fingerprint($certResource, 'sha1');
289+
290+
if ($fingerprint === false) {
291+
throw new \Exception('Unable to calculate certificate fingerprint: '.openssl_error_string());
292+
}
293+
294+
$this->certFingerprint = strtolower($fingerprint);
295+
296+
return $this->certFingerprint;
297+
}
298+
234299
/**
235300
* Send an HTTP request while enforcing and updating client rate limits.
236301
*
302+
* Rate limits are enforced per certificate (not per X-IBM-Client-Id).
303+
* The certificate fingerprint is used as the client identifier.
304+
*
237305
* @param \Psr\Http\Message\RequestInterface $request the HTTP request to send
238306
* @param array $options Request options to apply to the transfer. See \GuzzleHttp\RequestOptions.
239307
*
@@ -244,7 +312,9 @@ public static function getxRequestId()
244312
*/
245313
public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): \Psr\Http\Message\ResponseInterface
246314
{
247-
$this->rateLimiter->checkBeforeRequest($this->xIBMClientId);
315+
$certFingerprint = $this->getCertificateFingerprint();
316+
317+
$this->rateLimiter->checkBeforeRequest($certFingerprint);
248318

249319
$response = parent::send($request, $options);
250320

@@ -254,7 +324,7 @@ public function send(\Psr\Http\Message\RequestInterface $request, array $options
254324

255325
if ($statusCode === 429) { // 429 Too Many Requests
256326
if ($this->rateLimiter->isWaitMode()) {
257-
$this->rateLimiter->checkBeforeRequest($this->xIBMClientId);
327+
$this->rateLimiter->checkBeforeRequest($certFingerprint);
258328
$response = parent::send($request, $options);
259329

260330
$this->updateRateLimitsFromResponse($response);
@@ -268,13 +338,21 @@ public function send(\Psr\Http\Message\RequestInterface $request, array $options
268338
return $response;
269339
}
270340

341+
/**
342+
* Update rate limits from API response headers.
343+
*
344+
* Uses the certificate fingerprint as the client identifier for storing rate limits.
345+
*
346+
* @param \Psr\Http\Message\ResponseInterface $response the HTTP response containing rate limit headers
347+
*/
271348
private function updateRateLimitsFromResponse(\Psr\Http\Message\ResponseInterface $response): void
272349
{
273350
if ($response->hasHeader('x-ratelimit-remaining-second') && $response->hasHeader('x-ratelimit-remaining-day')) {
274351
$remainingSecond = (int) $response->getHeaderLine('x-ratelimit-remaining-second');
275352
$remainingDay = (int) $response->getHeaderLine('x-ratelimit-remaining-day');
276353
$timestamp = time();
277-
$this->rateLimiter->handleRateLimits($this->xIBMClientId, $remainingSecond, $remainingDay, $timestamp);
354+
$certFingerprint = $this->getCertificateFingerprint();
355+
$this->rateLimiter->handleRateLimits($certFingerprint, $remainingSecond, $remainingDay, $timestamp);
278356
}
279357
}
280358
}

0 commit comments

Comments
 (0)