patiently 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +35 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +67 -0
- data/CHANGELOG.md +18 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +187 -0
- data/Rakefile +8 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/patiently/configuration.rb +61 -0
- data/lib/patiently/errors.rb +12 -0
- data/lib/patiently/helpers.rb +81 -0
- data/lib/patiently/rspec.rb +12 -0
- data/lib/patiently/version.rb +5 -0
- data/lib/patiently.rb +24 -0
- data/media/logo.dark.shapes.svg +155 -0
- data/media/logo.dark.text.svg +125 -0
- data/media/logo.light.shapes.svg +145 -0
- data/media/logo.light.text.svg +115 -0
- data/media/makandra-with-bottom-margin.dark.svg +180 -0
- data/media/makandra-with-bottom-margin.light.svg +180 -0
- data/patiently.gemspec +36 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f738cd510ed2b9ff9b43ba29a3a72201f3c6a85332ff47af71f171bd7478d3ac
|
|
4
|
+
data.tar.gz: 6d5d64fbe90a9b14be3bc00480d53e79823441d4b194c14adcac7cc75ef5266b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2c85cbcda0754060cd80be043067a61fe21b8a4cd70a27722f3ddb433bb82cb7d96e158e0d273ce3c30aa96ddc0fc89fbae86ba1999a468c0e088093f26d753b
|
|
7
|
+
data.tar.gz: fe576c8eb91123d2c6de6b5dd1519ab0f3cdaa2fc3607bbe0899482ccf0fbb5aa5ad0b0e4da3e1657c6f118546a318f48774afa68c6d9c9ddf65d7f7bfac1b5f
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Tests
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
workflow_dispatch: {}
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
timeout-minutes: 5
|
|
15
|
+
continue-on-error: "${{ matrix.experimental || false }}"
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
ruby:
|
|
20
|
+
- "2.7"
|
|
21
|
+
- "3.0"
|
|
22
|
+
- "3.2"
|
|
23
|
+
- "3.4"
|
|
24
|
+
include:
|
|
25
|
+
- ruby: "head"
|
|
26
|
+
experimental: true
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- name: Install Ruby
|
|
30
|
+
uses: ruby/setup-ruby@v1
|
|
31
|
+
with:
|
|
32
|
+
ruby-version: "${{ matrix.ruby }}"
|
|
33
|
+
bundler-cache: true
|
|
34
|
+
- name: Run tests
|
|
35
|
+
run: bundle exec rake spec
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.7.8
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Conventions for working on the `patiently` gem. Read this before changing code,
|
|
4
|
+
dependencies, or CI.
|
|
5
|
+
|
|
6
|
+
## What this gem is
|
|
7
|
+
|
|
8
|
+
`patiently` retries a block until it stops raising (`patiently`) or returns
|
|
9
|
+
truthy (`patiently_until`, alias `patiently_wait_until`). It was extracted from a
|
|
10
|
+
`spec/support/patiently.rb` helper that used to be copy-pasted into many test
|
|
11
|
+
suites.
|
|
12
|
+
|
|
13
|
+
## Hard rules
|
|
14
|
+
|
|
15
|
+
- **No runtime dependencies.** Do not add RSpec, Capybara, Rails or
|
|
16
|
+
ActiveSupport as a runtime dependency. The gem must work in any Ruby project.
|
|
17
|
+
RSpec/Capybara are dev-only (for the self-tests).
|
|
18
|
+
- **Ruby >= 2.7.** Keep the code compatible with 2.7 syntax. `required_ruby_version`
|
|
19
|
+
in the gemspec is the source of truth.
|
|
20
|
+
- **Ask before committing** (repo-wide preference).
|
|
21
|
+
|
|
22
|
+
## Ruby versions & the lockfile
|
|
23
|
+
|
|
24
|
+
- `.ruby-version` pins **2.7.8** (our support floor). Locally you can use any
|
|
25
|
+
Ruby (`rbenv shell 2.7.8`, or just your default 3.x) — both are expected to pass.
|
|
26
|
+
- CI (`.github/workflows/test.yml`) runs a **matrix** across 2.7 / 3.0 / 3.2 / 3.4
|
|
27
|
+
and `head`. We do NOT use gemika/appraisals — one `Gemfile`, one `Gemfile.lock`.
|
|
28
|
+
- `Gemfile.lock` **is committed** and was generated under **Ruby 2.7.8 with
|
|
29
|
+
bundler 2.1.4** so `BUNDLED WITH` stays readable on every matrix Ruby. If you
|
|
30
|
+
change dependencies, regenerate the lock under the oldest supported Ruby:
|
|
31
|
+
`RBENV_VERSION=2.7.8 bundle _2.1.4_ install` — otherwise a newer bundler pin
|
|
32
|
+
breaks the 2.7 CI job.
|
|
33
|
+
|
|
34
|
+
## Running tests
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle exec rspec # or: bundle exec rake spec (the CI entrypoint)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Testing notes:
|
|
41
|
+
- Time-freeze detection is tested by **stubbing the private `monotonic_time`**
|
|
42
|
+
helper, not by mocking `Time`/`Process.clock_gettime` globally.
|
|
43
|
+
- Some examples wait **real** (sub-second) time on purpose; that is acceptable
|
|
44
|
+
here. Keep any real waits well under a second and stub `sleep` where timing
|
|
45
|
+
isn't the thing under test.
|
|
46
|
+
- Keep the suite dependency-light: no embedded Rails app, no nested RSpec runs.
|
|
47
|
+
|
|
48
|
+
## Public API & semantics (don't break without a version bump)
|
|
49
|
+
|
|
50
|
+
- Mix in with `include Patiently::Helpers`.
|
|
51
|
+
- Config lives on `Patiently.config` (`timeout`, `retry_intervals`,
|
|
52
|
+
`min_retries`, `max_retries`) or `Patiently.configure { |c| ... }`.
|
|
53
|
+
- `min_retries` / `max_retries` count **re-attempts** (invocations after the
|
|
54
|
+
first). `min_retries = 1` reproduces the original "try at least twice"
|
|
55
|
+
behavior. `max_retries = nil` means unlimited.
|
|
56
|
+
- `retry_intervals` is a backoff array; the last element is reused once exhausted.
|
|
57
|
+
- Errors: `Patiently::Error` (base) and `Patiently::FrozenInTime`. Do NOT reach
|
|
58
|
+
for Capybara's error classes.
|
|
59
|
+
- Optional RSpec glue is `require "patiently/rspec"` — keep it a thin
|
|
60
|
+
`config.include(Patiently::Helpers, type: :feature)` and nothing more.
|
|
61
|
+
|
|
62
|
+
## Packaging (house style, mirrors capybara-lockstep)
|
|
63
|
+
|
|
64
|
+
The gemspec ships **every tracked file except `spec/`, `test/`, `features/`** —
|
|
65
|
+
so `Gemfile`, `Gemfile.lock`, `.github/`, the gemspec and `bin/` are included in
|
|
66
|
+
the published `.gem` on purpose. `.gitignore` keeps IDE/build cruft (`.idea`,
|
|
67
|
+
`tmp/`, `pkg/`, `.rspec_status`) out of git and therefore out of the gem.
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Initial extraction of the `patiently` / `patiently_until` test helpers into a
|
|
10
|
+
standalone, dependency-free gem.
|
|
11
|
+
- `include Patiently::Helpers` to gain the `patiently` and `patiently_until`
|
|
12
|
+
helpers (`patiently_wait_until` is kept as an alias).
|
|
13
|
+
- Configurable defaults via `Patiently.config`: `timeout`, `retry_intervals`
|
|
14
|
+
(with backoff), `min_retries` and `max_retries`.
|
|
15
|
+
- Per-call timeout argument for both `patiently` and `patiently_until`.
|
|
16
|
+
- `Patiently::FrozenInTime` error raised when the monotonic clock does not
|
|
17
|
+
advance between retries (mocked/frozen time).
|
|
18
|
+
- Optional RSpec integration via `require "patiently/rspec"`.
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
patiently (1.0.0)
|
|
5
|
+
|
|
6
|
+
GEM
|
|
7
|
+
remote: https://rubygems.org/
|
|
8
|
+
specs:
|
|
9
|
+
diff-lcs (1.6.2)
|
|
10
|
+
rake (13.4.2)
|
|
11
|
+
rspec (3.13.2)
|
|
12
|
+
rspec-core (~> 3.13.0)
|
|
13
|
+
rspec-expectations (~> 3.13.0)
|
|
14
|
+
rspec-mocks (~> 3.13.0)
|
|
15
|
+
rspec-core (3.13.6)
|
|
16
|
+
rspec-support (~> 3.13.0)
|
|
17
|
+
rspec-expectations (3.13.5)
|
|
18
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
19
|
+
rspec-support (~> 3.13.0)
|
|
20
|
+
rspec-mocks (3.13.8)
|
|
21
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
22
|
+
rspec-support (~> 3.13.0)
|
|
23
|
+
rspec-support (3.13.7)
|
|
24
|
+
|
|
25
|
+
PLATFORMS
|
|
26
|
+
ruby
|
|
27
|
+
|
|
28
|
+
DEPENDENCIES
|
|
29
|
+
patiently!
|
|
30
|
+
rake (~> 13.0)
|
|
31
|
+
rspec (~> 3.0)
|
|
32
|
+
|
|
33
|
+
BUNDLED WITH
|
|
34
|
+
2.1.4
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henning Koch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<p>
|
|
2
|
+
<a href="https://makandra.de/">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="media/makandra-with-bottom-margin.light.svg">
|
|
5
|
+
<source media="(prefers-color-scheme: dark)" srcset="media/makandra-with-bottom-margin.dark.svg">
|
|
6
|
+
<img align="right" width="25%" alt="makandra" src="media/makandra-with-bottom-margin.light.svg">
|
|
7
|
+
</picture>
|
|
8
|
+
</a>
|
|
9
|
+
|
|
10
|
+
<picture>
|
|
11
|
+
<source media="(prefers-color-scheme: light)" srcset="media/logo.light.shapes.svg">
|
|
12
|
+
<source media="(prefers-color-scheme: dark)" srcset="media/logo.dark.shapes.svg">
|
|
13
|
+
<img width="200" alt="patiently" role="heading" aria-level="1" src="media/logo.light.shapes.svg">
|
|
14
|
+
</picture>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
`patiently` retries a block of code until it stops raising an exception (or
|
|
18
|
+
returns a truthy value). It is most useful in tests that need to wait for an
|
|
19
|
+
eventually-consistent condition — a background job to finish, an AJAX request to
|
|
20
|
+
update the DOM, a file to appear — without sprinkling `sleep` calls everywhere.
|
|
21
|
+
|
|
22
|
+
It has **no runtime dependencies**: it does not require RSpec, Capybara, Rails or
|
|
23
|
+
ActiveSupport.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
Add it to your `Gemfile` and run `bundle install`:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
gem "patiently"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Patiently does not depend on RSpec, but if you use it, wire the helpers into your
|
|
34
|
+
specs. Either require the bundled integration, which includes the helpers into
|
|
35
|
+
feature and system specs:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# spec/spec_helper.rb (or rails_helper.rb)
|
|
39
|
+
require "patiently/rspec"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
or include the helpers yourself with whatever scope you like:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
RSpec.configure do |config|
|
|
46
|
+
config.include(Patiently::Helpers, type: :feature)
|
|
47
|
+
config.include(Patiently::Helpers, type: :system)
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
Mix the helpers into any class:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
include Patiently::Helpers
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This gives you two methods.
|
|
60
|
+
|
|
61
|
+
### `patiently`
|
|
62
|
+
|
|
63
|
+
Runs the block and, if it raises, keeps retrying until it succeeds or the
|
|
64
|
+
patience window is exhausted. On success it returns the block's value; on failure
|
|
65
|
+
it re-raises the block's last exception.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
patiently do
|
|
69
|
+
expect(page).to have_content("Saved!")
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`patiently` retries on **any** exception (including non-`StandardError`s such as
|
|
74
|
+
RSpec's `ExpectationNotMetError`), which is what makes it work with test
|
|
75
|
+
assertions.
|
|
76
|
+
|
|
77
|
+
You can pass a custom timeout (in seconds) as the first argument, overriding the
|
|
78
|
+
global default:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
patiently(10) do
|
|
82
|
+
expect(page).to have_content("Slow import finished")
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `patiently_until`
|
|
87
|
+
|
|
88
|
+
Retries the block until it returns a truthy value, then returns `true`. If the
|
|
89
|
+
block is still falsey when the window elapses, it returns `false`. A *real*
|
|
90
|
+
exception raised inside the block still propagates.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
patiently_until { user.reload.confirmed? } # => true / false
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This is handy for building custom matchers that retry internally while keeping
|
|
97
|
+
their own failure message. It also accepts a custom timeout:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
patiently_until(10) { import.reload.done? }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`patiently_wait_until` is available as an alias for `patiently_until`.
|
|
104
|
+
|
|
105
|
+
### Nested blocks
|
|
106
|
+
|
|
107
|
+
When you call `patiently` (or `patiently_until`) while already inside a
|
|
108
|
+
`patiently` block on the same thread, the inner block simply runs once — only the
|
|
109
|
+
**outermost** block is retried. This prevents an inner retry loop from repeatedly
|
|
110
|
+
re-running expensive setup and lets the outer block drive the timing.
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
Configure global defaults via `Patiently.config`:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
Patiently.config.timeout = 5 # seconds before giving up
|
|
118
|
+
Patiently.config.retry_intervals = [0.05] # sleep durations between retries
|
|
119
|
+
Patiently.config.min_retries = 1 # always retry at least this often
|
|
120
|
+
Patiently.config.max_retries = nil # cap on retries (nil = unlimited)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Or use a block:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
Patiently.configure do |config|
|
|
127
|
+
config.timeout = 10
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Option | Default | Meaning |
|
|
132
|
+
| ----------------- | -------- | ------- |
|
|
133
|
+
| `timeout` | `5` | How long (in seconds) to keep retrying before giving up. |
|
|
134
|
+
| `retry_intervals` | `[0.05]` | Sleep durations (seconds) between retries. See "Backoff" below. |
|
|
135
|
+
| `min_retries` | `1` | The minimum number of *retries* performed before giving up, even if the timeout has already elapsed. |
|
|
136
|
+
| `max_retries` | `nil` | The maximum number of *retries* before giving up, regardless of the timeout. `nil` means unlimited. |
|
|
137
|
+
|
|
138
|
+
Both `min_retries` and `max_retries` count **retries** — re-invocations *after*
|
|
139
|
+
the first call. So `min_retries = 1` means the block runs at least twice before
|
|
140
|
+
`patiently` is allowed to give up.
|
|
141
|
+
|
|
142
|
+
`patiently` gives up (and re-raises the block's exception) when either:
|
|
143
|
+
|
|
144
|
+
- the timeout has elapsed **and** at least `min_retries` retries have happened, or
|
|
145
|
+
- `max_retries` retries have happened (when `max_retries` is set).
|
|
146
|
+
|
|
147
|
+
### Backoff
|
|
148
|
+
|
|
149
|
+
`retry_intervals` may be an array, used as a backoff schedule. The value at index
|
|
150
|
+
N is the sleep before the (N+1)-th retry; once the array is exhausted, its last
|
|
151
|
+
element is reused for all further retries:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
Patiently.config.retry_intervals = [0.05, 0.05, 0.05, 0.1]
|
|
155
|
+
# sleeps 0.05, 0.05, 0.05, then 0.1, 0.1, 0.1, ...
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Frozen time detection
|
|
159
|
+
|
|
160
|
+
If the monotonic clock does not advance between retries — which usually means time
|
|
161
|
+
has been mocked or frozen (Timecop, Rails' `travel`/`freeze_time`, …) —
|
|
162
|
+
`patiently` would loop forever. Instead it raises `Patiently::FrozenInTime` so you
|
|
163
|
+
notice and travel time explicitly.
|
|
164
|
+
|
|
165
|
+
## Errors
|
|
166
|
+
|
|
167
|
+
- `Patiently::Error` — base class for all errors raised by this gem.
|
|
168
|
+
- `Patiently::FrozenInTime` — raised when time appears to be frozen (see above).
|
|
169
|
+
|
|
170
|
+
## Development
|
|
171
|
+
|
|
172
|
+
After checking out the repo, run `bin/setup` to install dependencies, then run
|
|
173
|
+
`rake spec` to run the tests. You can also run `bin/console` for an interactive
|
|
174
|
+
prompt.
|
|
175
|
+
|
|
176
|
+
To release a new version, update the version number in
|
|
177
|
+
`lib/patiently/version.rb`, then run `bundle exec rake release`.
|
|
178
|
+
|
|
179
|
+
## Contributing
|
|
180
|
+
|
|
181
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
182
|
+
https://github.com/makandra/patiently.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
The gem is available as open source under the terms of the
|
|
187
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "patiently"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
require "irb"
|
|
11
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Patiently
|
|
4
|
+
# Holds the global defaults for {Patiently::Helpers#patiently}. Access the
|
|
5
|
+
# shared instance via +Patiently.config+.
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_TIMEOUT = 5
|
|
8
|
+
DEFAULT_RETRY_INTERVALS = [0.05].freeze
|
|
9
|
+
DEFAULT_MIN_RETRIES = 1
|
|
10
|
+
DEFAULT_MAX_RETRIES = nil # nil = unlimited
|
|
11
|
+
|
|
12
|
+
attr_writer :timeout, :retry_intervals, :min_retries, :max_retries
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
reset!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# How long (in seconds) `patiently` keeps retrying before giving up.
|
|
19
|
+
def timeout
|
|
20
|
+
@timeout.nil? ? DEFAULT_TIMEOUT : @timeout
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An array of sleep durations (in seconds) used between retries. The value
|
|
24
|
+
# at index N is used before the (N+1)-th retry; once the array is exhausted
|
|
25
|
+
# its last element is reused for all further retries. This allows a backoff,
|
|
26
|
+
# e.g. `[0.05, 0.05, 0.05, 0.1]`. A bare number is accepted and wrapped in
|
|
27
|
+
# an array.
|
|
28
|
+
def retry_intervals
|
|
29
|
+
intervals = @retry_intervals.nil? ? DEFAULT_RETRY_INTERVALS : @retry_intervals
|
|
30
|
+
Array(intervals)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The minimum number of *retries* (re-invocations after the first call)
|
|
34
|
+
# `patiently` performs before it is allowed to give up, even if the timeout
|
|
35
|
+
# has already elapsed. `1` reproduces the historical "try at least twice"
|
|
36
|
+
# behavior.
|
|
37
|
+
def min_retries
|
|
38
|
+
@min_retries.nil? ? DEFAULT_MIN_RETRIES : @min_retries
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The maximum number of *retries* `patiently` performs before giving up,
|
|
42
|
+
# regardless of the timeout. `nil` means unlimited.
|
|
43
|
+
def max_retries
|
|
44
|
+
@max_retries
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the sleep duration for the given (zero-based) retry index.
|
|
48
|
+
def retry_interval(retry_index)
|
|
49
|
+
intervals = retry_intervals
|
|
50
|
+
intervals[retry_index] || intervals.last
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Restores all defaults. Handy in test suites.
|
|
54
|
+
def reset!
|
|
55
|
+
@timeout = nil
|
|
56
|
+
@retry_intervals = nil
|
|
57
|
+
@min_retries = nil
|
|
58
|
+
@max_retries = nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Patiently
|
|
4
|
+
# Base class for all errors raised by Patiently.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the (monotonic) clock does not advance between retries, which
|
|
8
|
+
# usually means time has been mocked/frozen (e.g. via Timecop or Rails'
|
|
9
|
+
# `travel`/`freeze_time`). Retrying against a frozen clock would loop forever,
|
|
10
|
+
# so we fail loudly instead.
|
|
11
|
+
class FrozenInTime < Error; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Patiently
|
|
4
|
+
# Mix this into a class (or configure RSpec to include it) to gain the
|
|
5
|
+
# {#patiently} and {#patiently_until} helpers:
|
|
6
|
+
#
|
|
7
|
+
# include Patiently::Helpers
|
|
8
|
+
module Helpers
|
|
9
|
+
# Thread-local flag marking that we are already inside a `patiently` block.
|
|
10
|
+
# Nested blocks are run once and let the *outer* block do the retrying.
|
|
11
|
+
IN_PATIENTLY = :patiently_in_progress
|
|
12
|
+
|
|
13
|
+
# Internal sentinel used by {#patiently_until} to signal "not truthy yet".
|
|
14
|
+
RetryUntilTrue = Class.new(StandardError)
|
|
15
|
+
|
|
16
|
+
# Runs `block`, retrying it whenever it raises, until it succeeds or the
|
|
17
|
+
# patience window (timeout + min/max retries) is exhausted. Returns the
|
|
18
|
+
# block's return value on success; re-raises the last exception on failure.
|
|
19
|
+
#
|
|
20
|
+
# When called while another `patiently` block is already running on the
|
|
21
|
+
# current thread, the block is simply run once: only the outermost block is
|
|
22
|
+
# retried.
|
|
23
|
+
#
|
|
24
|
+
# @param timeout [Numeric, nil] overrides +Patiently.config.timeout+
|
|
25
|
+
# rubocop:disable Lint/RescueException
|
|
26
|
+
def patiently(timeout = nil, &block)
|
|
27
|
+
return block.call if Thread.current[IN_PATIENTLY]
|
|
28
|
+
|
|
29
|
+
config = Patiently.config
|
|
30
|
+
timeout ||= config.timeout
|
|
31
|
+
started_at = monotonic_time
|
|
32
|
+
retries = 0
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
Thread.current[IN_PATIENTLY] = true
|
|
36
|
+
attempt_started_at = monotonic_time
|
|
37
|
+
block.call
|
|
38
|
+
rescue Exception => e
|
|
39
|
+
elapsed = attempt_started_at - started_at
|
|
40
|
+
timed_out = elapsed > timeout && retries >= config.min_retries
|
|
41
|
+
retries_exhausted = config.max_retries && retries >= config.max_retries
|
|
42
|
+
raise e if timed_out || retries_exhausted
|
|
43
|
+
|
|
44
|
+
sleep(config.retry_interval(retries))
|
|
45
|
+
|
|
46
|
+
if monotonic_time == started_at
|
|
47
|
+
raise Patiently::FrozenInTime, "time appears to be frozen, consider time travelling instead"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
retries += 1
|
|
51
|
+
retry
|
|
52
|
+
ensure
|
|
53
|
+
Thread.current[IN_PATIENTLY] = false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
# rubocop:enable Lint/RescueException
|
|
57
|
+
|
|
58
|
+
# Retries `block` until it returns a truthy value or the patience window
|
|
59
|
+
# elapses, then returns the final result as a boolean.
|
|
60
|
+
#
|
|
61
|
+
# Because `patiently` only retries on a *raise*, we raise an internal
|
|
62
|
+
# sentinel while the block is falsey and translate a timed-out sentinel back
|
|
63
|
+
# into `false`. A real exception inside the block is not the sentinel, so it
|
|
64
|
+
# still propagates.
|
|
65
|
+
#
|
|
66
|
+
# @param timeout [Numeric, nil] overrides +Patiently.config.timeout+
|
|
67
|
+
def patiently_until(timeout = nil, &block)
|
|
68
|
+
patiently(timeout) { block.call || raise(RetryUntilTrue) }
|
|
69
|
+
true
|
|
70
|
+
rescue RetryUntilTrue
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
alias patiently_wait_until patiently_until
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def monotonic_time
|
|
78
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../patiently"
|
|
4
|
+
|
|
5
|
+
# Convenience integration for RSpec: makes `patiently` and `patiently_until`
|
|
6
|
+
# available in feature and system specs (both drive a browser and need retries).
|
|
7
|
+
# If you want a different scope, skip this file and call
|
|
8
|
+
# `config.include(Patiently::Helpers, ...)` yourself.
|
|
9
|
+
RSpec.configure do |config|
|
|
10
|
+
config.include(Patiently::Helpers, type: :feature)
|
|
11
|
+
config.include(Patiently::Helpers, type: :system)
|
|
12
|
+
end
|
data/lib/patiently.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "patiently/version"
|
|
4
|
+
require_relative "patiently/errors"
|
|
5
|
+
require_relative "patiently/configuration"
|
|
6
|
+
require_relative "patiently/helpers"
|
|
7
|
+
|
|
8
|
+
module Patiently
|
|
9
|
+
class << self
|
|
10
|
+
# The global configuration object. See Patiently::Configuration.
|
|
11
|
+
def config
|
|
12
|
+
@config ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Yields the configuration object for block-style setup:
|
|
16
|
+
#
|
|
17
|
+
# Patiently.configure do |config|
|
|
18
|
+
# config.timeout = 10
|
|
19
|
+
# end
|
|
20
|
+
def configure
|
|
21
|
+
yield config
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|