advanced 60 min

RPC handler best practices

Patterns and helpers for robust, consistent RPC handlers.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Apply parameter-validation and error-handling patterns.
  • Reuse RPC helper functions (`lookupLedger`, `parseBase58`…).
  • Follow conventions for consistent responses.
Complete this module by self-assessment and a quiz. Jump to assessment

Introduction

≈60 min · Advanced · builds on Building a custom RPC handler

A working handler and a good handler are not the same thing. In this module you'll pick up the patterns that separate them: rigorous input validation, safe ledger access, reusing helpers like lookupLedger and parseBase58, and the pitfalls (trusting client input, leaking internals, silent failures) that catch newcomers. Write handlers other people can trust.


Code Style Guidelines

In brief: naming and formatting conventions that keep handlers consistent.

Element Convention Example
Handler functions doPascalCase doAccountInfo(RPC::JsonContext&)
Files PascalCase.cpp under src/xrpld/rpc/handlers AccountInfo.cpp
Helpers camelCase validateAccountAddress()
Member variables trailing underscore, is prefix for bools ledger_, isValid_
Constants UPPER_CASE, constexpr MAX_PAGE_SIZE = 1000
Indentation 4 spaces, Stroustrup braces, ~80 char lines

Structure every handler in the same visible order, separated by blank lines: input validation, then main logic, then response building. Reviewers read dozens of handlers; sameness is a feature.


Common Pitfalls to Avoid

In brief: the mistakes that bite new handler authors most.

# Pitfall The fix in one line
1 trusting client input validate presence and type before every .asString()
2 not checking ledger availability test the lookupLedger result before reading
3 exposing implementation details log the real error, return a generic one
4 ignoring resource limits cap every loop and every result set
5 unguarded shared state mutex or thread-local; handlers run concurrently
6 bare error codes always add the human-readable message
7 silent failures every error path returns an error object
8 missing permission checks check context.role before doing anything sensitive

The three that cause real incidents, in code:

// Pitfall 1: trusting client input
std::string account = context.params["account"].asString();   // BAD: crashes if absent

if (!context.params.isMember("account") ||
    !context.params["account"].isString())                     // GOOD
    return rpcError(RpcInvalidParams, "Missing or invalid 'account'");
// Pitfall 2: unchecked ledger access
auto entry = context.ledger->read(k);          // BAD: context.ledger may be null

if (!context.ledger)                           // GOOD
    return rpcError(RpcNoCurrent);
auto entry = context.ledger->read(k);
if (!entry)
    return rpcError(RpcActNotFound);
// Pitfall 3: leaking internals
return rpcError(RpcInternal,
    "SQL query failed: " + std::string(ex.what()));   // BAD: leaks schema details

JLOG(context.app.journal("RPC")) << "DB error: " << ex.what();  // GOOD: log it...
return rpcError(RpcInternal);                                    // ...return generic

Watch out. Never trust client input, always check ledger availability before reading, and never leak internal details in error messages. Most handler bugs trace back to one of these three.


Performance Considerations

In brief: how to keep a handler fast under load.

Lever Bad habit Good habit
Ledger access calling lookupLedger repeatedly resolve once, reuse the ReadView
Queries one read per item, unbounded batch reads, cap the loop
Memory materialise a million-item array paginate with limit + marker
Strings chained += concatenations std::stringstream or fmt::format

The single most common offence, spelled out:

std::shared_ptr<ReadView const> ledger;
auto const result = RPC::lookupLedger(ledger, context);
if (!ledger)
    return result;
// reuse 'ledger' for every read in this handler; never look it up twice

Documentation and Maintenance

Document each handler in its header: purpose, required role and conditions, each parameter, response fields, and the error codes it can return. Keep it accurate; a stale doc is worse than none.

/**
 * account_tx_summary: paginated transaction history for an account.
 *
 * Role: USER. Conditions: NeedsCurrentLedger.
 * Params: account (required, base58), ledger_index (optional),
 *         limit (optional, 1-1000, default 100), marker (optional).
 * Errors: RpcInvalidParams, RpcActMalformed, RpcActNotFound, RpcNoCurrent.
 */
Json::Value doAccountTxSummary(RPC::JsonContext& context);

Maintenance habits that pay off:

  • Version breaking changes: register the same command name twice in the handler table with disjoint API version ranges (1..1 for the old handler, 2..UINT_MAX for the new one) instead of mutating behaviour under callers.
  • Deprecate loudly: keep the old handler working, log a deprecation warning, and add "deprecated": true plus "use_instead" to its response.
  • No hardcoded limits: read caps like page size or cache TTL from configuration, then clamp client values against them.
  • Every bug gets a regression test: name it after the issue so the suite documents history (see the testing module).

Code Review Checklist

In brief: what to check before a handler ships.

Area Verify
Functionality all parameters validated; every error path returns the right code; response matches the spec
Security no secrets; no internal details in responses; role checks present; resource limits enforced
Performance single ledger lookup; large results paginated; no unbounded memory
Maintainability naming conventions; small functions; no duplicated logic
Documentation header comment covers params, errors, role; complex sections commented
Testing happy path, every error code, every role, edge cases

Example: a Well-Implemented Handler

One handler, read in four passes. Together the four fences form the complete doAccountTransactions.

Pass 1: validate the input. Presence, type, bounds; fail fast with a specific message.

Json::Value doAccountTransactions(RPC::JsonContext& context)
{
    if (!context.params.isMember(jss::account))
        return rpcError(RpcInvalidParams, "Missing 'account' field");

    auto const account = parseBase58<AccountID>(
        context.params[jss::account].asString());
    if (!account)
        return rpcError(RpcActMalformed, "Invalid account address");

Pass 2: clamp the pagination. The client proposes, the server decides.

    unsigned int pageSize = 100;
    if (context.params.isMember("limit")) {
        if (!context.params["limit"].isUInt())
            return rpcError(RpcInvalidParams, "'limit' must be a positive integer");
        pageSize = context.params["limit"].asUInt();
        if (pageSize < 1 || pageSize > 1000)
            return rpcError(RpcInvalidParams, "'limit' must be between 1 and 1000");
    }

Pass 3: resolve the ledger once, then query with a bound.

    std::shared_ptr<ReadView const> ledger;
    auto const ledgerResult = RPC::lookupLedger(ledger, context);
    if (!ledger)
        return ledgerResult;

    std::vector<Json::Value> transactions;
    std::string nextMarker;
    for (auto seq = ledger->info().seq;
         seq > 0 && transactions.size() < pageSize; --seq) {
        // fetch ledger seq, filter by account, append...
    }

Pass 4: build the response, marker last.

    Json::Value result;
    result[jss::status] = jss::success;
    result[jss::account] = to_string(*account);
    result[jss::ledger_index] = ledger->info().seq;
    result[jss::validated] = ledger->isImmutable();
    result["transactions"] = txArray;
    if (!nextMarker.empty())
        result["marker"] = nextMarker;   // absent marker = last page
    return result;
}

The Helper Toolbox

In brief: rippled already wrote the utilities; use them instead of reinventing.

Everything below lives in src/xrpld/rpc/detail/RPCHelpers.h / .cpp unless noted.

Task Helper Returns / notes
Parse an account address RPC::accountFromString(str) optional<AccountID>; wraps parseBase58
Parse any Base58Check value parseBase58<T>(str) optional<T>; checksum verified; works for AccountID, uint256, seeds
Resolve the requested ledger RPC::lookupLedger(ledger, context) fills the out-param; returns the error JSON on failure; handles ledger_hash, ledger_index, "current", "validated"
Validate a ledger index range RPC::getLedgerRange(min, max, context) false if no ledgers; clamp user indices against it
Parse an amount (XRP or IOU) amountFromJsonNoThrow(value) optional<Amount>; accepts drops string/number or currency object
Convert types for JSON output to_string(hash / account / type) overloaded for most protocol types
Check the caller's privileges context.role is a plain Role enum: compare with Role::ADMIN, or call the free function isUnlimited(context.role) (true for ADMIN and IDENTIFIED) check early, before expensive work
Read a ledger entry ledger->read(keylet::account(id)) nullptr = not found; keylets exist for accounts, offers, trust lines, escrows...
Build an error response rpcError(code, message) standard JSON-RPC error shape; never craft error JSON by hand

lookupLedger failure modes worth knowing: RpcLgrNotFound (bad hash/index), RpcNoCurrent / RpcNoClosed (node not ready), RpcInvalidLgrRange.

The canonical handler skeleton

Every helper in its place; this is the shape reviewers expect:


Summary

This module covered the patterns that separate a working handler from a good one: rigorous input validation, checking ledger availability before reading, reusing helpers like lookupLedger and parseBase58, and returning complete, honest error responses. It also walked the common pitfalls, trusting client input, leaking internal details, and silent failures, that catch newcomers.

To remember:

  • Never trust client input: validate, then use
  • Check ledger availability (the lookupLedger result) before reading anything
  • Reuse the helpers: lookupLedger, parseBase58, accountFromString
  • Errors carry the right code and complete fields, and never leak internals
  • Mind the cost: charge resources appropriately and paginate large reads
  • Handlers run concurrently on many threads: no shared mutable state
  • Match the house style: naming, jss::, response shape
  • Watch out: silent failure (empty success on an error path) is the worst pattern in the list; always surface an error object

Next up. Clean code still needs proof. Next: testing your handler with the same framework rippled tests itself with.

Assignments

0 of 2 complete

Unlocks

Finishing this module opens up:

XRPL Academy © 2026