Skip to content

Commit 2144a1e

Browse files
authored
v0.2.0 (#3)
* go over readme and license * minor refactor * test and fix race condition handling * update changelog * remove predis dependency, update changelog
1 parent 9415484 commit 2144a1e

File tree

8 files changed

+166
-53
lines changed

8 files changed

+166
-53
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,10 @@ All notable changes to `swiftmade/playback` will be documented in this file
55
## 0.1.0 - 2020-09-23
66

77
- initial release
8+
9+
## 0.2.0 - 2020-09-24
10+
11+
- Fix lock key
12+
- Add a test case for the handling of race condition
13+
- Minor refactor and documentation improvements
14+
- Remove predis dependency

LICENSE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) Ahmet Özisik
3+
Copyright (c) Swiftmade OÜ
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@
44
[![Build Status](https://img.shields.io/travis/swiftmade/playback/master.svg?style=flat-square)](https://travis-ci.org/swiftmade/playback)
55
[![Total Downloads](https://img.shields.io/packagist/dt/swiftmade/playback.svg?style=flat-square)](https://packagist.org/packages/swiftmade/playback)
66

7-
Are you developing a sensitive API where calling the same endpoint twice can cause catastrophy? 💥
7+
Do you need idempotent endpoints in Laravel? This package handles just that.
88

9-
Here's how Stripe handles it:
10-
- https://stripe.com/blog/idempotency
9+
What's even idempotency? What should you care?
1110
- https://stripe.com/docs/api/idempotent_requests
1211

13-
If you said "oh yes, that's smart" then read on. Because we implemented that for Laravel.
14-
1512
## Features
1613

1714
- Apply it to a single route, or apply to your whole API...
@@ -21,62 +18,67 @@ If you said "oh yes, that's smart" then read on. Because we implemented that for
2118
- Doesn't remember the response if there was a validation error (4xx). So it's safe to retry.
2219
- Prevents race conditions using Laravel's support for cache locks.
2320

24-
2521
## Installation
2622

27-
You can install the package via composer:
23+
> 💡 Currently, we only support Laravel 8.x.
24+
25+
1. You can install the package via composer:
2826

2927
```bash
3028
composer require swiftmade/playback
3129
```
3230

33-
To customize the configuration:
31+
2. Publish the config file (optional):
3432

3533
```bash
3634
php artisan vendor:publish --provider="Swiftmade\Playback\PlaybackServiceProvider"
3735
```
3836

39-
💡 **Add the playback cache store**
37+
3. Add the playback cache store
4038

41-
Open `config/cache.php` and add a new item to the `stores` array.
39+
Open `config/cache.php` and add a new store.
4240

4341
```php
4442
'stores' => [
4543
// ... other stores
4644
'playback' => [
4745
'driver' => 'redis',
48-
// We strongly recommend using a different
49-
// connection (another redis DB) in production.
46+
// 👇🏻 Caution!
47+
// You probably don't want to use the cache connection in production.
48+
// Playback cache can grow to a huge size for busy applications.
49+
// Make sure your redis instance is ready.
5050
'connection' => 'cache',
5151
],
5252
]
5353
```
5454

5555
💡 **Apply the middleware**
5656

57-
Just apply the `Swiftmade\Swiftmade\Playback` middleware to your endpoints. You can see how here:
57+
Just apply the `Swiftmade\Playback\Playback` middleware to your endpoints. There are many ways of doing it, so here's a link to the docs:
5858

5959
- https://laravel.com/docs/8.x/middleware
6060

6161
## Use
6262

63-
+ The client must supply an idempotency key. Otherwise, the middleware won't execute.
63+
Even when middleware is active on a route, it's business as usual unless the client sends an `Idempotency-Key` in their request header.
6464

6565
```
6666
Idempotency-Key: preferrably uuid4, but anything flies
6767
```
6868

69-
+ The server will look the key up. If there's a match, exactly that response will be returned.
69+
Once Playback detects a key, it'll look it up in redis. If found, it will serve the same response **without hitting your controller action again**. You can know that happened by looking at the response headers. If it contains `Is-Playback`, you know it's just a repetition.
7070

71-
You can know that the response is a playback from the response headers:
71+
If the key is not found during the lookup, a race begins. The first request to acquire the redis lock gets to process the request and cache the response. Any other unlucky requests that land during that time window will return `425` status code.
7272

73-
```
74-
Is-Playback: your idempotency key
75-
```
73+
#### Errors:
74+
75+
+ **400 Bad Request**
76+
If you get back status `400`, it means your request was not identical to the cached one. It's the client's responsibility to repeat the exact same request. This is also why another user can't steal a response just by stealing/guessing the idempotency key. The cookies/authentication token would be different, which fails the signature check.
7677

77-
+ If you get back status `400`, it means the following request was not identical with the cached one. Just use another idempotency key, if you mean to execute a fresh request.
78+
+ **425 Too Early**
79+
If you get this error, it means you retried too fast after your initial attempt. Don't panic and try again a second later or so. It's perfectly safe to do so!
7880

79-
+ If you get back status `425`, it means you retried too fast. It's perfectly safe to try again later.
81+
🚨 Pro tip: If your controller action returns 4xx or 3xx status code, Playback won't cache the response. It's your responsibility to ensure no side effects take place (or they are rolled back) if a validation fails, a related db record was not found, etc and therefore the response status is 4xx or 3xx.
8082

8183
### Testing
8284

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
],
1818
"require": {
1919
"php": "^7.3",
20-
"illuminate/support": "^8.0",
21-
"predis/predis": "^1.1"
20+
"illuminate/support": "^8.0"
2221
},
2322
"require-dev": {
2423
"orchestra/testbench": "^6.0",
25-
"phpunit/phpunit": "^9.0"
24+
"phpunit/phpunit": "^9.0",
25+
"spatie/async": "^1.5"
2626
},
2727
"autoload": {
2828
"psr-4": {

src/Playback.php

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,37 +20,35 @@ public function handle(Request $request, Closure $next)
2020
return $next($request);
2121
}
2222

23-
// The key doesn't exist yet... Allow processing the request
24-
if (! ($recordedResponse = Recorder::find($key))) {
25-
26-
// Start a race. The winner gets to process the request.
27-
return Recorder::race(
28-
$key,
29-
// Winner
30-
function () use ($key, $request, $next) {
31-
// Now, actually process the request
32-
$response = $next($request);
23+
// If cached, play back the response.
24+
if ($recordedResponse = Recorder::find($key)) {
25+
return $recordedResponse->playback(
26+
$this->requestHash($request)
27+
);
28+
}
3329

34-
if ($this->isResponseRecordable($response)) {
35-
Recorder::save(
36-
$key,
37-
$this->requestHash($request),
38-
$response
39-
);
40-
}
30+
// The key doesn't exist yet... Start a race.
31+
return Recorder::race(
32+
$key,
33+
// Winner gets to process the request
34+
function () use ($key, $request, $next) {
35+
$response = $next($request);
4136

42-
return $response;
43-
},
44-
// Loser
45-
function () {
46-
return abort(425, 'Your request is still being processed.'
47-
. 'You retried too early. You can safely retry later.');
37+
if ($this->isResponseRecordable($response)) {
38+
Recorder::save(
39+
$key,
40+
$this->requestHash($request),
41+
$response
42+
);
4843
}
49-
);
50-
}
5144

52-
return $recordedResponse->playback(
53-
$this->requestHash($request)
45+
return $response;
46+
},
47+
// Too early for the losers
48+
function () {
49+
return abort(425, 'Your request is still being processed.'
50+
. 'You retried too early. You can safely retry later.');
51+
}
5452
);
5553
}
5654

src/Recorder.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public static function find($key): ?RecordedResponse
1818

1919
public static function race($key, Closure $winner, Closure $loser)
2020
{
21-
$lock = static::store()->lock($key);
21+
$lock = static::store()->lock(static::getRedisLockKey($key));
2222

2323
if ($lock->get()) {
2424
try {
@@ -62,6 +62,11 @@ protected static function getRedisKey($key)
6262
return 'ir.' . $key;
6363
}
6464

65+
protected static function getRedisLockKey($key)
66+
{
67+
return static::getRedisKey($key) . '.l';
68+
}
69+
6570
public static function flush()
6671
{
6772
self::store()->flush();

tests/PlaybackTest.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Swiftmade\Playback\Tests;
44

5+
use Spatie\Async\Pool;
56
use Swiftmade\Playback\Recorder;
67
use Orchestra\Testbench\TestCase;
78
use Swiftmade\Playback\PlaybackServiceProvider;
@@ -251,4 +252,98 @@ public function it_does_not_record_get_request()
251252
$response2->getContent()
252253
);
253254
}
255+
256+
/**
257+
* @test
258+
*/
259+
public function the_first_request_wins()
260+
{
261+
$headers = [
262+
config('playback.header_name') => uniqid(),
263+
];
264+
265+
$pool = Pool::create()
266+
->concurrency(3)
267+
->timeout(6);
268+
269+
$pool[] = async(function () use ($headers) {
270+
$app = new self();
271+
$app->setUpBeforeClass();
272+
$app->setUp();
273+
$response = $app->post('slow', [], $headers);
274+
275+
return [
276+
'id' => 'request1',
277+
'response' => [
278+
'status' => $response->baseResponse->getStatusCode(),
279+
'headers' => $response->baseResponse->headers->all(),
280+
'body' => $response->getContent(),
281+
],
282+
];
283+
});
284+
285+
$pool[] = async(function () use ($headers) {
286+
usleep(150 * 1000);
287+
288+
$app = new self();
289+
$app->setUpBeforeClass();
290+
$app->setUp();
291+
$response = $app->post('slow', [], $headers);
292+
293+
return [
294+
'id' => 'request2',
295+
'response' => [
296+
'status' => $response->baseResponse->getStatusCode(),
297+
'headers' => $response->baseResponse->headers->all(),
298+
'body' => $response->getContent(),
299+
],
300+
];
301+
});
302+
$pool[] = async(function () use ($headers) {
303+
usleep(3000 * 1000);
304+
305+
$app = new self();
306+
$app->setUpBeforeClass();
307+
$app->setUp();
308+
$response = $app->post('slow', [], $headers);
309+
310+
return [
311+
'id' => 'request3',
312+
'response' => [
313+
'status' => $response->baseResponse->getStatusCode(),
314+
'headers' => $response->baseResponse->headers->all(),
315+
'body' => $response->getContent(),
316+
],
317+
];
318+
});
319+
320+
$responses = collect(await($pool))->pluck('response', 'id');
321+
322+
$response1 = $responses->get('request1');
323+
$response2 = $responses->get('request2');
324+
$response3 = $responses->get('request3');
325+
326+
$this->assertEquals(200, $response1['status']);
327+
$this->assertArrayNotHasKey(
328+
strtolower(config('playback.playback_header_name')),
329+
$response1['headers']
330+
);
331+
332+
$this->assertEquals(425, $response2['status']);
333+
$this->assertArrayNotHasKey(
334+
strtolower(config('playback.playback_header_name')),
335+
$response2['headers']
336+
);
337+
338+
$this->assertEquals(200, $response3['status']);
339+
$this->assertArrayHasKey(
340+
strtolower(config('playback.playback_header_name')),
341+
$response3['headers']
342+
);
343+
344+
$this->assertEquals(
345+
$response1['body'],
346+
$response3['body']
347+
);
348+
}
254349
}

tests/Support/routes.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,9 @@
2828
'validation' => 'ok',
2929
]);
3030
})->middleware(Playback::class);
31+
32+
Route::post('slow', function (Request $request) {
33+
sleep(2);
34+
35+
return microtime();
36+
})->middleware(Playback::class);

0 commit comments

Comments
 (0)