Skip to content

Release 2.0.0: stable exception contract, observable retries, dead-code and bug removal - #43

Open
joescottdave wants to merge 9 commits into
mainfrom
spike/v2
Open

Release 2.0.0: stable exception contract, observable retries, dead-code and bug removal#43
joescottdave wants to merge 9 commits into
mainfrom
spike/v2

Conversation

@joescottdave

@joescottdave joescottdave commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Objective

The amount of time it takes to make very small (and, unfortunately, error-prone) tweaks
to the logging in the HMLR suite of applications is incredibly high, in part because we
first have to update gems like this one and then adopt the new version across four
different apps and deploy them to three different environments.

During an investigation into a silently crashing log-line (replicated in three of these
apps) we find that this gem is contributing to the noise with a misconfiguration of
Faraday that cannot be altered from the outside, hence it is once again necessary
to make a fix here and to update the gem in two places
(epimorphics/standard-reports-ui, and epimorphics/ppd-explorer).

The gem, until now, has accepted the Rails.logger instance at initialisation and uses
it to print logs of its own. In v2 the gem will switch to the ActiveSupport::Notification
and ActiveSupport::Subscriber pattern so that consuming applications can log as they
choose and we reduce future need to revisit this gem any time there is a problem with
the log quality.

Breaking changes

  • Logging removed entirely. No logger: config, no automatic
    Rails.logger wiring, no debug-level Faraday logging on by default. The
    gem now only emits ActiveSupport::Notifications events; consuming apps
    subscribe and log however they want. Faraday's own request/response
    logging is opt-in via faraday_logger:.
  • Notification names renamed, moved off the generic, collision-prone
    .api suffix onto .data_services_api
    (response.api -> response.data_services_api, etc).
  • Faraday's exception types are no longer raised directly. Any 4xx/5xx
    status or unparseable body is now always wrapped in
    DataServicesApi::ServiceException before reaching the caller. This
    restores the exception contract consuming apps were already written
    against (rescue DataServicesApi::ServiceException, e.service_message)
    but weren't reliably getting, since only 404s were ever wrapped before,
    and even that path was largely dead code.

New

  • retry.data_services_api: fired before each retry attempt on a network
    failure, so retry behaviour is observable for the first time instead of
    happening silently inside Faraday.
  • connection_timeout config option, replacing a hardcoded 600.

Bug fixes

  • Service#datasets, Service#as_http_api, Dataset#structure,
    Dataset#describe all always raised (ArgumentError or
    URI::InvalidComponentError) for anyone who called them — confirmed
    unused by every current consuming app, which is why none of this was
    caught until now.
  • ServiceException#service_message always returned nil due to a typo
    (@service_msg vs @service_message) — the exact accessor consuming
    apps call in their rescue blocks.
  • Removed Service#ok? (unreachable, Faraday's own raise_error
    middleware already handled every case it covered, and it would have
    crashed itself if it ever ran) and a dead, non-functional auth
    parameter.
  • Removed the yajl-ruby dependency: response bodies were being
    re-serialized and re-parsed a second time for no reason, and the gem's
    own require "yajl" had been silently commented out, meaning it only
    ever worked by accident in this repo's own test suite.
  • Removed the unused faraday-encoding dependency.

Impact

Any app on 1.x will need a small migration when adopting 2.0.0. Primarily this
will involve updating ActiveSupport::Subscriber#attach_to from :api to
:data_services_api if using notification-based subscribers (silent
failure otherwise, not a crash), and reviewing rescue clauses that may now
correctly catch ServiceException for cases they previously missed.

Testing

Full test suite green, rubocop clean. Manually verified end-to-end against
a running ppd-explorer checkout: connection failures, service exceptions,
and retries all instrument and log correctly with no crash.

… events

Service previously logged directly to Rails.logger (with no way for
consuming apps to disable, reformat, or change level) and enabled
Faraday's debug-level request/response logging unconditionally. It also
depended on yajl-ruby to re-parse JSON response bodies that Faraday's
own :json middleware had already parsed, which was both redundant and
broken out of the box (the gem's own `require "yajl"` was commented out).

- Remove all logger:/log_message/generate_service_message/Rails.logger
  usage; consumers now subscribe to instrumentation events instead
- Namespace all notification event names under `data_services_api`
  (was the generic, collision-prone `.api` suffix), and add a new
  query_result event carrying path/method/status/returned_rows
- Make Faraday's own logging middleware opt-in via faraday_logger:/
  faraday_logger_options:, defaulting to debug level when enabled
- Drop the yajl-ruby dependency and the parse_json/report_json_failure
  code built on it, since Faraday already parses response bodies
- Fix a NameError (RACK::Exception typo) in the service-exception path

BREAKING CHANGE: the `logger:` config option is removed, and the
response.api/connection_failure.api/service_exception.api/requests.api
notification names are renamed to their data_services_api-suffixed
equivalents. Consuming apps must subscribe to the new event names and
provide their own logging via ActiveSupport::Notifications subscribers.
Auditing service.rb turned up several methods that were broken or
unreachable but had gone unnoticed because no consuming app exercised
them:

- Service#datasets always raised ArgumentError (missing required
  argument to api_get_json)
- as_http_api raised URI::InvalidComponentError whenever url: was
  configured with a scheme, exactly as the README's own example shows,
  because URI::HTTP.build(host: @url, ...) treated the full URL as a
  bare hostname
- Service#ok? was unreachable (Faraday's raise_error middleware already
  raises on all 4xx/5xx first) and would have raised a TypeError itself
  if it ever ran, since response.body is already a parsed Hash by then
- create_http_connection's auth parameter was unused and non-functional
  (referenced api_user/api_pw methods that don't exist)

Also drop the unused faraday-encoding dependency, add a
connection_timeout config option in place of the hardcoded 600s
default, and extract the duplicated request-timing/instrumentation
logic in get_from_api/post_to_api into a shared perform_request helper.
… hook

Faraday's own exception types (ResourceNotFound, ClientError, ServerError,
ParsingError) were leaking straight through to callers for anything beyond
a 404. But both consuming apps (ppd-explorer, ukhpi) rescue
DataServicesApi::ServiceException and call e.service_message on it,
expecting this gem to own that contract rather than exposing Faraday's
exception hierarchy directly.

- perform_request now catches the full Faraday::Error hierarchy and
  re-raises as ServiceException for any 4xx/5xx status or unparseable
  body; network-level failures (TimeoutError/ConnectionFailed) are left
  as Faraday's own types, since they're transport failures, not API ones
- Fixed ServiceException#service_message, which always returned nil due
  to a typo (@service_msg instead of @service_message) - the exact
  accessor both consuming apps already call on rescue
- service_exception.data_services_api now fires for this whole class of
  failure instead of just 404s, and its query_string field is populated
  from the actual request params instead of always being nil
- Added a retry.data_services_api notification, fired before each retry
  attempt on a network failure, since retries were previously invisible
  to any instrumentation subscriber

README and CHANGELOG updated to document the exception contract and the
new notification.
Service#dataset(name) only ever populated data-api/dataset in the JSON
handed to Dataset, never structure-api/describe-api, so any Dataset
obtained the normal way (the only way any consuming app gets one) had
those fields as nil. Dataset#structure then compounded it by calling
api_get_json with a missing required argument - the same class of bug
already found and fixed in Service#datasets.

- Derive structure-api/describe-api from data-api the same way the real
  /dataset listing endpoint returns them (<data-api>/structure,
  <data-api>/describe), confirmed against old cassette fixtures
- Fix Dataset#structure's missing argument to api_get_json
- Add test coverage for Dataset#structure and Dataset#describe
… emitted by removing in favour of the more generic
@joescottdave joescottdave changed the title spike/v2 Release 2.0.0: stable exception contract, observable retries, dead-code and bug removal Jul 28, 2026
@joescottdave

Copy link
Copy Markdown
Contributor Author

Wondering if we should mark unused methods with bugs (fixed here) as deprecated with a view to removing them entirely.
See first bullet under Bug Fixes

@joescottdave joescottdave self-assigned this Jul 29, 2026
@joescottdave
joescottdave requested a review from ajtucker July 29, 2026 08:05
@joescottdave
joescottdave marked this pull request as ready for review July 29, 2026 08:05
@ajtucker

ajtucker commented Aug 6, 2026

Copy link
Copy Markdown

I'm gradually reviewing this. It looks to be fine, though I'm stepping through things to figure out what is going on, so it is taking time.

I've added some Ruby type signatures (rbs files) to a local branch and am using steep to do static type checking. It might be worth adding these as a separate PR. As the Gem uses dynamic methods with method_missing and respond_to_missing?, we can add some of the expected run-time methods to the type signatures and catch potential errors. The return type of most API calls is generic JSON, but we can add some assumptions, e.g. that we expect the return type to be a JSON array. One thing steep flagged up was the run-time assumption that Faraday's connection option hash has a :timeout key and we can change this to config.options.timeout rather than config.options[:timeout].

The other thing I'm looking at is the VCRs, some of which have been updated, but others which date back to 2017. If I remove them and try to rebuild them against a local API running against the live SPARQL endpoint, I get some 404s, which may just be that the test instances need updating.

@ajtucker

ajtucker commented Aug 6, 2026

Copy link
Copy Markdown

I get some 404s

1st error is indeed the @service.datasets call. This doesn't exist in the lr-data-api and as noted, is never used.

2nd error is @dataset.describe, which again doesn't exist in the API and as noted, is never used.

3rd error is @dataset.structure, ditto.

Wondering if we should mark unused methods with bugs (fixed here) as deprecated with a view to removing them entirely. See first bullet under Bug Fixes

As this is a major version bump, I'd vote to remove these methods and tests.

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.

2 participants