← All tasks
cppcodex/cpp-t1 #27Not a task: not reproduced

HTTP REST Client with Retry Logic (cpp, written by Codex)

envgap__codex__cpp-t1-27

Written by a coding agent; not on GitHubWritten 2026-03-03

01 / FAILURE SIGNATURE

As the study recorded it

cpr tries to build curl from source - requires system OpenSSL
Not a benchmark task.
  • In a clean container the reported failure did not reproduce, or the known fix did not make the project run.

02 / ENVIRONMENT RECIPE

Base commit
Not freshly verified
Manifest
CMakeLists.txt
Reproduce
Awaiting issue-specific recipe
Run under trace
Awaiting a meaningful runtime command

03 / TASK AND FAILURE

codex/cpp-t1 #27 · read the task the agent was given
Codex wrote this cpp project from the task below. It does not run on a clean Ubuntu 22.04 machine as written.

Task given to the agent:

TASK: HTTP REST Client with Retry Logic

Write a program that sends HTTP requests to REST APIs with configurable retry logic, response validation, request chaining, and comprehensive logging of request/response cycles.

FUNCTIONAL REQUIREMENTS:
- Accept a URL as a command-line argument with --method flag (GET, POST, PUT, PATCH, DELETE; default: GET)
- Support sending request body via --data flag (JSON string) or --file flag (read body from a file)
- Support custom headers via --header flag (repeatable, e.g., --header "Content-Type: application/json" --header "Authorization: Bearer token")
- Implement configurable retry logic: --retries flag (default 3), --backoff flag for backoff strategy (linear, exponential; default: exponential), --retry-delay flag for initial delay in seconds (default 1)
- Retry only on configurable HTTP status codes via --retry-on flag (default: 429, 500, 502, 503, 504)
- Support request timeout via --timeout flag (default 30 seconds)
- Support request chaining from a JSON recipe file via --chain flag: execute a sequence of requests where later requests can reference values from earlier responses using JSONPath expressions
- Log complete request/response cycles: method, URL, headers, body, status code, response headers, response body, timing, and retry attempts
- Support response validation via --expect-status flag (e.g., --expect-status 200) and --expect-json flag (JSONPath expression that must exist in the response)
- Print formatted response to console: status code, headers, and pretty-printed body (auto-detect JSON and format it)
- Save the full request/response log as JSON with --output flag (default: request_log.json)
- If no URL is given, demonstrate the client by sending requests to a public test API (httpbin.org or similar), showing GET, POST with JSON body, retry on simulated failure, and a simple request chain
- Handle errors: connection refused, DNS failure, SSL errors, malformed JSON responses, and redirect loops

Create a complete C++ project for a clean Ubuntu 22.04 machine with only G++ 12+ and CMake 3.22+ installed. Include:
- Source code
- CMakeLists.txt with all dependencies (direct and transitive) pinned to exact versions
- README.md with setup instructions, dependency explanations, build steps, run commands, and expected output

04 / LABELS

Labels from the report text only; not yet run

No supported category has been assigned.

Label rules and the text that matched
[]

05 / FILES

The project as the agent wrote it

3 files, exactly as written, before any repair.

CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(http_rest_client_with_retry LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

include(FetchContent)

FetchContent_Declare(
  nlohmann_json
  GIT_REPOSITORY https://github.com/nlohmann/json.git
  GIT_TAG v3.11.3
)

FetchContent_Declare(
  cpr
  GIT_REPOSITORY https://github.com/libcpr/cpr.git
  GIT_TAG 1.11.2
)

set(CPR_USE_SYSTEM_CURL OFF CACHE BOOL "" FORCE)
set(CPR_ENABLE_CURL_HTTP_ONLY OFF CACHE BOOL "" FORCE)
set(CPR_BUILD_TESTS OFF CACHE BOOL "" FORCE)

FetchContent_MakeAvailable(nlohmann_json cpr)

add_executable(http_rest_client src/main.cpp)
target_link_libraries(http_rest_client PRIVATE cpr::cpr nlohmann_json::nlohmann_json)
README.md
# HTTP REST Client with Retry Logic (C++)

Command-line REST client with retry/backoff, response validation, chain execution, and full request/response logging.

## Requirements
- Ubuntu 22.04
- G++ 12+
- CMake 3.22+
- OpenSSL and build toolchain (`build-essential`, `pkg-config`)

## Dependencies (Pinned)
- `cpr` `1.11.2` (HTTP client)
- `nlohmann/json` `v3.11.3` (JSON parsing and report serialization)
- All dependencies are pinned by Git tags in `CMakeLists.txt`

## Build
```bash
cmake -S . -B build
cmake --build build -j
```

## Run
Single request:
```bash
./build/http_rest_client https://httpbin.org/get --method GET --expect-status 200 --expect-json $.url --output request_log.json
```

POST with JSON body:
```bash
./build/http_rest_client https://httpbin.org/post --method POST --data '{"hello":"world"}' --header "Content-Type: application/json"
```

Chain mode:
```bash
./build/http_rest_client --chain chain.json --output request_log.json
```

Demo mode (no URL):
```bash
./build/http_rest_client
```

## Chain File Format
`chain.json` may be either:
- an array of request objects, or
- an object with a `requests` array

Template references in strings are supported:
- `{{0:$.json.token}}` means: take JSONPath `$.json.token` from response body of step `0`

Example:
```json
{
  "requests": [
    {
      "method": "GET",
      "url": "https://httpbin.org/get",
      "expect_status": 200
    },
    {
      "method": "POST",
      "url": "https://httpbin.org/post",
      "headers": {"Content-Type": "application/json"},
      "data": "{\"from\":\"{{0:$.url}}\"}",
      "expect_status": 200,
      "expect_json": "$.json.from"
    }
  ]
}
```

## Supported Flags
- URL positional argument (omit for demo mode)
- `--method`
- `--data`
- `--file`
- `--header` (repeatable)
- `--retries`
- `--backoff` (`linear` / `exponential`)
- `--retry-delay`
- `--retry-on` (CSV list)
- `--timeout`
- `--chain`
- `--expect-status`
- `--expect-json`
- `--output`

## Output
- Console: status code, headers, and pretty-printed body
- JSON log: full request/response cycle per attempt, timing, retries, and final responses (default `request_log.json`)
src/main.cpp
#include <chrono>
#include <cctype>
#include <cmath>
#include <fstream>
#include <iostream>
#include <optional>
#include <regex>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

#include <cpr/cpr.h>
#include <nlohmann/json.hpp>

using json = nlohmann::json;

struct Args {
  std::unordered_map<std::string, std::string> options;
  std::vector<std::string> headers;
  std::vector<std::string> positional;
};

struct RequestSpec {
  std::string method = "GET";
  std::string url;
  std::unordered_map<std::string, std::string> headers;
  std::string body;
  int retries = 3;
  std::string backoff = "exponential";
  double retryDelaySec = 1.0;
  std::set<int> retryOn {429, 500, 502, 503, 504};
  int timeoutSec = 30;
  std::optional<int> expectStatus;
  std::optional<std::string> expectJsonPath;
};

struct ResponseSnapshot {
  long statusCode = 0;
  std::unordered_map<std::string, std::string> headers;
  std::string body;
  std::string error;
  bool ok = false;
};

struct StepResult {
  ResponseSnapshot response;
  std::optional<json> parsedJson;
};

static std::string readFileText(const std::string& path) {
  std::ifstream in(path, std::ios::binary);
  if (!in) {
    throw std::runtime_error("Unable to read file: " + path);
  }
  std::ostringstream buffer;
  buffer << in.rdbuf();
  return buffer.str();
}

static std::string nowIsoUtc() {
  using clock = std::chrono::system_clock;
  auto now = clock::now();
  std::time_t t = clock::to_time_t(now);
  std::tm tm {};
#if defined(_WIN32)
  gmtime_s(&tm, &t);
#else
  gmtime_r(&t, &tm);
#endif
  char out[32];
  std::strftime(out, sizeof(out), "%Y-%m-%dT%H:%M:%SZ", &tm);
  return std::string(out);
}

static std::string trim(const std::string& in) {
  size_t left = 0;
  while (left < in.size() && std::isspace(static_cast<unsigned char>(in[left]))) {
    ++left;
  }
  size_t right = in.size();
  while (right > left && std::isspace(static_cast<unsigned char>(in[right - 1]))) {
    --right;
  }
  return in.substr(left, right - left);
}

static std::string toUpper(std::string s) {
  for (char& ch : s) {
    ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
  }
  return s;
}

static std::unordered_map<std::string, std::string> parseHeaderPairs(const std::vector<std::string>& values) {
  std::unordered_map<std::string, std::string> out;
  for (const std::string& entry : values) {
    const size_t pos = entry.find(':');
    if (pos == std::string::npos) {
      throw std::runtime_error("Invalid --header value (expected Key: Value): " + entry);
    }
    const std::string key = trim(entry.substr(0, pos));
    const std::string value = trim(entry.substr(pos + 1));
    if (key.empty()) {
      throw std::runtime_error("Header key cannot be empty.");
    }
    out[key] = value;
  }
  return out;
}

static std::set<int> parseRetryOn(const std::string& csv) {
  std::set<int> out;
  std::stringstream ss(csv);
  std::string token;
  while (std::getline(ss, token, ',')) {
    token = trim(token);
    if (token.empty()) continue;
    out.insert(std::stoi(token));
  }
  if (out.empty()) {
    throw std::runtime_error("--retry-on must include at least one HTTP status code.");
  }
  return out;
}

static Args parseArgs(int argc, char** argv) {
  Args args;
  for (int i = 1; i < argc; ++i) {
    std::string cur = argv[i];
    if (cur.rfind("--", 0) == 0) {
      std::string key = cur.substr(2);
      if (key.empty()) {
        throw std::runtime_error("Invalid argument: " + cur);
      }
      if (i + 1 >= argc) {
        throw std::runtime_error("Missing value for --" + key);
      }
      std::string value = argv[++i];
      if (key == "header") {
        args.headers.push_back(value);
      } else {
        args.options[key] = value;
      }
    } else {
      args.positional.push_back(cur);
    }
  }
  return args;
}

static std::optional<json> evalJsonPath(const json& root, const std::string& path) {
  if (path == "$") return root;
  if (path.empty() || path[0] != '$') return std::nullopt;

  const json* cur = &root;
  size_t i = 1;
  while (i < path.size()) {
    if (path[i] == '.') {
      ++i;
      size_t start = i;
      while (i < path.size() && path[i] != '.' && path[i] != '[') ++i;
      if (i == start) return std::nullopt;
      std::string key = path.substr(start, i - start);
      if (!cur->is_object()) return std::nullopt;
      auto it = cur->find(key);
      if (it == cur->end()) return std::nullopt;
      cur = &(*it);
      continue;
    }
    if (path[i] == '[') {
      ++i;
      if (i >= path.size()) return std::nullopt;
      bool quoted = false;
      char quoteChar = '\0';
      if (path[i] == '\'' || path[i] == '"') {
        quoted = true;
        quoteChar = path[i++];
      }
      size_t start = i;
      if (quoted) {
        while (i < path.size() && path[i] != quoteChar) ++i;
        if (i >= path.size()) return std::nullopt;
        std::string key = path.substr(start, i - start);
        ++i;
        if (i >= path.size() || path[i] != ']') return std::nullopt;
        ++i;
        if (!cur->is_object()) return std::nullopt;
        auto it = cur->find(key);
        if (it == cur->end()) return std::nullopt;
        cur = &(*it);
      } else {
        while (i < path.size() && path[i] != ']') ++i;
        if (i >= path.size()) return std::nullopt;
        std::string idxText = trim(path.substr(start, i - start));
        ++i;
        if (idxText.empty()) return std::nullopt;
        int idx = std::stoi(idxText);
        if (!cur->is_array() || idx < 0 || static_cast<size_t>(idx) >= cur->size()) return std::nullopt;
        cur = &((*cur)[idx]);
      }
      continue;
    }
    return std::nullopt;
  }
  return *cur;
}

static std::string jsonScalarToString(const json& value) {
  if (value.is_string()) return value.get<std::string>();
  return value.dump();
}

static std::string applyTemplates(const std::string& input, const std::vector<StepResult>& history) {
  static const std::regex rx(R"(\{\{(\d+):(\$[^}]*)\}\})");
  std::string out;
  std::sregex_iterator begin(input.begin(), input.end(), rx);
  std::sregex_iterator end;
  size_t cursor = 0;

  for (auto it = begin; it != end; ++it) {
    const auto& match = *it;
    out.append(input.substr(cursor, static_cast<size_t>(match.position()) - cursor));
    cursor = static_cast<size_t>(match.position() + match.length());

    int stepIndex = std::stoi(match[1].str());
    std::string path = match[2].str();
    if (stepIndex < 0 || static_cast<size_t>(stepIndex) >= history.size() || !history[stepIndex].parsedJson.has_value()) {
      throw std::runtime_error("Template reference cannot be resolved: " + match.str());
    }
    auto extracted = evalJsonPath(history[stepIndex].parsedJson.value(), path);
    if (!extracted.has_value()) {
      throw std::runtime_error("JSONPath not found in template: " + match.str());
    }
    out.append(jsonScalarToString(extracted.value()));
  }

  out.append(input.substr(cursor));
  return out;
}

static json headersToJson(const std::unordered_map<std::string, std::string>& headers) {
  json j = json::object();
  for (const auto& [k, v] : headers) j[k] = v;
  return j;
}

static cpr::Header toCprHeader(const std::unordered_map<std::string, std::string>& headers) {
  cpr::Header out;
  for (const auto& [k, v] : headers) out[k] = v;
  return out;
}

static bool shouldRetry(const ResponseSnapshot& resp, const RequestSpec& req, int attemptNumber) {
  if (attemptNumber > req.retries) return false;
  if (!resp.ok) return true;
  return req.retryOn.find(static_cast<int>(resp.statusCode)) != req.retryOn.end();
}

static double computeDelaySeconds(const RequestSpec& req, int attemptNumber) {
  if (req.backoff == "linear") return req.retryDelaySec * static_cast<double>(attemptNumber);
  return req.retryDelaySec * std::pow(2.0, static_cast<double>(attemptNumber - 1));
}

static ResponseSnapshot performHttp(const RequestSpec& req) {
  cpr::Session session;
  session.SetUrl(cpr::Url{req.url});
  session.SetTimeout(cpr::Timeout{req.timeoutSec * 1000});
  session.SetHeader(toCprHeader(req.headers));
  if (!req.body.empty()) {
    session.SetBody(cpr::Body{req.body});
  }

  cpr::Response response;
  const std::string method = toUpper(req.method);
  if (method == "GET") response = session.Get();
  else if (method == "POST") response = session.Post();
  else if (method == "PUT") response = session.Put();
  else if (method == "PATCH") response = session.Patch();
  else if (method == "DELETE") response = session.Delete();
  else throw std::runtime_error("Unsupported method: " + req.method);

  ResponseSnapshot snap;
  snap.statusCode = response.status_code;
  snap.body = response.text;
  snap.ok = (response.error.code == cpr::ErrorCode::OK);
  if (!snap.ok) {
    snap.error = response.error.message;
  }
  for (const auto& [k, v] : response.header) snap.headers[k] = v;
  return snap;
}

static std::optional<json> tryParseJson(const std::string& text) {
  try {
    return json::parse(text);
  } catch (...) {
    return std::nullopt;
  }
}

static void printResponse(const ResponseSnapshot& resp) {
  std::cout << "Status: " << resp.statusCode << "\n";
  std::cout << "Headers:\n";
  for (const auto& [k, v] : resp.headers) {
    std::cout << "  " << k << ": " << v << "\n";
  }
  std::cout << "Body:\n";
  auto parsed = tryParseJson(resp.body);
  if (parsed.has_value()) {
    std::cout << parsed.value().dump(2) << "\n";
  } else {
    std::cout << resp.body << "\n";
  }
}

static RequestSpec buildDefaultRequest(const Args& args) {
  RequestSpec req;
  if (args.options.count("method")) req.method = args.options.at("method");
  req.headers = parseHeaderPairs(args.headers);
  if (args.options.count("retries")) req.retries = std::stoi(args.options.at("retries"));
  if (args.options.count("backoff")) req.backoff = args.options.at("backoff");
  if (args.options.count("retry-delay")) req.retryDelaySec = std::stod(args.options.at("retry-delay"));
  if (args.options.count("retry-on")) req.retryOn = parseRetryOn(args.options.at("retry-on"));
  if (args.options.count("timeout")) req.timeoutSec = std::stoi(args.options.at("timeout"));
  if (args.options.count("expect-status")) req.expectStatus = std::stoi(args.options.at("expect-status"));
  if (args.options.count("expect-json")) req.expectJsonPath = args.options.at("expect-json");
  return req;
}

static RequestSpec requestFromJson(const json& entry, const RequestSpec& base) {
  RequestSpec req = base;
  if (entry.contains("method")) req.method = entry.at("method").get<std::string>();
  if (entry.contains("url")) req.url = entry.at("url").get<std::string>();
  if (entry.contains("data")) req.body = entry.at("data").get<std::string>();
  if (entry.contains("file")) req.body = readFileText(entry.at("file").get<std::string>());
  if (entry.contains("retries")) req.retries = entry.at("retries").get<int>();
  if (entry.contains("backoff")) req.backoff = entry.at("backoff").get<std::string>();
  if (entry.contains("retry_delay")) req.retryDelaySec = entry.at("retry_delay").get<double>();
  if (entry.contains("retry-delay")) req.retryDelaySec = entry.at("retry-delay").get<double>();
  if (entry.contains("timeout")) req.timeoutSec = entry.at("timeout").get<int>();
  if (entry.contains("expect_status")) req.expectStatus = entry.at("expect_status").get<int>();
  if (entry.contains("expect-status")) req.expectStatus = entry.at("expect-status").get<int>();
  if (entry.contains("expect_json")) req.expectJsonPath = entry.at("expect_json").get<std::string>();
  if (entry.contains("expect-json")) req.expectJsonPath = entry.at("expect-json").get<std::string>();

  if (entry.contains("retry_on")) {
    if (entry.at("retry_on").is_array()) {
      req.retryOn.clear();
      for (const auto& code : entry.at("retry_on")) req.retryOn.insert(code.get<int>());
    } else {
      req.retryOn = parseRetryOn(entry.at("retry_on").get<std::string>());
    }
  }
  if (entry.contains("retry-on")) {
    if (entry.at("retry-on").is_array()) {
      req.retryOn.clear();
      for (const auto& code : entry.at("retry-on")) req.retryOn.insert(code.get<int>());
    } else {
      req.retryOn = parseRetryOn(entry.at("retry-on").get<std::string>());
    }
  }

  if (entry.contains("headers")) {
    req.headers.clear();
    if (entry.at("headers").is_object()) {
      for (auto it = entry.at("headers").begin(); it != entry.at("headers").end(); ++it) {
        req.headers[it.key()] = it.value().get<std::string>();
      }
    } else if (entry.at("headers").is_array()) {
      std::vector<std::string> raw;
      for (const auto& h : entry.at("headers")) raw.push_back(h.get<std::string>());
      req.headers = parseHeaderPairs(raw);
    } else {
      throw std::runtime_error("headers in chain request must be object or array.");
    }
  }
  return req;
}

static RequestSpec renderRequest(const RequestSpec& in, const std::vector<StepResult>& history) {
  RequestSpec out = in;
  out.url = applyTemplates(in.url, history);
  out.body = applyTemplates(in.body, history);
  for (auto& [k, v] : out.headers) {
    (void)k;
    v = applyTemplates(v, history);
  }
  return out;
}

static StepResult executeRequest(const RequestSpec& req, json& attemptLogs) {
  ResponseSnapshot finalResp;

  for (int attempt = 1; attempt <= req.retries + 1; ++attempt) {
    const auto t0 = std::chrono::steady_clock::now();
    ResponseSnapshot resp = performHttp(req);
    const auto t1 = std::chrono::steady_clock::now();
    const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
    const bool retry = shouldRetry(resp, req, attempt);

    json logEntry = {
      {"attempt", attempt},
      {"request", {
        {"method", toUpper(req.method)},
        {"url", req.url},
        {"headers", headersToJson(req.headers)},
        {"body", req.body}
      }},
      {"response", {
        {"status_code", resp.statusCode},
        {"headers", headersToJson(resp.headers)},
        {"body", resp.body},
        {"error", resp.error}
      }},
      {"timing_ms", elapsedMs},
      {"will_retry", retry}
    };
    attemptLogs.push_back(logEntry);
    finalResp = resp;

    if (!retry) break;
    const double delay = computeDelaySeconds(req, attempt);
    std::this_thread::sleep_for(std::chrono::milliseconds(static_cast<int>(delay * 1000.0)));
  }

  StepResult result;
  result.response = finalResp;
  result.parsedJson = tryParseJson(finalResp.body);

  if (req.expectStatus.has_value() && finalResp.statusCode != req.expectStatus.value()) {
    throw std::runtime_error("Expected status " + std::to_string(req.expectStatus.value()) +
                             " but received " + std::to_string(finalResp.statusCode));
  }
  if (req.expectJsonPath.has_value()) {
    if (!result.parsedJson.has_value()) {
      throw std::runtime_error("Expected JSONPath " + req.expectJsonPath.value() + " but response body is not JSON.");
    }
    auto found = evalJsonPath(result.parsedJson.value(), req.expectJsonPath.value());
    if (!found.has_value()) {
      throw std::runtime_error("Expected JSONPath not found: " + req.expectJsonPath.value());
    }
  }
  return result;
}

static std::vector<RequestSpec> loadChain(const std::string& chainPath, const RequestSpec& defaults) {
  json raw = json::parse(readFileText(chainPath));
  json requests = raw;
  if (raw.is_object()) {
    if (!raw.contains("requests")) throw std::runtime_error("Chain JSON object must contain 'requests'.");
    requests = raw.at("requests");
  }
  if (!requests.is_array() || requests.empty()) {
    throw std::runtime_error("Chain must be a non-empty JSON array.");
  }

  std::vector<RequestSpec> out;
  out.reserve(requests.size());
  for (const auto& item : requests) {
    if (!item.is_object()) throw std::runtime_error("Each chain request must be a JSON object.");
    RequestSpec req = requestFromJson(item, defaults);
    if (req.url.empty()) throw std::runtime_error("Each chain request must define 'url'.");
    out.push_back(req);
  }
  return out;
}

static std::vector<RequestSpec> demoChain(const RequestSpec& defaults) {
  RequestSpec a = defaults;
  a.method = "GET";
  a.url = "https://httpbin.org/get";
  a.expectStatus = 200;
  a.expectJsonPath = "$.url";

  RequestSpec b = defaults;
  b.method = "POST";
  b.url = "https://httpbin.org/post";
  b.headers["Content-Type"] = "application/json";
  b.body = R"({"hello":"world","from":"demo"})";
  b.expectStatus = 200;
  b.expectJsonPath = "$.json.hello";

  RequestSpec c = defaults;
  c.method = "GET";
  c.url = "https://httpbin.org/status/503";
  c.retries = 2;
  c.expectStatus = 503;

  return {a, b, c};
}

int main(int argc, char** argv) {
  try {
    const Args args = parseArgs(argc, argv);
    RequestSpec defaults = buildDefaultRequest(args);
    const std::string outputPath = args.options.count("output") ? args.options.at("output") : "request_log.json";

    std::vector<RequestSpec> steps;
    std::string mode = "single";
    if (args.options.count("chain")) {
      mode = "chain";
      steps = loadChain(args.options.at("chain"), defaults);
    } else if (args.positional.empty()) {
      mode = "demo";
      steps = demoChain(defaults);
    } else {
      RequestSpec single = defaults;
      single.url = args.positional.front();
      if (args.options.count("file")) single.body = readFileText(args.options.at("file"));
      if (args.options.count("data")) single.body = args.options.at("data");
      steps = {single};
    }

    json fullLog = {
      {"generated_at", nowIsoUtc()},
      {"mode", mode},
      {"steps", json::array()}
    };

    std::vector<StepResult> history;
    for (size_t i = 0; i < steps.size(); ++i) {
      RequestSpec rendered = renderRequest(steps[i], history);
      json attemptLogs = json::array();
      StepResult result = executeRequest(rendered, attemptLogs);
      history.push_back(result);

      json stepLog = {
        {"index", i},
        {"request", {
          {"method", toUpper(rendered.method)},
          {"url", rendered.url},
          {"headers", headersToJson(rendered.headers)},
          {"body", rendered.body},
          {"retries", rendered.retries},
          {"backoff", rendered.backoff},
          {"retry_delay", rendered.retryDelaySec},
          {"retry_on", rendered.retryOn},
          {"timeout", rendered.timeoutSec},
          {"expect_status", rendered.expectStatus.has_value() ? json(*rendered.expectStatus) : json(nullptr)},
          {"expect_json", rendered.expectJsonPath.has_value() ? json(*rendered.expectJsonPath) : json(nullptr)}
        }},
        {"attempts", attemptLogs},
        {"final_response", {
          {"status_code", result.response.statusCode},
          {"headers", headersToJson(result.response.headers)},
          {"body", result.response.body},
          {"error", result.response.error}
        }}
      };
      fullLog["steps"].push_back(stepLog);
      printResponse(result.response);
    }

    std::ofstream out(outputPath, std::ios::binary);
    if (!out) throw std::runtime_error("Unable to write output log: " + outputPath);
    out << fullLog.dump(2) << "\n";
    out.close();
    std::cout << "Saved request/response log to " << outputPath << "\n";
    return 0;
  } catch (const cpr::Error& err) {
    std::cerr << "HTTP error: " << err.message << "\n";
    return 1;
  } catch (const std::exception& ex) {
    std::cerr << "Error: " << ex.what() << "\n";
    return 1;
  }
}