Skip to content

Harden ApiError: AccessDeniedError subclass + stricter input validation#10

Merged
jramos merged 4 commits into
developfrom
feature/api-error-hardening
Apr 25, 2026
Merged

Harden ApiError: AccessDeniedError subclass + stricter input validation#10
jramos merged 4 commits into
developfrom
feature/api-error-hardening

Conversation

@jramos
Copy link
Copy Markdown
Owner

@jramos jramos commented Apr 25, 2026

Summary

Follow-up to PR #9. Four commits hardening CgminerApiClient::ApiError and adding a dedicated subclass for the most-commonly-dispatched-on case.

  • CgminerApiClient::AccessDeniedError < ApiError — covers cgminer's STATUS=E Code 45 response and the gem's own access_denied? local guard. Inherits from ApiError so existing rescue ApiError clauses still catch it; callers wanting finer dispatch use rescue AccessDeniedError instead of case e.code; when :access_denied. Constructor pins code: :access_denied so the symbolic tag is consistent regardless of which call site raised.

  • ApiError.for_status(c, msg) factory — centralized at the wire-side emission point. Picks AccessDeniedError when the cgminer integer maps to :access_denied, falls back to ApiError otherwise. Wire boundary stays best-effort: a non-numeric Code coerces to nil and the symbolic tag becomes :unknown rather than raising mid-poll.

  • Stricter constructor input validationcgminer_code: must be Integer or nil; code: must be Symbol, String, or nil. Bad input raises ArgumentError with a clear message instead of silently producing code: :unknown (string cgminer_code) or NoMethodError deep in the constructor (code: 42). code: false is now also rejected loudly rather than silently bypassing to map lookup.

  • Forward-rot guard — precondition test asserting CGMINER_CODES has no key for 23, so a future PR mapping it trips loudly instead of silently degrading the existing :unknown-fallback test that depends on 23 being unmapped.

  • Docserrors.rb block-doc trimmed and corrected (the prior factually wrong "before any wire interaction" claim about access_denied? was already fixed in PR Attach structured cgminer_code and code symbol to ApiError #9, but the block doc still echoed the same misframing). CHANGELOG / AGENTS.md gain a "prefer e.code for dispatch over e.cgminer_code" advisory and drop the cgminer 4.11.1 version pin in favor of real cgminer wire fixtures — the version isn't load-bearing for the claim and would date when fixtures refresh.

Backward compatibility

  • All existing rescue CgminerApiClient::ApiError and rescue CgminerApiClient::Error clauses keep working — AccessDeniedError inherits from ApiError, no rescue chain changes needed in cgminer_monitor or cgminer_manager.
  • Existing emission sites (wire-side Miner#check_status, local-guard access_denied?) raise the same observable thing (caught by rescue ApiError, message and code symbol unchanged); the only visible difference is that e.is_a?(AccessDeniedError) now returns true for the access-denied path.
  • The constructor input-validation changes are loud-on-misuse, not loud-on-existing-callers — the gem's own emission sites all pass valid types. Any external caller passing cgminer_code: "45" or code: 42 was already getting silent or opaque failures; now the failure is loud and actionable.

Test plan

  • bundle exec rake green: 251 → 284 examples (+33), rubocop clean
  • Coverage holds at 99.7%
  • AccessDeniedError is rescued by rescue AccessDeniedError, rescue ApiError, and rescue CgminerApiClient::Error (all three pinned by spec)
  • ApiError.for_status(45, ...) returns AccessDeniedError; for_status(14, ...) returns plain ApiError; for_status(999, ...) returns plain ApiError with code: :unknown; for_status('45', ...) coerces the String and returns AccessDeniedError; for_status('not-a-number', ...) falls through to cgminer_code: nil, code: :unknown
  • ApiError.new("msg", cgminer_code: "45") raises ArgumentError; same for Float, Hash, Array
  • ApiError.new("msg", code: 42) raises ArgumentError; same for Hash, Array, false
  • Symbol, String, nil for code: and Integer, nil for cgminer_code: continue to work (regression guard)
  • Forward-rot precondition: CgminerApiClient::ApiError::CGMINER_CODES has no key 23
  • Wire-side integration spec via FakeCgminer: query(:totally_fake_command) raises ApiError with cgminer_code: 14, code: :invalid_command; query(:privileged) against PRIVILEGED_DENIED raises AccessDeniedError with cgminer_code: 45, code: :access_denied

jramos added 4 commits April 25, 2026 08:26
ApiError subclass for cgminer's "access denied" response (STATUS=E
Code 45) and the gem's own access_denied? local guard. Inherits
from ApiError so existing `rescue ApiError` clauses still catch
it; callers wanting finer dispatch use `rescue AccessDeniedError`
instead of pattern-matching on `e.code == :access_denied`.

  - lib/cgminer_api_client/errors.rb: new AccessDeniedError class.
    Constructor pins code: :access_denied so the symbolic tag is
    consistent regardless of which call site raised. Also adds
    ApiError.for_status — factory used by the wire-side emission
    point that picks AccessDeniedError when the cgminer integer
    maps to :access_denied, falling back to ApiError otherwise.

  - lib/cgminer_api_client/miner.rb: check_status now delegates to
    ApiError.for_status. Removes the inline Integer coercion and
    class-picking from the case statement.

  - lib/cgminer_api_client/miner/commands.rb: access_denied? raises
    AccessDeniedError directly (legacy raise(class, message) form,
    cgminer_code stays nil because privileged's rescue dropped it).

  - Tests: dedicated access_denied_error_spec.rb covers class
    hierarchy + constructor + rescue dispatch (subclass, ApiError,
    Error). api_error_spec.rb gains .for_status coverage including
    the String-Code defensive coercion. Existing emission-site
    tests in miner_spec.rb, commands_spec.rb, and the wire-side
    integration test extended to assert the subclass type.

Tests: 258 → 276 examples (+18). Rubocop clean. Coverage 99.7%.
ApiError.new("msg", code: 42) previously raised NoMethodError on
.to_sym deep in the constructor — opaque failure mode. code: false
silently bypassed to map lookup because false || x evaluates to x.
Both rejected at the library boundary now with a clear ArgumentError.

Symbol and String forms still work (the to_sym coercion stays for
ergonomic callers); nil still falls through to map lookup or
:unknown. Seven new tests pin the boundaries (Integer, Hash, Array,
false rejected; Symbol, String, nil accepted).

Tests: 276 → 283 examples (+7). Rubocop clean. Coverage 99.7%.
The fatal-status test asserts that an unmapped cgminer Code falls
through to code: :unknown — but the assertion silently degrades if
a future PR maps 23 to a symbol (the test would still pass, but
exercise the mapped path while claiming to test the fallback).

A precondition example asserting CGMINER_CODES has no key for 23
trips loudly the moment 23 gets mapped, so the unmapped fixture
gets updated alongside the map change.
CHANGELOG:
  - New [Unreleased] ### Added entry for AccessDeniedError +
    ApiError.for_status factory.
  - Existing structured-codes entry updated with the e.code-not-
    e.cgminer_code dispatch advisory and the corrected mechanism
    description (privileged hits the wire and discards the integer
    in its rescue, rather than the previously stated "before any
    wire interaction").
  - New [Unreleased] ### Changed entry for the constructor's
    Integer/Symbol-or-String input validation.
  - Drops the "real cgminer 4.11.1 fixtures" version pin in favor
    of "real cgminer wire fixtures" — the version isn't
    load-bearing for the claim and would date when fixtures
    refresh against a newer cgminer.

AGENTS.md:
  - errors.rb tree-line picks up AccessDeniedError.
  - Error-handling section gains the dispatch advisory ("prefer
    e.code over e.cgminer_code") and a paragraph naming
    AccessDeniedError + the asymmetry rule for adding new
    ApiError subclasses (only when a code earns enough dispatch-
    site interest).
  - Same version-pin removal as the CHANGELOG.
  - New paragraph names ApiError.for_status as the wire-side
    factory and notes that non-wire paths raise the right class
    directly rather than going through the factory.
@jramos jramos merged commit afbba2d into develop Apr 25, 2026
6 checks passed
@jramos jramos mentioned this pull request Apr 25, 2026
3 tasks
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