Harden ApiError: AccessDeniedError subclass + stricter input validation#10
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to PR #9. Four commits hardening
CgminerApiClient::ApiErrorand 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 ownaccess_denied?local guard. Inherits fromApiErrorso existingrescue ApiErrorclauses still catch it; callers wanting finer dispatch userescue AccessDeniedErrorinstead ofcase e.code; when :access_denied. Constructor pinscode: :access_deniedso the symbolic tag is consistent regardless of which call site raised.ApiError.for_status(c, msg)factory — centralized at the wire-side emission point. PicksAccessDeniedErrorwhen the cgminer integer maps to:access_denied, falls back toApiErrorotherwise. Wire boundary stays best-effort: a non-numeric Code coerces toniland the symbolic tag becomes:unknownrather than raising mid-poll.Stricter constructor input validation —
cgminer_code:must beIntegerornil;code:must beSymbol,String, ornil. Bad input raisesArgumentErrorwith a clear message instead of silently producingcode: :unknown(stringcgminer_code) orNoMethodErrordeep in the constructor (code: 42).code: falseis now also rejected loudly rather than silently bypassing to map lookup.Forward-rot guard — precondition test asserting
CGMINER_CODEShas no key for23, so a future PR mapping it trips loudly instead of silently degrading the existing:unknown-fallback test that depends on23being unmapped.Docs —
errors.rbblock-doc trimmed and corrected (the prior factually wrong "before any wire interaction" claim aboutaccess_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 "prefere.codefor dispatch overe.cgminer_code" advisory and drop thecgminer 4.11.1version pin in favor ofreal cgminer wire fixtures— the version isn't load-bearing for the claim and would date when fixtures refresh.Backward compatibility
rescue CgminerApiClient::ApiErrorandrescue CgminerApiClient::Errorclauses keep working —AccessDeniedErrorinherits fromApiError, no rescue chain changes needed incgminer_monitororcgminer_manager.Miner#check_status, local-guardaccess_denied?) raise the same observable thing (caught byrescue ApiError, message andcodesymbol unchanged); the only visible difference is thate.is_a?(AccessDeniedError)now returnstruefor the access-denied path.cgminer_code: "45"orcode: 42was already getting silent or opaque failures; now the failure is loud and actionable.Test plan
bundle exec rakegreen: 251 → 284 examples (+33), rubocop cleanAccessDeniedErroris rescued byrescue AccessDeniedError,rescue ApiError, andrescue CgminerApiClient::Error(all three pinned by spec)ApiError.for_status(45, ...)returnsAccessDeniedError;for_status(14, ...)returns plainApiError;for_status(999, ...)returns plainApiErrorwithcode: :unknown;for_status('45', ...)coerces the String and returnsAccessDeniedError;for_status('not-a-number', ...)falls through tocgminer_code: nil, code: :unknownApiError.new("msg", cgminer_code: "45")raisesArgumentError; same forFloat,Hash,ArrayApiError.new("msg", code: 42)raisesArgumentError; same forHash,Array,falseSymbol,String,nilforcode:andInteger,nilforcgminer_code:continue to work (regression guard)CgminerApiClient::ApiError::CGMINER_CODEShas no key23query(:totally_fake_command)raisesApiErrorwithcgminer_code: 14, code: :invalid_command;query(:privileged)againstPRIVILEGED_DENIEDraisesAccessDeniedErrorwithcgminer_code: 45, code: :access_denied