5pm 0.1.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/CHANGELOG.md +66 -0
- data/LICENSE +21 -0
- data/README.md +209 -0
- data/SECURITY.md +15 -0
- data/docs/api-support.md +71 -0
- data/docs/cached-validation.md +58 -0
- data/docs/implementation-guides.md +12 -0
- data/docs/implementation-status.md +45 -0
- data/docs/live-validation-report.json +177 -0
- data/docs/live-validation.md +109 -0
- data/docs/mcp-plan.md +19 -0
- data/docs/release-preparation/0.1.0-manual-artifacts.json +112 -0
- data/docs/release-preparation/limited-first-release-decision.md +28 -0
- data/docs/release-preparation/manual-first-publication.md +192 -0
- data/docs/releasing.md +154 -0
- data/examples/read_only.rb +12 -0
- data/lib/five_pm/client.rb +145 -0
- data/lib/five_pm/download.rb +16 -0
- data/lib/five_pm/errors.rb +73 -0
- data/lib/five_pm/operations.json +354 -0
- data/lib/five_pm/pagination.rb +51 -0
- data/lib/five_pm/request.rb +80 -0
- data/lib/five_pm/resource.rb +41 -0
- data/lib/five_pm/response.rb +212 -0
- data/lib/five_pm/retry_policy.rb +48 -0
- data/lib/five_pm/services.rb +40 -0
- data/lib/five_pm/transport.rb +147 -0
- data/lib/five_pm/types.json +498 -0
- data/lib/five_pm/version.rb +3 -0
- data/lib/five_pm.rb +9 -0
- data/sig/five_pm.rbs +152 -0
- data/sig/models.rbs +223 -0
- metadata +113 -0
data/docs/releasing.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Release preparation
|
|
2
|
+
|
|
3
|
+
`bin/package` builds the gem, checks its exact file inventory and MIT license, then
|
|
4
|
+
loads the installed artifact in a subprocess outside Bundler's source checkout.
|
|
5
|
+
It does not publish. `bin/release-check VERSION FULL_COMMIT_SHA` is also read-only.
|
|
6
|
+
|
|
7
|
+
## Owner-controlled live validation
|
|
8
|
+
|
|
9
|
+
Stan Carver II decides when another live API check is useful. Live API access,
|
|
10
|
+
an active trial or paid subscription, Keychain credentials, and fresh live evidence
|
|
11
|
+
are not release prerequisites. The 15-day trial does not create an ongoing account
|
|
12
|
+
requirement. Releases and CI use offline tests, stored contracts, package verification
|
|
13
|
+
and maintainer acceptance of the exact candidate.
|
|
14
|
+
|
|
15
|
+
Historical reports retain their actual outcomes and tested source. Do not relabel
|
|
16
|
+
an old report as a fresh check. `bin/validate` reuses its local sanitized report
|
|
17
|
+
without expiration or network requests, even if source changes; `source_matches`
|
|
18
|
+
indicates whether its fingerprint still matches. Only an explicit owner-requested
|
|
19
|
+
refresh contacts 5PM. See [cached validation](cached-validation.md).
|
|
20
|
+
|
|
21
|
+
Before a release:
|
|
22
|
+
|
|
23
|
+
1. Review the source and matching core/MCP versions, and date the changelog.
|
|
24
|
+
2. Pass `bin/spec`, `bin/lint --cache false`, and `bin/package-mcp` offline.
|
|
25
|
+
3. Merge reviewed changes through PRs with passing CI. Fetch origin/master and tags.
|
|
26
|
+
4. Run `bin/release-check VERSION FULL_COMMIT_SHA` from a clean checkout. It checks
|
|
27
|
+
accepted source ancestry, matching versions, the dated changelog and conflicting
|
|
28
|
+
tags. It does not contact 5PM or require `docs/live-validation.json`.
|
|
29
|
+
5. Rebuild from the accepted source, inspect both artifacts and independently accept
|
|
30
|
+
their hashes. Recheck gem-name availability and obtain maintainer publication
|
|
31
|
+
approval. Publish the exact accepted bytes, core first and then MCP.
|
|
32
|
+
|
|
33
|
+
Optional live checks remain available when the owner requests them. If used, keep
|
|
34
|
+
them outside CI, designate a disposable tenant, and enable mutations separately.
|
|
35
|
+
Record actual results and clean up labelled records; do not invent success receipts.
|
|
36
|
+
See [live validation](live-validation.md). Outstanding API observations can be
|
|
37
|
+
tracked as known issues without imposing a mandatory new live test campaign.
|
|
38
|
+
|
|
39
|
+
## Connect GitHub and RubyGems once
|
|
40
|
+
|
|
41
|
+
This follows [abbu's release workflow](https://github.com/scarver2/abbu/blob/4c64d0eb0ccdbc0a8c04b2e50da92594faf75d09/.github/workflows/release.yml)
|
|
42
|
+
(browser dispatch, exact commit, separate tag and publish jobs) and
|
|
43
|
+
[printavo's release workflow](https://github.com/scarver2/printavo-ruby/blob/464ae6f29b8ff7627cf829c02351d1b9eadea37d/.github/workflows/release.yml)
|
|
44
|
+
(pinned RubyGems action and Bundler tasks). Both packages publish through
|
|
45
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/), without a
|
|
46
|
+
long-lived RubyGems API key or GitHub PAT in repository secrets.
|
|
47
|
+
|
|
48
|
+
A repository administrator must create the
|
|
49
|
+
[release environment](https://github.com/scarver2/5pm-ruby/settings/environments):
|
|
50
|
+
|
|
51
|
+
- Name: `release`; required reviewer: `scarver2`.
|
|
52
|
+
- Disable administrator bypass. Allow the initiating maintainer to approve, matching abbu.
|
|
53
|
+
- Select deployment branches and tags: branch `master` and tag `v*`, only.
|
|
54
|
+
|
|
55
|
+
Both owner prerequisites remain required: this protected GitHub `release`
|
|
56
|
+
environment and RubyGems trusted publishers for **both** `5pm` and `5pm-mcp`.
|
|
57
|
+
Workflow environment variables only describe execution context and the selected
|
|
58
|
+
candidate; they are not independent authorization. GitHub's required-reviewer
|
|
59
|
+
approval gate and RubyGems' matching OIDC publisher policies enforce authorization.
|
|
60
|
+
|
|
61
|
+
The workflow checks these protections before building or tagging. Missing protection
|
|
62
|
+
or inaccessible environment metadata stops the run. The current GitHub connection
|
|
63
|
+
cannot create this environment: GitHub returned HTTP 403, administrator rights required.
|
|
64
|
+
|
|
65
|
+
Sign into RubyGems.org and create two
|
|
66
|
+
[pending trusted publishers](https://rubygems.org/profile/oidc/pending_trusted_publishers)
|
|
67
|
+
for the first publication:
|
|
68
|
+
|
|
69
|
+
| Gem name | GitHub owner | Repository | Workflow filename | Environment |
|
|
70
|
+
| --- | --- | --- | --- | --- |
|
|
71
|
+
| `5pm` | `scarver2` | `5pm-ruby` | `release.yml` | `release` |
|
|
72
|
+
| `5pm-mcp` | `scarver2` | `5pm-ruby` | `release.yml` | `release` |
|
|
73
|
+
|
|
74
|
+
These are separate gem registrations using the same workflow identity. For an
|
|
75
|
+
already-owned gem, configure its trusted publisher instead of a pending publisher.
|
|
76
|
+
Recheck both names for availability before publication. Account setup must
|
|
77
|
+
be completed by the authenticated RubyGems owner; repository configuration alone
|
|
78
|
+
does not establish that connection.
|
|
79
|
+
|
|
80
|
+
## Shared bin-script conventions
|
|
81
|
+
|
|
82
|
+
Like abbu and printavo, Bash entrypoints source `bin/_lib.sh`, change to the
|
|
83
|
+
repository root, and select Ruby through mise when available. Run them directly:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
bin/spec
|
|
87
|
+
bin/lint
|
|
88
|
+
bin/package
|
|
89
|
+
bin/package-mcp
|
|
90
|
+
bin/release-check VERSION FULL_COMMIT_SHA
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`bin/package` writes `pkg/5pm-VERSION.gem`; `bin/package-mcp` also writes
|
|
94
|
+
`mcp/pkg/5pm-mcp-VERSION.gem`. Inventory checks live in `bin/verify-package.rb`;
|
|
95
|
+
package installation, smoke checks, and release logic live in Ruby helpers under
|
|
96
|
+
`bin/support/`, following printavo's layout. Temporary installs are removed on exit.
|
|
97
|
+
Dependencies come from the verified bundle, as in abbu, without network installation.
|
|
98
|
+
|
|
99
|
+
`bin/verify-release-tag` reads `RELEASE_TAG_REF` (or `GITHUB_REF` on tag events), requires `refs/tags/vVERSION`, and
|
|
100
|
+
verifies that the tag matches HEAD and passes the accepted-commit preflight.
|
|
101
|
+
It is read-only. Publication remains the protected workflow's `rake release`.
|
|
102
|
+
|
|
103
|
+
## Build and approve a release
|
|
104
|
+
|
|
105
|
+
Standard Bundler tasks build the core and MCP artifacts respectively:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
bundle exec rake build
|
|
109
|
+
bundle exec rake -f mcp/Rakefile build
|
|
110
|
+
bundle exec rake
|
|
111
|
+
bundle exec rubocop --cache false
|
|
112
|
+
bin/package-mcp
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The packaging checks inspect the actual installed artifacts. CI repeats validation
|
|
116
|
+
on Ruby 3.2, 3.3, 3.4 and 4.0. Release builds set `SOURCE_DATE_EPOCH` to the approved
|
|
117
|
+
commit timestamp. Neither build task publishes.
|
|
118
|
+
|
|
119
|
+
Before dispatch, complete the offline checks and dated changelog above. Both
|
|
120
|
+
gem versions must match. Live reports are optional historical evidence. Preparing
|
|
121
|
+
this workflow does not advance the version, date the changelog or authorize publication.
|
|
122
|
+
|
|
123
|
+
After merging the workflow, open Actions → **Release gems** → **Run workflow** on
|
|
124
|
+
`master`. Enter the explicitly approved stable version without `v` and its full
|
|
125
|
+
40-character accepted commit SHA. The read-only preflight rejects an unaccepted
|
|
126
|
+
commit, a dirty checkout, an undated changelog, mismatched versions or a conflicting tag.
|
|
127
|
+
Tag pushes never start publication. The only release entry point is manual dispatch
|
|
128
|
+
from `master`; dispatch from any other branch or tag is rejected. Version and full
|
|
129
|
+
commit inputs must identify the explicitly approved candidate.
|
|
130
|
+
|
|
131
|
+
Approve the protected tag job only after inspecting the candidate summary and
|
|
132
|
+
verification jobs. It creates `vVERSION` only if absent and verifies its exact target;
|
|
133
|
+
it never moves an existing tag. Approve each protected publication job for that same
|
|
134
|
+
candidate. Publishing has OIDC permission and read-only repository contents; only
|
|
135
|
+
the separate tag job can write repository contents. Bundler's source-control push
|
|
136
|
+
action is disabled. Local publishing tasks refuse to run outside this workflow.
|
|
137
|
+
|
|
138
|
+
The pinned `rubygems/release-gem` action invokes `bundle exec rake release` for the
|
|
139
|
+
core gem, waits for RubyGems availability, then does the same in `mcp/`. The MCP
|
|
140
|
+
package depends on the matching core version. The workflow does not create a GitHub
|
|
141
|
+
Release. No workflow should be dispatched merely to test the connection: a successful
|
|
142
|
+
run publishes both gems.
|
|
143
|
+
|
|
144
|
+
If core publication succeeds and MCP fails, inspect the published core version and
|
|
145
|
+
provenance, fix the cause, and rerun only the failed jobs against the same approved
|
|
146
|
+
candidate. Do not rerun an already-successful publish job, move the tag, overwrite a
|
|
147
|
+
published version or automatically yank it. Code changes require a new reviewed
|
|
148
|
+
candidate and fresh evidence; arrange a new version when the core is already published.
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
—
|
|
152
|
+
Stan Carver II
|
|
153
|
+
Made in Texas 🤠
|
|
154
|
+
https://stancarver.com
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require "five_pm"
|
|
2
|
+
|
|
3
|
+
# Environment variables can be injected by Infisical or another process secret provider.
|
|
4
|
+
client = FivePM::Client.new(host: ENV.fetch("FIVE_PM_HOST"), login: ENV.fetch("FIVE_PM_LOGIN"),
|
|
5
|
+
password: ENV.fetch("FIVE_PM_PASSWORD"), response_mode: :typed)
|
|
6
|
+
begin
|
|
7
|
+
# One bounded request; returned account content may be sensitive.
|
|
8
|
+
response = client.tasks.list(filter: { projectId: Integer(ENV.fetch("FIVE_PM_PROJECT_ID")) }, count: 20)
|
|
9
|
+
response.list.each { |task| puts "#{task.id}: #{task.name}" }
|
|
10
|
+
ensure
|
|
11
|
+
client.sign_out
|
|
12
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module FivePM
|
|
4
|
+
OPERATIONS = JSON.parse(File.read(File.join(__dir__, "operations.json")))
|
|
5
|
+
SERVICE_NAMES = { authentication: "authentication", projects: "projects", project_groups: "projectsgroups",
|
|
6
|
+
tasks: "tasks", activity: "activity", users: "users" }.freeze
|
|
7
|
+
|
|
8
|
+
class Service
|
|
9
|
+
def initialize(client, name)
|
|
10
|
+
@client = client
|
|
11
|
+
@name = name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Parameter names follow the upstream contract exactly; unknown fields inside
|
|
15
|
+
# complex objects are preserved. Responses remain intact until JSON is verified.
|
|
16
|
+
def call(operation, **parameters)
|
|
17
|
+
@client.call(@name, operation, **parameters)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def inspect
|
|
21
|
+
"#<FivePM::Service #{@name}>"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
require_relative "resource"
|
|
26
|
+
require_relative "services"
|
|
27
|
+
|
|
28
|
+
class Client
|
|
29
|
+
SESSION_TTL = 30 * 60
|
|
30
|
+
|
|
31
|
+
def initialize(host:, login:, password:, open_timeout: 5, read_timeout: 30, write_timeout: 30,
|
|
32
|
+
response_mode: :raw, read_retries: 0, max_retry_delay: 30, max_response_bytes: 5 * 1024 * 1024,
|
|
33
|
+
transport: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
34
|
+
unless [login, password].all? { |value| value.is_a?(String) && !value.empty? }
|
|
35
|
+
raise ConfigurationError, "login and password must be nonempty strings"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
raise ConfigurationError, "response_mode must be :raw or :typed" unless %i[raw typed].include?(response_mode)
|
|
39
|
+
|
|
40
|
+
@retry_policy = RetryPolicy.new(max_retries: read_retries, max_delay: max_retry_delay)
|
|
41
|
+
@response_mode = response_mode
|
|
42
|
+
validated = Transport.new(host: host, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
43
|
+
write_timeout: write_timeout, max_response_bytes: max_response_bytes)
|
|
44
|
+
@transport = transport || validated
|
|
45
|
+
@login = login.dup.freeze
|
|
46
|
+
@password = password.dup.freeze
|
|
47
|
+
@clock = clock
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@services = SERVICE_NAMES.to_h do |name, wire|
|
|
50
|
+
type = { authentication: Authentication, users: Users, activity: Activity }.fetch(name, Resource)
|
|
51
|
+
[name, type.new(self, wire)]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
SERVICE_NAMES.each_key { |name| define_method(name) { @services.fetch(name) } }
|
|
56
|
+
|
|
57
|
+
def call(service, operation, **parameters)
|
|
58
|
+
contract = OPERATIONS.dig(service.to_s, operation.to_s)
|
|
59
|
+
raise ArgumentError, "unsupported 5pm operation" unless contract
|
|
60
|
+
if service.to_s == "authentication" && %w[signIn signOut].include?(operation.to_s)
|
|
61
|
+
raise ArgumentError, "use Client#sign_in or Client#sign_out"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
params = parameters.transform_keys(&:to_s)
|
|
65
|
+
allowed = contract.fetch("parameters").keys - %w[sessionId login password]
|
|
66
|
+
raise ArgumentError, "unsupported parameter names" unless (params.keys - allowed).empty?
|
|
67
|
+
|
|
68
|
+
Request.operation!(service.to_s, operation.to_s, params)
|
|
69
|
+
token = @mutex.synchronize { session_id } if contract.fetch("parameters").key?("sessionId")
|
|
70
|
+
params["sessionId"] = token if token
|
|
71
|
+
binary = service.to_s == "activity" && operation.to_s == "downloadFile"
|
|
72
|
+
begin
|
|
73
|
+
payload = @retry_policy.call(service.to_s, operation.to_s) do
|
|
74
|
+
binary ? @transport.download_file(params) : @transport.post(service.to_s, operation.to_s, params)
|
|
75
|
+
end
|
|
76
|
+
rescue AuthenticationError
|
|
77
|
+
@mutex.synchronize { @session_id = nil if @session_id == token }
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
return payload if binary
|
|
81
|
+
|
|
82
|
+
# Injected transports may return unchecked JSON. Keep this public boundary
|
|
83
|
+
# safe as well as Transport's direct-call boundary.
|
|
84
|
+
APIResult.check!(payload)
|
|
85
|
+
@response_mode == :typed ? Response.new(payload, contract: contract) : payload
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sign_in
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
@session_id = nil
|
|
91
|
+
session_id
|
|
92
|
+
end
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def download_file(id)
|
|
97
|
+
call("activity", "downloadFile", id: id, stream: false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def sign_out
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
return false unless @session_id
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
payload = @transport.post("authentication", "signOut", { "sessionId" => @session_id })
|
|
106
|
+
APIResult.check!(payload)
|
|
107
|
+
contract = OPERATIONS.fetch("authentication").fetch("signOut")
|
|
108
|
+
@response_mode == :typed ? Response.new(payload, contract: contract) : payload
|
|
109
|
+
ensure
|
|
110
|
+
@session_id = nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def marshal_dump
|
|
116
|
+
raise TypeError, "FivePM clients cannot be serialized"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inspect
|
|
120
|
+
"#<FivePM::Client>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def session_id
|
|
126
|
+
return @session_id if @session_id && @clock.call < @expires_at
|
|
127
|
+
|
|
128
|
+
@session_id = nil
|
|
129
|
+
started = @clock.call
|
|
130
|
+
response = @transport.post("authentication", "signIn", { "login" => @login, "password" => @password })
|
|
131
|
+
APIResult.check!(response)
|
|
132
|
+
# The test tenant returns data.sessionId; retain the WSDL-shaped envelope
|
|
133
|
+
# for compatibility. Reject ambiguous responses rather than picking a token.
|
|
134
|
+
envelopes = response.is_a?(Hash) ? %w[data auth].select { |key| response.key?(key) } : []
|
|
135
|
+
envelope = response[envelopes.first] if envelopes.length == 1
|
|
136
|
+
token = envelope["sessionId"] if envelope.is_a?(Hash)
|
|
137
|
+
unless token.is_a?(String) && !token.empty?
|
|
138
|
+
raise ProtocolError, "5pm signIn response did not contain an unambiguous sessionId"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@expires_at = started + SESSION_TTL - 30
|
|
142
|
+
@session_id = token
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module FivePM
|
|
2
|
+
# Explicit access to bounded binary data; inspection never includes its contents.
|
|
3
|
+
class Download
|
|
4
|
+
attr_reader :bytes, :content_type
|
|
5
|
+
|
|
6
|
+
def initialize(bytes:, content_type:)
|
|
7
|
+
@bytes = bytes.b.freeze
|
|
8
|
+
@content_type = content_type&.dup&.freeze
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def inspect
|
|
13
|
+
"#<FivePM::Download>"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module FivePM
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
class ConfigurationError < Error; end
|
|
4
|
+
class TransportError < Error; end
|
|
5
|
+
class ProtocolError < Error; end
|
|
6
|
+
|
|
7
|
+
class SchemaError < ProtocolError
|
|
8
|
+
attr_reader :field, :expected_type, :actual_type
|
|
9
|
+
|
|
10
|
+
def initialize(field:, expected_type:, value:)
|
|
11
|
+
@field = field.dup.freeze
|
|
12
|
+
@expected_type = expected_type.dup.freeze
|
|
13
|
+
@actual_type = case value
|
|
14
|
+
when Hash then "object"
|
|
15
|
+
when Array then "array"
|
|
16
|
+
when String then "string"
|
|
17
|
+
when Numeric then "number"
|
|
18
|
+
when true, false then "boolean"
|
|
19
|
+
when nil then "null"
|
|
20
|
+
else "unknown"
|
|
21
|
+
end.freeze
|
|
22
|
+
super("5pm response contains an invalid schema field")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class AuthenticationError < Error; end
|
|
27
|
+
|
|
28
|
+
class APIError < Error
|
|
29
|
+
attr_reader :code
|
|
30
|
+
|
|
31
|
+
def initialize(code)
|
|
32
|
+
@code = code
|
|
33
|
+
super("5pm API request failed (code #{code})")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module APIResult
|
|
38
|
+
def self.check!(payload)
|
|
39
|
+
if payload.is_a?(Hash) && payload["code"].is_a?(Integer) && payload["message"].is_a?(String) &&
|
|
40
|
+
(!payload.key?("data") || payload["code"] >= 400)
|
|
41
|
+
raise APIError, payload.fetch("code")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
payload
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class HTTPError < Error
|
|
49
|
+
attr_reader :status
|
|
50
|
+
|
|
51
|
+
def initialize(status)
|
|
52
|
+
@status = status
|
|
53
|
+
super("5pm HTTP request failed (#{status})")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class RateLimitError < HTTPError
|
|
58
|
+
attr_reader :retry_after
|
|
59
|
+
|
|
60
|
+
def initialize(retry_after: nil, retryable: true)
|
|
61
|
+
@retry_after = retry_after
|
|
62
|
+
@retryable = retryable
|
|
63
|
+
super(429)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def retryable?
|
|
67
|
+
@retryable
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class NotFoundError < HTTPError; end
|
|
72
|
+
class ServerError < HTTPError; end
|
|
73
|
+
end
|