Skip to content

feat(datasource-pylon): foundation β€” gem, config & resilient client - #341

Merged
christophebrun-forest merged 4 commits into
feat/datasource-pylonfrom
feature/ext-5-story-1-foundation-gem-config-resilient-client
Aug 7, 2026
Merged

feat(datasource-pylon): foundation β€” gem, config & resilient client#341
christophebrun-forest merged 4 commits into
feat/datasource-pylonfrom
feature/ext-5-story-1-foundation-gem-config-resilient-client

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Aug 4, 2026

Copy link
Copy Markdown
Member

Story 1 of the Pylon datasource β€” EXT-5. Targets the integration branch feat/datasource-pylon (EXT-4), not main.

What this adds

A new forest_admin_datasource_pylon gem skeleton with an authenticated, rate-limit-aware HTTP client:

  • Entry point β€” Zeitwerk for_gem autoloading, typed error hierarchy (Error / ConfigurationError / UnsupportedOperatorError / APIError), configurable logger with a Rails.logger fallback. APIError carries the HTTP status and the parsed body so the smart actions in Story 8 can surface Pylon's own validation message.
  • Configuration β€” validated api_key, base URL https://api.usepylon.com (Pylon paths are unversioned), configurable timeouts and retry budget.
  • Client β€” Faraday with Authorization: Bearer, JSON in/out, 429 retry with exponential backoff, GET /me health check, plus the envelope-unwrapping and error-mapping helpers the later stories build on.

Faraday middleware order β€” deliberate divergence from the Mambu Payments gem

forest_admin_datasource_mambu_payments registers retry before raise_error, which means 429s are never actually retried: raise_error sits inside, so it raises Faraday::TooManyRequestsError, which is not in faraday-retry's default exceptions β€” the middleware never observes a response and retry_statuses silently does nothing.

This gem inverts the order: raise_error outside the JSON parser (errors carry an already-parsed body) and retry innermost, where it sees raw statuses. Two specs pin the behaviour β€” one 429 then 200 issues 2 requests; a persistent 429 issues 3 and raises APIError with status: 429.

Non-idempotent verbs are retried only on 429, where Pylon rejected the request before processing it β€” a 502 on POST /issues may well have created the issue. Implemented with retry_if rather than methods, because faraday-retry ORs the two conditions and retry_if can therefore only widen, never restrict.

Worth a separate fix on the Mambu gem.

Monorepo wiring

forest_admin_datasource_pylon added to the lint matrix, test matrix and coverage file list in build.yml, plus the needed .rubocop.yml excludes.

.releaserc.js is intentionally untouched, so no incomplete gem can reach RubyGems β€” the deploy job only runs on push to main/beta, so no story PR into the integration branch can publish. The exact 3-line patch is recorded as a comment on EXT-13 (Story 9), which owns "add gem to the release/publish workflow". Consequence: the Pylon VERSION stays at 1.36.2 and will drift from main until Story 9 realigns it.

Test plan

  • BUNDLE_GEMFILE=Gemfile-test bundle exec rspec β†’ 29 examples, 0 failures, coverage 96.97% (threshold 90)
  • bundle exec rubocop over the whole repo β†’ 786 files, no offenses
  • Zeitwerk autoloading verified by requiring the gem standalone and resolving Client / Configuration

πŸ€– Generated with Claude Code

Note

Add forest_admin_datasource_pylon gem with configuration and resilient HTTP client

  • Introduces a new forest_admin_datasource_pylon Ruby gem with a Zeitwerk-loaded module, structured APIError (carrying HTTP status and parsed body), and a logger that defaults to Rails.logger when available.
  • Adds Configuration requiring api_key, defaulting base_url to https://api.usepylon.com, and bundling a RetryPolicy.
  • RetryPolicy retries on 429/502/503/504 and connection failures with exponential backoff, capping at 65s to respect Pylon Retry-After headers; non-idempotent verbs only retry on 429.
  • Client wraps Faraday with JSON middleware and the retry policy, maps failures to APIError with request_id propagation and message truncation.
  • CI lint/test matrix and coverage upload are extended to include this new package.

Macroscope summarized f07ce77.

Story 1 of the Pylon datasource (EXT-5).

Adds the forest_admin_datasource_pylon gem skeleton: Zeitwerk autoloading,
typed error hierarchy with an APIError carrying HTTP status and parsed body,
configurable logger, Configuration with api_key validation, and a Faraday
client authenticating with a Bearer token plus a GET /me health check.

The Faraday middleware order is deliberate and differs from the Mambu
Payments gem: raise_error sits outside the JSON parser so errors carry an
already-parsed body, and retry sits innermost so it can observe raw statuses.
Behind raise_error the retry middleware never sees a 429 and retry_statuses
silently does nothing. Non-idempotent verbs are only retried on 429, where
Pylon rejected the request before processing it.

Wires the package into the CI lint, test and coverage jobs. The
semantic-release publish pipeline is intentionally left untouched until
Story 9, so an incomplete gem is never pushed to RubyGems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown

EXT-5

@qltysh

qltysh Bot commented Aug 4, 2026

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Duplication Found 17 lines of identical code in 3 locations (mass = 76) 1
qlty Duplication Found 34 lines of similar code in 2 locations (mass = 102) 1
qlty Structure Function with many parameters (count = 5): initialize 1

gem 'rspec', '~> 3.0'
gem 'simplecov', '~> 0.22', require: false
gem 'webmock', '~> 3.0'
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 17 lines of identical code in 3 locations (mass = 76) [qlty:identical-code]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 Medium

ruby-version: ["3.4"]

The Send coverage step references reports/3.4-forest_admin_datasource_pylon/coverage.json, but coverage artifacts are uploaded only for ruby-version == '4.0', so the artifact is named 4.0-forest_admin_datasource_pylon. The 3.4 path does not exist, so the Pylon coverage file is missing and the qltysh/qlty-action/coverage step receives a non-existent input. The coverage job matrix uses 3.4, which mismatches the 4.0 upload condition. Either use 4.0 in the files path for Pylon or align the upload and coverage job matrices to the same version.

πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.github/workflows/build.yml around line 133:

The `Send coverage` step references `reports/3.4-forest_admin_datasource_pylon/coverage.json`, but coverage artifacts are uploaded only for `ruby-version == '4.0'`, so the artifact is named `4.0-forest_admin_datasource_pylon`. The `3.4` path does not exist, so the Pylon coverage file is missing and the `qltysh/qlty-action/coverage` step receives a non-existent input. The coverage job matrix uses `3.4`, which mismatches the `4.0` upload condition. Either use `4.0` in the `files` path for Pylon or align the upload and coverage job matrices to the same version.

Comment on lines +32 to +33
spec.add_dependency 'activesupport', '>= 6.1'
spec.add_dependency 'faraday', '~> 2.0'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec:32

The gemspec omits forest_admin_datasource_toolkit from its runtime dependencies, so installing forest_admin_datasource_pylon does not pull in the toolkit. When users require 'forest_admin_datasource_pylon', the gem fails with LoadError because the toolkit gem is missing. Add forest_admin_datasource_toolkit as a runtime dependency via spec.add_dependency.

  spec.add_dependency 'forest_admin_datasource_toolkit'
+  spec.add_dependency 'activesupport', '>= 6.1'
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/forest_admin_datasource_pylon.gemspec around lines 32-33:

The gemspec omits `forest_admin_datasource_toolkit` from its runtime dependencies, so installing `forest_admin_datasource_pylon` does not pull in the toolkit. When users `require 'forest_admin_datasource_pylon'`, the gem fails with `LoadError` because the toolkit gem is missing. Add `forest_admin_datasource_toolkit` as a runtime dependency via `spec.add_dependency`.

Review findings on EXT-5.

max_interval was hardcoded to 5s. faraday-retry gives up outright
when Retry-After exceeds max_interval, so a Pylon 429 carrying a
per-minute Retry-After performed zero retries -- defeating the
retry the story is about, on the very endpoints Story 2 will hit
(10 req/min). Verified: Retry-After 30 issued 1 request, no retry.

The cap now defaults to 65s to cover a full rate-limit window and
is configurable. Three specs pin it: the give-up mechanism when the
cap is exceeded, a header within the cap still retrying, and the
default staying above a per-minute window. The existing specs
missed this because none of them sent a Retry-After header.

Also from the review:
- retry Faraday::ConnectionFailed, absent from faraday-retry's
  defaults, so transient connection drops are absorbed too
- truncate the error message before appending the request_id, which
  a long error body used to push out first
- require json explicitly rather than via Faraday
- drop a dead compact in error_detail

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Logger.new($stderr).tap { |l| l.progname = 'forest_admin_datasource_pylon' }
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 34 lines of similar code in 2 locations (mass = 102) [qlty:similar-code]

Gathers everything governing how the client reacts to a failed
request into one value object: retryable statuses and exceptions,
the idempotent-verb rule and its retry_if escape hatch for 429s,
the budget and the Retry-After cap. The client no longer spreads
that policy across four constants and three Configuration kwargs;
it just splats to_faraday_options.

Configuration drops from 7 keyword arguments to 5, so the
Metrics/ParameterLists exclusion added for it is no longer needed
and is removed rather than left to rot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

attr_reader :api_key, :base_url, :open_timeout, :timeout, :retry_policy

def initialize(api_key:, base_url: nil, open_timeout: 5, timeout: 30, retry_policy: RetryPolicy.new)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): initialize [qlty:function-parameters]

activesupport was declared as a runtime dependency but nothing in the
package uses it: blank? is hand-rolled in Configuration. Declaring it
forced a heavy transitive dependency on every consumer for nothing.

must_succeed rescued StandardError after Faraday::Error, so an APIError
raised inside the block was caught by the generic arm and re-wrapped,
dropping its status to nil. Nothing does that yet, but the collections
landing next branch on status == 404, so the failure mode has teeth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +53 to +55
nested = parsed['error']
message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) ||
join_errors(parsed['errors'])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 Medium forest_admin_datasource_pylon/client.rb:53

An API error payload like { "message": "", "errors": [{ "message": "invalid field" }] } surfaces the serialized whole payload instead of the validation message from errors. parsed['message'] is an empty string, which is truthy in Ruby, so error_message keeps the empty value and append_request_id falls through to parsed.to_json β€” bypassing join_errors(parsed['errors']) which would have returned the real error text. Use presence (or a blank check) so empty-string candidates are treated as absent and the fallback chain proceeds.

-      nested = parsed['error']
-      message = parsed['message'] || (nested.is_a?(Hash) ? nested['message'] : nested) ||
-                join_errors(parsed['errors'])
+      nested = parsed['error']
+      message = parsed['message'].presence ||
+                (nested.is_a?(Hash) ? nested['message'].presence : nested&.presence) ||
+                join_errors(parsed['errors'])
πŸš€ Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forest_admin_datasource_pylon/lib/forest_admin_datasource_pylon/client.rb around lines 53-55:

An API error payload like `{ "message": "", "errors": [{ "message": "invalid field" }] }` surfaces the serialized whole payload instead of the validation message from `errors`. `parsed['message']` is an empty string, which is truthy in Ruby, so `error_message` keeps the empty value and `append_request_id` falls through to `parsed.to_json` β€” bypassing `join_errors(parsed['errors'])` which would have returned the real error text. Use `presence` (or a blank check) so empty-string candidates are treated as absent and the fallback chain proceeds.

@christophebrun-forest
christophebrun-forest merged commit 9cd45e0 into feat/datasource-pylon Aug 7, 2026
52 checks passed
@christophebrun-forest
christophebrun-forest deleted the feature/ext-5-story-1-foundation-gem-config-resilient-client branch August 7, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant