From 09903429efb518a3b93207091b7d66437504ca81 Mon Sep 17 00:00:00 2001 From: "F." Date: Fri, 1 May 2026 20:25:53 +0200 Subject: [PATCH 01/17] ci: add security scanning, provenance, and overhaul lint workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Codacy Security Scan workflow with a Python-based SARIF splitter to handle large result sets across multiple GitHub Advanced Security uploads - Add CodeQL analysis workflow for static analysis of Go code - Add SLSA3 provenance workflow with keyless cosign signing on release, generating a source archive with SHA-256 subjects - Rename pre-commit.yml → lint.yml; replace pre-commit runner with native Go tooling (gci, gofumpt, staticcheck, golangci-lint) and go mod tidy check - Bump cspell-cli v9.4.0 → v10.0.0 in pre-commit configs - Bump zerolog v1.35.0 → v1.35.1, zap v1.27.1 → v1.28.0, go-isatty v0.0.21 → v0.0.22; add go.yaml.in/yaml/v3 v3.0.4 --- .github/workflows/codacy.yml | 153 ++++++++++++++++++ .github/workflows/codeql.yml | 76 +++++++++ .../workflows/{pre-commit.yml => lint.yml} | 41 +++-- .github/workflows/provenance.yml | 78 +++++++++ .pre-commit-ci-config.yaml | 8 +- .pre-commit-config.yaml | 2 +- go.mod | 6 +- go.sum | 14 +- 8 files changed, 351 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/codacy.yml create mode 100644 .github/workflows/codeql.yml rename .github/workflows/{pre-commit.yml => lint.yml} (50%) create mode 100644 .github/workflows/provenance.yml diff --git a/.github/workflows/codacy.yml b/.github/workflows/codacy.yml new file mode 100644 index 0000000..229b586 --- /dev/null +++ b/.github/workflows/codacy.yml @@ -0,0 +1,153 @@ +--- +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow checks out code, performs a Codacy security scan +# and integrates the results with the +# GitHub Advanced Security code scanning feature. For more information on +# the Codacy security scan action usage and parameters, see +# https://github.com/codacy/codacy-analysis-cli-action. +# For more information on Codacy Analysis CLI in general, see +# https://github.com/codacy/codacy-analysis-cli. + +name: Codacy Security Scan + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: "40 11 * * 5" + +permissions: + contents: read + +jobs: + codacy-security-scan: + outputs: + sarif_matrix: ${{ steps.split-sarif.outputs.matrix }} + permissions: + # for actions/checkout to fetch code + contents: read + # for github/codeql-action/upload-sarif to upload SARIF results + security-events: write + # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + actions: read + name: Codacy Security Scan + runs-on: ubuntu-latest + steps: + # Checkout the repository to the GitHub Actions runner + - name: Checkout code + uses: actions/checkout@v6 + + # Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis + - name: Run Codacy Analysis CLI + uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08 + with: + # Check https://github.com/codacy/codacy-analysis-cli#project-token to get your project token from your Codacy repository + # You can also omit the token and run the tools that support default configurations + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + verbose: true + output: results.sarif + format: sarif + # Adjust severity of non-security issues + gh-code-scanning-compat: true + # Force 0 exit code to allow SARIF file generation + # This will handover control about PR rejection to the GitHub side + max-allowed-issues: 2147483647 + + - name: Normalize and split SARIF runs + id: split-sarif + run: | + set -euo pipefail + python - <<'PY' + import json, os + from pathlib import Path + + max_runs = 20 + + with open("results.sarif", "r", encoding="utf-8") as f: + sarif = json.load(f) + + runs = sarif.get("runs", []) + if not runs: + raise SystemExit("results.sarif has no runs") + + for idx, run in enumerate(runs): + auto = run.get("automationDetails") or {} + base = auto.get("id") or "codacy" + auto["id"] = f"{base}-{idx}" + run["automationDetails"] = auto + + outdir = Path("sarif-chunks") + outdir.mkdir(parents=True, exist_ok=True) + + base_payload = {k: v for k, v in sarif.items() if k != "runs"} + include = [] + + for chunk_idx, start in enumerate(range(0, len(runs), max_runs)): + chunk_runs = runs[start : start + max_runs] + chunk_file = outdir / f"results-{chunk_idx:03d}.sarif" + + payload = dict(base_payload) + payload["runs"] = chunk_runs + chunk_file.write_text(json.dumps(payload), encoding="utf-8") + + include.append( + { + "file": chunk_file.name, + "category": f"codacy-{chunk_idx:03d}", + } + ) + + matrix = json.dumps({"include": include}, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out: + out.write(f"matrix={matrix}\n") + + print(f"Prepared {len(include)} SARIF chunk(s)") + PY + + - name: Upload SARIF chunks artifact + uses: actions/upload-artifact@v7 + with: + name: codacy-sarif-chunks + path: sarif-chunks/*.sarif + if-no-files-found: error + + codacy-security-upload: + name: Codacy Security Scan Upload + needs: codacy-security-scan + if: ${{ needs.codacy-security-scan.outputs.sarif_matrix != '' }} + permissions: + # for actions/checkout to fetch code + contents: read + # for github/codeql-action/upload-sarif to upload SARIF results + security-events: write + # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + actions: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.codacy-security-scan.outputs.sarif_matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Download SARIF chunks artifact + uses: actions/download-artifact@v7 + with: + name: codacy-sarif-chunks + path: sarif-chunks + + - name: Verify SARIF chunk path + run: ls -la sarif-chunks + + - name: Upload SARIF chunk + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: sarif-chunks/${{ matrix.file }} + category: ${{ matrix.category }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..df12d74 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,76 @@ +--- +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: "33 23 * * 3" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ "go" ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/lint.yml similarity index 50% rename from .github/workflows/pre-commit.yml rename to .github/workflows/lint.yml index 39b1240..bb521c4 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/lint.yml @@ -1,13 +1,13 @@ --- -name: pre-commit +name: lint on: pull_request: push: - branches: [main] + branches: [ main ] jobs: - pre-commit: + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -18,11 +18,9 @@ jobs: source .project-settings.env set +a echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" + echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/starter}" >> "$GITHUB_OUTPUT" echo "golangci_lint_version=${GOLANGCI_LINT_VERSION}" >> "$GITHUB_OUTPUT" - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" + echo "proto_enabled=${PROTO_ENABLED:-true}" >> "$GITHUB_OUTPUT" - name: Setup Go uses: actions/setup-go@v6 with: @@ -34,15 +32,32 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ hashFiles('**/go.sum') }} + key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ + hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}- - - name: Install pre-commit - run: pip install pre-commit - - name: Install Go tools for hooks + - name: Install tools run: | go install github.com/daixiang0/gci@latest go install mvdan.cc/gofumpt@latest + go install honnef.co/go/tools/cmd/staticcheck@latest curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" "${{ steps.settings.outputs.golangci_lint_version }}" - - name: Run pre-commit - run: pre-commit run --config .pre-commit-ci-config.yaml --all-files + - name: Modules + run: go mod download + - name: Tidy check + run: | + go mod tidy + git diff --exit-code go.mod go.sum + - name: gci + run: gci write -s standard -s default -s blank -s dot -s "prefix(${{ + steps.settings.outputs.gci_prefix }})" -s localmodule --skip-vendor + --skip-generated $(find . -type f -name '*.go' -not -path + "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not + -path "./.git/*") + - name: gofumpt + run: gofumpt -l -w $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not + -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") + - name: staticcheck + run: staticcheck ./... + - name: golangci-lint + run: golangci-lint run -v ./... diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml new file mode 100644 index 0000000..4cb5099 --- /dev/null +++ b/.github/workflows/provenance.yml @@ -0,0 +1,78 @@ +--- +name: provenance + +on: + release: + types: [ published ] + workflow_dispatch: + +jobs: + source: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + outputs: + base64_subjects: ${{ steps.subjects.outputs.base64 }} + archive_name: ${{ steps.archive.outputs.archive }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Create source archive + id: archive + run: | + set -euo pipefail + ref="${GITHUB_REF_NAME}" + safe_ref="${ref//\//-}" + archive="starter-${safe_ref}.tar.gz" + git archive --format=tar.gz --prefix="starter-${safe_ref}/" -o "${archive}" "${ref}" + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + - name: Compute subjects + id: subjects + run: | + set -euo pipefail + archive="${{ steps.archive.outputs.archive }}" + sum=$(sha256sum "${archive}" | awk '{print $1}') + printf '%s %s\n' "$sum" "$archive" | base64 -w0 > subjects.b64 + echo "base64=$(cat subjects.b64)" >> "$GITHUB_OUTPUT" + - uses: sigstore/cosign-installer@v3 + - name: Sign source archive + # COSIGN_EXPERIMENTAL enables keyless signing via Sigstore (Fulcio/Rekor). + # This requires network access to the Sigstore infrastructure and will fail + # in air-gapped environments. For offline use, disable keyless mode and + # configure cosign with a non-keyless signing method instead. + env: + COSIGN_EXPERIMENTAL: "true" + run: | + set -euo pipefail + archive="${{ steps.archive.outputs.archive }}" + cosign sign-blob --yes --output-signature "${archive}.sig" --output-certificate "${archive}.pem" "${archive}" + - name: Upload source artifact + uses: actions/upload-artifact@v7 + with: + name: source-archive + path: | + ${{ steps.archive.outputs.archive }} + ${{ steps.archive.outputs.archive }}.sig + ${{ steps.archive.outputs.archive }}.pem + - name: Upload release assets + if: github.event_name == 'release' + uses: softprops/action-gh-release@v3 + with: + files: | + ${{ steps.archive.outputs.archive }} + ${{ steps.archive.outputs.archive }}.sig + ${{ steps.archive.outputs.archive }}.pem + + provenance: + needs: [ source ] + permissions: + actions: read + contents: write + id-token: write + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 + with: + base64-subjects: ${{ needs.source.outputs.base64_subjects }} + upload-assets: ${{ github.event_name == 'release' }} + upload-tag-name: ${{ github.ref_name }} diff --git a/.pre-commit-ci-config.yaml b/.pre-commit-ci-config.yaml index 2e6c7a4..cce02e3 100644 --- a/.pre-commit-ci-config.yaml +++ b/.pre-commit-ci-config.yaml @@ -12,16 +12,16 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ exclude: mkdocs.yml - args: [--allow-multiple-documents] + args: [ --allow-multiple-documents ] - repo: https://github.com/adrienverge/yamllint.git rev: v1.38.0 hooks: - id: yamllint files: \.(yaml|yml)$ - types: [file, yaml] + types: [ file, yaml ] entry: yamllint --strict -f parsable - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v9.4.0 + rev: v10.0.0 hooks: # Spell check changed files - id: cspell @@ -34,7 +34,7 @@ repos: - --no-summary - --files - .git/COMMIT_EDITMSG - stages: [commit-msg] + stages: [ commit-msg ] always_run: true - repo: https://github.com/markdownlint/markdownlint.git rev: v0.15.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7b709a..8f7c056 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: hooks: - id: hadolint-docker - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v9.4.0 + rev: v10.0.0 hooks: # Spell check changed files - id: cspell diff --git a/go.mod b/go.mod index f60cf88..fa45267 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,11 @@ require ( emperror.dev/errors v0.8.1 github.com/goccy/go-json v0.10.6 github.com/hashicorp/go-multierror v1.1.1 - github.com/rs/zerolog v1.35.0 + github.com/rs/zerolog v1.35.1 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 - go.uber.org/zap v1.27.1 + go.uber.org/zap v1.28.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,7 +19,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/go.sum b/go.sum index 97042fb..53134d7 100644 --- a/go.sum +++ b/go.sum @@ -14,14 +14,14 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= -github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -36,8 +36,10 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= From 20e8b3faf13d82f54a99f038904c1f2beb62cae7 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 09:00:48 +0200 Subject: [PATCH 02/17] refactor(core)!: zero external deps, typed fields, stdlib error chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all third-party runtime dependencies (zap, logrus, zerolog, goccy/go-json, emperror, hashicorp/multierr, uber/multierr, testify) leaving only gopkg.in/yaml.v3. Replace goccy/go-json with stdlib encoding/json throughout. Core Error struct: - Promote errorContext, recovery, and retry out of the generic metadata map into dedicated typed fields, eliminating unsafe string-keyed lookups and data races at option-application time. - Add httpStatus, retryable (tri-state *bool), safeMsg, and fullMsg fields for new cross-cutting attributes. - Cache Error() and Stack() outputs lazily via sync.Once. - Introduce newAt/wrapAt internal helpers with explicit caller-skip counts; expose NewSkip/WrapSkip for helper-wrapping use cases. - Add WithStackDepth option to cap or disable stack capture. - Remove the broken Is() override; rely solely on Unwrap() so errors.Is/As use standard pointer-identity semantics. - Lazily initialise the metadata map (nil until first WithMetadata). New surface area (attributes.go): - WithHTTPStatus / HTTPStatus – attach and read HTTP status codes from the error chain. - WithRetryable / IsRetryable – explicit tri-state retry classification with fallback to stdlib Temporary() interface. - WithSafeMessage / SafeError – redacted error messages for untrusted log sinks. Format improvements: - Implement fmt.Formatter (%s, %q, %v, %+v) on *Error. - Implement slog.LogValuer so structured loggers receive a group of fields rather than an opaque string. - standardErrorOutput walks errors.Unwrap for non-ewrap causes so JSON/YAML output preserves the full chain. Logger: - Move the Logger interface from internal/logger to the root package. - Drop all bundled third-party adapters (pkg/logger/adapters). - Add slog/ subpackage with a minimal stdlib slog.Adapter. Circuit breaker (threshold.go): - Upgrade sync.RWMutex → sync.Mutex; all state mutations are writes. - Capture transition details into a transitionEvent while holding the lock, then fire observer/callback synchronously after releasing it, eliminating the goroutine launch and the need for sleeps in tests. - Fix CanExecute lock-upgrade race: acquire write lock upfront. Tooling: - Bump golangci-lint to v2.12.1, buf to v1.69.0. - Enable gomodguard_v2, disable testpackage linter. - Add ci Makefile target; tighten .PHONY list. - Remove stale .trunk/ symlinks. BREAKING CHANGE: pkg/logger/adapters and internal/logger packages are removed. Callers using the bundled Zap/Logrus/Zerolog adapters must write their own thin adapter (≤10 lines) or switch to the new slog/ subpackage. The Logger interface is now exported from the root ewrap package instead of internal/logger. --- .cspell/custom-dictionary.txt | 2 + .github/workflows/codeql.yml | 7 +- .golangci.yaml | 15 +- .pre-commit/golangci-lint-hook | 2 +- .project-settings.env | 4 +- .trunk/actions | 1 - .trunk/logs | 1 - .trunk/notifications | 1 - .trunk/out | 1 - .trunk/tools | 1 - Makefile | 6 +- __examples/main.go | 27 +- attributes.go | 102 ++++++++ attributes_test.go | 167 ++++++++++++ context.go | 5 +- cspell.config.yaml | 7 +- error_group.go | 17 +- error_group_test.go | 4 +- errors.go | 392 ++++++++++++++++++----------- errors_test.go | 16 +- format.go | 95 +++---- format_test.go | 4 +- format_verb.go | 78 ++++++ go.mod | 24 +- go.sum | 44 ---- hardening_test.go | 132 ++++++++++ internal/logger/logger.go | 17 -- internal/logger/logger_test.go | 84 ------- logger.go | 16 ++ pkg/.gitkeep | 0 pkg/logger/adapters/logger.go | 151 ----------- pkg/logger/adapters/logger_test.go | 103 -------- pkg/logger/adapters/slog.go | 30 --- pkg/logger/adapters/slog_test.go | 37 --- retry.go | 26 +- retry_test.go | 89 +++++-- slog/slog.go | 30 +++ slog/slog_test.go | 36 +++ stack.go | 5 +- stack_test.go | 16 +- test/benchmark_test.go | 20 +- test/comparison_benchmark_test.go | 125 --------- test/load_test.go | 193 -------------- threshold.go | 99 +++++--- threshold_test.go | 29 +-- 45 files changed, 1084 insertions(+), 1177 deletions(-) delete mode 120000 .trunk/actions delete mode 120000 .trunk/logs delete mode 120000 .trunk/notifications delete mode 120000 .trunk/out delete mode 120000 .trunk/tools create mode 100644 attributes.go create mode 100644 attributes_test.go create mode 100644 format_verb.go create mode 100644 hardening_test.go delete mode 100644 internal/logger/logger.go delete mode 100644 internal/logger/logger_test.go create mode 100644 logger.go delete mode 100644 pkg/.gitkeep delete mode 100644 pkg/logger/adapters/logger.go delete mode 100644 pkg/logger/adapters/logger_test.go delete mode 100644 pkg/logger/adapters/slog.go delete mode 100644 pkg/logger/adapters/slog_test.go create mode 100644 slog/slog.go create mode 100644 slog/slog_test.go delete mode 100644 test/comparison_benchmark_test.go delete mode 100644 test/load_test.go diff --git a/.cspell/custom-dictionary.txt b/.cspell/custom-dictionary.txt index ae38da6..2de108e 100644 --- a/.cspell/custom-dictionary.txt +++ b/.cspell/custom-dictionary.txt @@ -2,8 +2,10 @@ Errorf goexit golangci +monkeypatch myapp Println retryable Retryable +subpackages uintptr diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index df12d74..6e0a2fc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,12 +48,7 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) diff --git a/.golangci.yaml b/.golangci.yaml index 40caa7f..0dce4e0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -50,17 +50,20 @@ linters: default: all enable: - wsl_v5 + - gomodguard_v2 disable: - depguard - embeddedstructfieldcheck - exhaustruct - gomoddirectives + - gomodguard - ireturn - lll - nonamedreturns - recvcheck - tagliatelle - tagalign + - testpackage - wsl settings: @@ -137,20 +140,20 @@ linters: disabled: false arguments: - max-lit-count: "3" - allow-strs: '"","-"' + allow-strs: "\"\",\"-\"" allow-ints: "-1,0,1,2,10,1024,0o600,0o644,0o700,0o755,0666,0755" allow-floats: "0.0,1.0" - name: cognitive-complexity severity: warning disabled: false - arguments: [10] + arguments: [ 10 ] - name: cyclomatic - arguments: [15] + arguments: [ 15 ] - name: max-public-structs - arguments: [10] + arguments: [ 10 ] - name: nested-structs disabled: true @@ -159,7 +162,7 @@ linters: disabled: true - name: line-length-limit - arguments: [140] + arguments: [ 140 ] - name: var-naming disabled: true @@ -167,7 +170,7 @@ linters: - name: package-comments severity: warning disabled: false - exclude: [""] + exclude: [ "" ] wrapcheck: # An array of strings that specify globs of packages to ignore. diff --git a/.pre-commit/golangci-lint-hook b/.pre-commit/golangci-lint-hook index 86718b2..d3e82ea 100755 --- a/.pre-commit/golangci-lint-hook +++ b/.pre-commit/golangci-lint-hook @@ -23,7 +23,7 @@ if [[ -f "${ROOT_DIR}/.project-settings.env" ]]; then # shellcheck disable=SC1090 source "${ROOT_DIR}/.project-settings.env" fi -GOLANGCI_LINT_VERSION="${GOLANGCI_LINT_VERSION:-v2.11.4}" +GOLANGCI_LINT_VERSION="${GOLANGCI_LINT_VERSION:-v2.12.1}" # ####################################### # Install dependencies to run the pre-commit hook diff --git a/.project-settings.env b/.project-settings.env index 6d8d0d3..a8d0ead 100644 --- a/.project-settings.env +++ b/.project-settings.env @@ -1,5 +1,5 @@ -GOLANGCI_LINT_VERSION=v2.11.4 -BUF_VERSION=v1.67.0 +GOLANGCI_LINT_VERSION=v2.12.1 +BUF_VERSION=v1.69.0 GO_VERSION=1.26.2 GCI_PREFIX=github.com/hyp3rd/ewrap PROTO_ENABLED=false diff --git a/.trunk/actions b/.trunk/actions deleted file mode 120000 index 5ab1872..0000000 --- a/.trunk/actions +++ /dev/null @@ -1 +0,0 @@ -/Users/dy14uc/.cache/trunk/repos/b5501095af04a5bf566529652ac9c455/actions \ No newline at end of file diff --git a/.trunk/logs b/.trunk/logs deleted file mode 120000 index e8d8a60..0000000 --- a/.trunk/logs +++ /dev/null @@ -1 +0,0 @@ -/Users/dy14uc/.cache/trunk/repos/b5501095af04a5bf566529652ac9c455/logs \ No newline at end of file diff --git a/.trunk/notifications b/.trunk/notifications deleted file mode 120000 index 1901140..0000000 --- a/.trunk/notifications +++ /dev/null @@ -1 +0,0 @@ -/Users/dy14uc/.cache/trunk/repos/b5501095af04a5bf566529652ac9c455/notifications \ No newline at end of file diff --git a/.trunk/out b/.trunk/out deleted file mode 120000 index 1da5560..0000000 --- a/.trunk/out +++ /dev/null @@ -1 +0,0 @@ -/Users/dy14uc/.cache/trunk/repos/b5501095af04a5bf566529652ac9c455/out \ No newline at end of file diff --git a/.trunk/tools b/.trunk/tools deleted file mode 120000 index 4faf29d..0000000 --- a/.trunk/tools +++ /dev/null @@ -1 +0,0 @@ -/Users/dy14uc/.cache/trunk/repos/b5501095af04a5bf566529652ac9c455/tools \ No newline at end of file diff --git a/Makefile b/Makefile index f23af4d..19078d4 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ include .project-settings.env -GOLANGCI_LINT_VERSION ?= v2.11.4 +GOLANGCI_LINT_VERSION ?= v2.12.1 GO_VERSION ?= 1.26.2 GCI_PREFIX ?= github.com/hyp3rd/ewrap PROTO_ENABLED ?= true @@ -99,6 +99,8 @@ sec: @echo "\nRunning gosec..." gosec -exclude-generated ./... +ci: .PHONY + # check_command_exists is a helper function that checks if a command exists. define check_command_exists @which $(1) > /dev/null 2>&1 || (echo "$(1) command not found" && exit 1) @@ -125,4 +127,4 @@ help: @echo @echo "For more information, see the project README." -.PHONY: prepare-toolchain update-toolchain sec vet test benchmark update-deps lint help +.PHONY: update-deps lint sec test test-race benchmark diff --git a/__examples/main.go b/__examples/main.go index 5ee161d..1bf4744 100644 --- a/__examples/main.go +++ b/__examples/main.go @@ -3,27 +3,16 @@ package main import ( "context" "fmt" + "log/slog" "os" "time" - "github.com/rs/zerolog" - "github.com/sirupsen/logrus" - "go.uber.org/zap" - "github.com/hyp3rd/ewrap" - "github.com/hyp3rd/ewrap/pkg/logger/adapters" + ewrapslog "github.com/hyp3rd/ewrap/slog" ) func main() { - // Initialize different loggers - zapLogger, _ := zap.NewProduction() - logrusLogger := logrus.New() - zerologLogger := zerolog.New(os.Stdout).With().Timestamp().Logger() - - // Create adapters - zapAdapter := adapters.NewZapAdapter(zapLogger) - logrusAdapter := adapters.NewLogrusAdapter(logrusLogger) - zerologAdapter := adapters.NewZerologAdapter(zerologLogger) + logger := ewrapslog.New(slog.New(slog.NewJSONHandler(os.Stdout, nil))) // Create context with request ID ctx := context.WithValue(context.Background(), "request_id", "123") @@ -31,7 +20,7 @@ func main() { // Create and format an error err := ewrap.New("database connection failed", ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithLogger(zapAdapter)). + ewrap.WithLogger(logger)). WithMetadata("host", "db.example.com"). WithMetadata("port", 5432) @@ -51,12 +40,8 @@ func main() { fmt.Fprintln(os.Stdout, "yaml", yamlOutput) - // Log the error using different loggers + // Wrap and log err = ewrap.Wrap(err, "failed to initialize application", - ewrap.WithLogger(logrusAdapter)) - err.Log() - - err = ewrap.Wrap(err, "application startup failed", - ewrap.WithLogger(zerologAdapter)) + ewrap.WithLogger(logger)) err.Log() } diff --git a/attributes.go b/attributes.go new file mode 100644 index 0000000..557a96f --- /dev/null +++ b/attributes.go @@ -0,0 +1,102 @@ +package ewrap + +import "errors" + +// WithHTTPStatus tags the error with an HTTP status code. The first non-zero +// status found while walking the chain via errors.As is what HTTPStatus +// returns to the caller. Use net/http constants (e.g. http.StatusBadRequest). +func WithHTTPStatus(status int) Option { + return func(err *Error) { + err.httpStatus = status + } +} + +// HTTPStatus walks the chain and returns the first attached HTTP status +// code, or 0 if none is set. +func HTTPStatus(err error) int { + for err != nil { + var e *Error + if errors.As(err, &e) && e.httpStatus != 0 { + return e.httpStatus + } + + err = errors.Unwrap(err) + } + + return 0 +} + +// WithRetryable marks the error as transient (true) or permanent (false). It +// is consulted by IsRetryable and by middleware deciding whether to retry. +// +// If unset, IsRetryable falls back to the stdlib Temporary() bool interface +// where available. +func WithRetryable(retryable bool) Option { + return func(err *Error) { + err.retryable = &retryable + } +} + +// Retryable reports whether the error has been explicitly classified as +// retryable. Callers usually want IsRetryable instead since that walks the +// chain and honors stdlib markers. +func (e *Error) Retryable() (value, set bool) { + if e.retryable == nil { + return false, false + } + + return *e.retryable, true +} + +// IsRetryable reports whether an error should be retried. It walks the chain +// looking for an explicit ewrap classification first; falling back to the +// stdlib `interface{ Temporary() bool }` (as exposed by net.Error and +// similar) when no explicit value has been set. +func IsRetryable(err error) bool { + for cur := err; cur != nil; cur = errors.Unwrap(cur) { + var e *Error + if errors.As(cur, &e) { + if v, set := e.Retryable(); set { + return v + } + } + + if t, ok := cur.(interface{ Temporary() bool }); ok { + return t.Temporary() + } + } + + return false +} + +// WithSafeMessage attaches a redacted variant of the error message that +// SafeError will return instead of msg. Use this when the unredacted +// message contains PII or other content that must not leak into external +// logs/sinks. +func WithSafeMessage(safe string) Option { + return func(err *Error) { + err.safeMsg = safe + } +} + +// SafeError returns a redacted version of the error chain suitable for +// logging into untrusted sinks. Each layer contributes either its +// WithSafeMessage value (if set) or its raw msg. Standard wrapped errors +// without a SafeError method are included verbatim — callers redacting +// upstream errors must wrap them in an ewrap.Error with WithSafeMessage. +func (e *Error) SafeError() string { + msg := e.msg + if e.safeMsg != "" { + msg = e.safeMsg + } + + if e.cause == nil || e.fullMsg { + return msg + } + + if c, ok := e.cause.(interface{ SafeError() string }); ok { + return msg + ": " + c.SafeError() + } + + return msg + ": " + e.cause.Error() +} diff --git a/attributes_test.go b/attributes_test.go new file mode 100644 index 0000000..fe3f1e5 --- /dev/null +++ b/attributes_test.go @@ -0,0 +1,167 @@ +package ewrap + +import ( + "errors" + "fmt" + "net/http" + "testing" +) + +func TestHTTPStatus(t *testing.T) { + t.Parallel() + + t.Run("unset returns zero", func(t *testing.T) { + t.Parallel() + + err := New("plain") + if got := HTTPStatus(err); got != 0 { + t.Errorf("got %d, want 0", got) + } + }) + + t.Run("explicit on outer error", func(t *testing.T) { + t.Parallel() + + err := New("forbidden", WithHTTPStatus(http.StatusForbidden)) + if got := HTTPStatus(err); got != http.StatusForbidden { + t.Errorf("got %d, want %d", got, http.StatusForbidden) + } + }) + + t.Run("walks chain to find status", func(t *testing.T) { + t.Parallel() + + root := New("not found", WithHTTPStatus(http.StatusNotFound)) + wrapped := fmt.Errorf("layered: %w", root) + + if got := HTTPStatus(wrapped); got != http.StatusNotFound { + t.Errorf("got %d, want %d", got, http.StatusNotFound) + } + }) + + t.Run("non-ewrap error returns zero", func(t *testing.T) { + t.Parallel() + + if got := HTTPStatus(errors.New("plain")); got != 0 { + t.Errorf("got %d, want 0", got) + } + }) +} + +func TestIsRetryable(t *testing.T) { + t.Parallel() + + t.Run("explicit retryable true", func(t *testing.T) { + t.Parallel() + + err := New("transient", WithRetryable(true)) + if !IsRetryable(err) { + t.Error("expected retryable true") + } + }) + + t.Run("explicit retryable false", func(t *testing.T) { + t.Parallel() + + err := New("permanent", WithRetryable(false)) + if IsRetryable(err) { + t.Error("expected retryable false") + } + }) + + t.Run("unset and no Temporary defaults false", func(t *testing.T) { + t.Parallel() + + if IsRetryable(New("plain")) { + t.Error("expected retryable false for unclassified error") + } + }) + + t.Run("falls through to Temporary interface", func(t *testing.T) { + t.Parallel() + + err := temporaryError{msg: "transient", temp: true} + if !IsRetryable(err) { + t.Error("expected retryable true via Temporary fallback") + } + }) + + t.Run("walks chain", func(t *testing.T) { + t.Parallel() + + inner := New("transient", WithRetryable(true)) + outer := Wrap(inner, "outer") + + if !IsRetryable(outer) { + t.Error("expected retryable true via chain inheritance") + } + }) +} + +type temporaryError struct { + msg string + temp bool +} + +func (t temporaryError) Error() string { return t.msg } +func (t temporaryError) Temporary() bool { return t.temp } + +func TestSafeError(t *testing.T) { + t.Parallel() + + t.Run("uses safe message when set", func(t *testing.T) { + t.Parallel() + + err := New("user secret123 failed", WithSafeMessage("user [redacted] failed")) + if got := err.SafeError(); got != "user [redacted] failed" { + t.Errorf("got %q, want redacted form", got) + } + }) + + t.Run("falls back to msg when no safe set", func(t *testing.T) { + t.Parallel() + + err := New("public message") + if got := err.SafeError(); got != "public message" { + t.Errorf("got %q, want %q", got, "public message") + } + }) + + t.Run("walks ewrap chain redacting each layer", func(t *testing.T) { + t.Parallel() + + root := New("token=abc", WithSafeMessage("token=[redacted]")) + wrapped := Wrap(root, "auth failed for user@example.com", + WithSafeMessage("auth failed for [redacted]")) + + got := wrapped.SafeError() + want := "auth failed for [redacted]: token=[redacted]" + + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) +} + +func TestFormatVerbs(t *testing.T) { + t.Parallel() + + err := New("boom") + + if got := fmt.Sprintf("%s", err); got != "boom" { + t.Errorf("%%s: got %q, want %q", got, "boom") + } + + if got := fmt.Sprintf("%v", err); got != "boom" { + t.Errorf("%%v: got %q, want %q", got, "boom") + } + + if got := fmt.Sprintf("%q", err); got != `"boom"` { + t.Errorf("%%q: got %q, want %q", got, `"boom"`) + } + + plus := fmt.Sprintf("%+v", err) + if plus == "boom" || plus[:len("boom")] != "boom" { + t.Errorf("%%+v: got %q, expected message followed by stack", plus) + } +} diff --git a/context.go b/context.go index dc1fe5b..7d42d24 100644 --- a/context.go +++ b/context.go @@ -94,10 +94,7 @@ func (ec *ErrorContext) String() string { func WithContext(ctx context.Context, errorType ErrorType, severity Severity) Option { return func(err *Error) { errorCtx := newErrorContext(ctx, errorType, severity) - - err.mu.Lock() - err.metadata["error_context"] = errorCtx - err.mu.Unlock() + err.errorContext = errorCtx if err.logger != nil { err.logger.Debug("error context added", diff --git a/cspell.config.yaml b/cspell.config.yaml index 4acac80..fbca3bb 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -54,8 +54,8 @@ words: - GOFILES - golines - gomod - - goroutines - GOPATH + - goroutines - GOTOOLCHAIN - govulncheck - httptest @@ -66,6 +66,7 @@ words: - logrus - milli - mkdocs + - monkeypatching - multierr - multierror - Newf @@ -74,15 +75,15 @@ words: - pbkit - popd - pushd - - Reqs - recvcheck + - Reqs - sarif - securego - shellcheck - sirupsen - staticcheck - - stretchr - strconv + - stretchr - strs - tagalign - testuser diff --git a/error_group.go b/error_group.go index 31f8c31..6ae0aec 100644 --- a/error_group.go +++ b/error_group.go @@ -1,6 +1,7 @@ package ewrap import ( + "encoding/json" "errors" "fmt" "log/slog" @@ -10,7 +11,6 @@ import ( "sync" "time" - "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) @@ -187,7 +187,9 @@ type ErrorGroupSerialization struct { Errors []SerializableError `json:"errors" yaml:"errors"` } -// toSerializableError converts an error to a SerializableError. +// toSerializableError converts an error to a SerializableError. The cause +// chain is preserved for both *Error and standard wrapped errors via +// errors.Unwrap so transport consumers do not lose context at boundaries. func toSerializableError(err error) SerializableError { if err == nil { return SerializableError{} @@ -198,13 +200,11 @@ func toSerializableError(err error) SerializableError { Type: "standard", } - // Check if it's our custom Error type customErr := &Error{} if errors.As(err, &customErr) { serErr.Type = "ewrap" serErr.StackTrace = customErr.GetStackFrames() - // Get metadata safely customErr.mu.RLock() if len(customErr.metadata) > 0 { @@ -214,11 +214,18 @@ func toSerializableError(err error) SerializableError { customErr.mu.RUnlock() - // Handle cause if customErr.cause != nil { cause := toSerializableError(customErr.cause) serErr.Cause = &cause } + + return serErr + } + + cause := errors.Unwrap(err) + if cause != nil { + c := toSerializableError(cause) + serErr.Cause = &c } return serErr diff --git a/error_group_test.go b/error_group_test.go index 99f3640..c734428 100644 --- a/error_group_test.go +++ b/error_group_test.go @@ -30,7 +30,7 @@ func TestErrorGroupPool(t *testing.T) { groups[i] = pool.Get() // Add errors - for j := 0; j < tt.numErrors; j++ { + for j := range tt.numErrors { groups[i].Add(fmt.Errorf("error %d", j)) } } @@ -101,7 +101,7 @@ func BenchmarkErrorGroupPool(b *testing.B) { b.Run("WithoutPool", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { eg := NewErrorGroup() for _, err := range sampleErrors { eg.Add(err) diff --git a/errors.go b/errors.go index aca4921..7962a8f 100644 --- a/errors.go +++ b/errors.go @@ -9,37 +9,69 @@ import ( "runtime" "strings" "sync" - - "github.com/hyp3rd/ewrap/internal/logger" ) const ( - baseLogDataSize = 4 // error, msg, stack, and potentially cause - runtimeCallers = 3 + baseLogDataSize = 4 + // defaultStackDepth is the default number of frames captured when no + // override is supplied via WithStackDepth. + defaultStackDepth = 32 + // callerSkipNew is the number of frames runtime.Callers should skip so + // the captured stack starts at the user's call site rather than inside + // ewrap. Tuned for direct calls to New / Wrap / Newf / Wrapf. + callerSkipNew = 3 ) -// Error represents a custom error type with stack trace and metadata. +// Error represents a custom error type with stack trace and structured metadata. +// +// Fields populated by package-provided options (ErrorContext, RecoverySuggestion, +// RetryInfo) are stored in dedicated typed fields so they cannot collide with +// arbitrary user-supplied metadata keys. The formatted error string and stack +// trace are computed lazily and cached on first access. type Error struct { - msg string - cause error - stack []uintptr - metadata map[string]any - logger logger.Logger - observer Observer - mu sync.RWMutex // Protects metadata, logger, and observer + msg string + cause error + stack []uintptr + metadata map[string]any + errorContext *ErrorContext + recovery *RecoverySuggestion + retry *RetryInfo + logger Logger + observer Observer + + // httpStatus carries an HTTP status code attached via WithHTTPStatus. + // Zero means unset. + httpStatus int + // retryable holds an explicit retry classification (tri-state via pointer: + // nil = not classified, &true / &false = explicit). + retryable *bool + // safeMsg is a redacted variant of msg returned by SafeError when set. + safeMsg string + + // fullMsg is set when msg already includes the cause text (e.g. constructed + // via Newf with %w). When true, Error() returns msg verbatim. + fullMsg bool + + // mu protects metadata mutation and retry mutation. Cached strings use + // sync.Once so they need no separate lock. + mu sync.RWMutex + + // Cached lazy outputs. errOnce/errStr cache Error(); stackOnce/stackStr + // cache the formatted stack trace. + errOnce sync.Once + errStr string + stackOnce sync.Once + stackStr string } // Option defines the signature for configuration options. type Option func(*Error) // WithLogger sets a logger for the error. -func WithLogger(log logger.Logger) Option { +func WithLogger(log Logger) Option { return func(err *Error) { - err.mu.Lock() err.logger = log - err.mu.Unlock() - // Log error creation if logger is available if log != nil { log.Debug("error created", "message", err.msg, @@ -52,18 +84,41 @@ func WithLogger(log logger.Logger) Option { // WithObserver sets an observer for the error. func WithObserver(observer Observer) Option { return func(err *Error) { - err.mu.Lock() err.observer = observer - err.mu.Unlock() + } +} + +// WithStackDepth overrides the default number of stack frames captured. +// Pass 0 to disable stack capture entirely; clamps to a minimum of 1 +// otherwise. Must be supplied at construction time (it has no effect on +// already-constructed errors). +func WithStackDepth(depth int) Option { + return func(err *Error) { + if depth <= 0 { + err.stack = nil + + return + } + + err.stack = capturePCs(callerSkipNew, depth) } } // New creates a new Error with a stack trace and applies the provided options. func New(msg string, opts ...Option) *Error { + return newAt(callerSkipNew, msg, opts...) +} + +// NewSkip is like New but skips an additional frames stack frames so callers +// wrapping New in a helper see their own location captured. +func NewSkip(skip int, msg string, opts ...Option) *Error { + return newAt(callerSkipNew+skip, msg, opts...) +} + +func newAt(skip int, msg string, opts ...Option) *Error { err := &Error{ - msg: msg, - stack: CaptureStack(), - metadata: make(map[string]any), + msg: msg, + stack: capturePCs(skip, defaultStackDepth), } for _, opt := range opts { @@ -73,47 +128,82 @@ func New(msg string, opts ...Option) *Error { return err } -// Newf creates a new Error with a formatted message and applies the provided options. +// Newf creates a new Error with a formatted message. +// +// If format contains the %w verb, the matching argument is preserved as the +// error's cause so that errors.Is/As walk through it. The resulting Error +// behaves like fmt.Errorf with respect to message text and unwrap chain. func Newf(format string, args ...any) *Error { - return New(fmt.Sprintf(format, args...)) + return newfAt(callerSkipNew, format, args...) } -// Wrap wraps an existing error with additional context and stack trace. +func newfAt(skip int, format string, args ...any) *Error { + if !strings.Contains(format, "%w") { + return newAt(skip+1, fmt.Sprintf(format, args...)) + } + + formatted := fmt.Errorf(format, args...) + + var cause error + if u, ok := formatted.(interface{ Unwrap() error }); ok { + cause = u.Unwrap() + } else if u, ok := formatted.(interface{ Unwrap() []error }); ok { + if causes := u.Unwrap(); len(causes) > 0 { + cause = causes[0] + } + } + + return &Error{ + msg: formatted.Error(), + cause: cause, + stack: capturePCs(skip+1, defaultStackDepth), + fullMsg: true, + } +} + +// Wrap wraps an existing error with additional context, capturing a stack +// trace at the wrap site. Returns nil if err is nil. +// +// When the wrapped error is itself an *Error, the wrapper inherits its +// metadata, error context, recovery suggestion, retry info, logger and +// observer. Each wrapper retains its own stack frames so deep chains carry +// the full call history. func Wrap(err error, msg string, opts ...Option) *Error { + return wrapAt(callerSkipNew, err, msg, opts...) +} + +// WrapSkip is like Wrap but skips an additional skip stack frames. +func WrapSkip(skip int, err error, msg string, opts ...Option) *Error { + return wrapAt(callerSkipNew+skip, err, msg, opts...) +} + +func wrapAt(skip int, err error, msg string, opts ...Option) *Error { if err == nil { return nil } - var ( - stack []uintptr - metadata map[string]any - observer Observer - log logger.Logger - wrappedErr *Error - ) - // If the error is already wrapped, preserve its stack trace and metadata - if errors.As(err, &wrappedErr) { - wrappedErr.mu.RLock() - - stack = wrappedErr.stack - // Clone metadata map using maps.Clone for simplicity - metadata = maps.Clone(wrappedErr.metadata) - observer = wrappedErr.observer - log = wrappedErr.logger - - wrappedErr.mu.RUnlock() - } else { - stack = CaptureStack() - metadata = make(map[string]any) + wrapped := &Error{ + msg: msg, + cause: err, + stack: capturePCs(skip, defaultStackDepth), } - wrapped := &Error{ - msg: msg, - cause: err, - stack: stack, - metadata: metadata, - observer: observer, - logger: log, + var inner *Error + if errors.As(err, &inner) { + inner.mu.RLock() + + if len(inner.metadata) > 0 { + wrapped.metadata = maps.Clone(inner.metadata) + } + + wrapped.errorContext = inner.errorContext + wrapped.recovery = inner.recovery + wrapped.retry = inner.retry + wrapped.observer = inner.observer + wrapped.logger = inner.logger + wrapped.httpStatus = inner.httpStatus + wrapped.retryable = inner.retryable + inner.mu.RUnlock() } for _, opt := range opts { @@ -129,16 +219,22 @@ func Wrapf(err error, format string, args ...any) *Error { return nil } - return Wrap(err, fmt.Sprintf(format, args...)) + return wrapAt(callerSkipNew, err, fmt.Sprintf(format, args...)) } -// Error implements the error interface. +// Error implements the error interface. The result is computed once on first +// call and cached; subsequent calls are lock-free reads. func (e *Error) Error() string { - if e.cause != nil { - return fmt.Sprintf("%s: %v", e.msg, e.cause) - } + e.errOnce.Do(func() { + switch { + case e.fullMsg, e.cause == nil: + e.errStr = e.msg + default: + e.errStr = e.msg + ": " + e.cause.Error() + } + }) - return e.msg + return e.errStr } // Cause returns the underlying cause of the error. @@ -147,29 +243,34 @@ func (e *Error) Cause() error { } // WithMetadata adds metadata to the error. +// +// The key namespace is reserved for user data; package-managed values (error +// context, recovery suggestion, retry info) live in dedicated accessors. func (e *Error) WithMetadata(key string, value any) *Error { e.mu.Lock() + + if e.metadata == nil { + e.metadata = make(map[string]any) + } + e.metadata[key] = value + log := e.logger + e.mu.Unlock() - if e.logger != nil { - e.logger.Debug("metadata added", + if log != nil { + log.Debug("metadata added", "key", key, "value", value, "error", e.msg, ) } - e.mu.Unlock() - return e } -// WithContext adds context information to the error. +// WithContext attaches an existing ErrorContext to the error. func (e *Error) WithContext(ctx *ErrorContext) *Error { - e.mu.Lock() - defer e.mu.Unlock() - - e.metadata["error_context"] = ctx + e.errorContext = ctx if e.logger != nil { e.logger.Debug("context added", @@ -184,9 +285,7 @@ func (e *Error) WithContext(ctx *ErrorContext) *Error { // WithRecoverySuggestion attaches recovery guidance to the error. func WithRecoverySuggestion(rs *RecoverySuggestion) Option { return func(err *Error) { - err.mu.Lock() - err.metadata["recovery_suggestion"] = rs - err.mu.Unlock() + err.recovery = rs if err.logger != nil && rs != nil { logData := []any{"message", rs.Message} @@ -203,7 +302,7 @@ func WithRecoverySuggestion(rs *RecoverySuggestion) Option { } } -// GetMetadata retrieves metadata from the error. +// GetMetadata retrieves user-defined metadata from the error. func (e *Error) GetMetadata(key string) (any, bool) { e.mu.RLock() defer e.mu.RUnlock() @@ -213,7 +312,7 @@ func (e *Error) GetMetadata(key string) (any, bool) { return val, ok } -// GetMetadataValue retrieves metadata and attempts to cast it to type T. +// GetMetadataValue retrieves user-defined metadata and casts it to type T. func GetMetadataValue[T any](e *Error, key string) (T, bool) { e.mu.RLock() defer e.mu.RUnlock() @@ -233,55 +332,62 @@ func GetMetadataValue[T any](e *Error, key string) (T, bool) { return typedVal, true } -// GetErrorContext retrieves the context from the error. +// GetErrorContext returns the structured error context, or nil if none was set. func (e *Error) GetErrorContext() *ErrorContext { - e.mu.RLock() - defer e.mu.RUnlock() + return e.errorContext +} - if ctx, ok := e.metadata["error_context"].(*ErrorContext); ok { - return ctx - } +// Recovery returns the recovery suggestion attached to the error, or nil. +func (e *Error) Recovery() *RecoverySuggestion { + return e.recovery +} - return nil +// Retry returns the retry information attached to the error, or nil. +func (e *Error) Retry() *RetryInfo { + return e.retry } -// Stack returns the stack trace as a string. +// Stack returns the stack trace as a string, with runtime and ewrap-package +// frames filtered out so callers see their own code first. The result is +// computed once and cached. func (e *Error) Stack() string { - var builder strings.Builder + e.stackOnce.Do(func() { + if len(e.stack) == 0 { + return + } - frames := runtime.CallersFrames(e.stack) + var builder strings.Builder - for { - frame, more := frames.Next() - // Skip runtime frames and error package frames - if !strings.Contains(frame.File, "runtime/") && !strings.Contains(frame.File, "ewrap/errors.go") { - _, _ = fmt.Fprintf(&builder, "%s:%d - %s\n", frame.File, frame.Line, frame.Function) - } + frames := runtime.CallersFrames(e.stack) + + for { + frame, more := frames.Next() + if !isInternalFrame(frame) { + _, _ = fmt.Fprintf(&builder, "%s:%d - %s\n", frame.File, frame.Line, frame.Function) + } - if !more { - break + if !more { + break + } } - } - return builder.String() + e.stackStr = builder.String() + }) + + return e.stackStr } // Log logs the error using the configured logger. func (e *Error) Log() { - e.mu.RLock() - observer := e.observer - log := e.logger - e.mu.RUnlock() - - if observer != nil { - observer.RecordError(e.msg) + if e.observer != nil { + e.observer.RecordError(e.msg) } - if log == nil { + if e.logger == nil { return } - // Create a metadata map for logging + e.mu.RLock() logData := make([]any, 0, len(e.metadata)*2+baseLogDataSize) logData = append(logData, "error", e.msg) @@ -291,74 +397,68 @@ func (e *Error) Log() { logData = append(logData, "stack", e.Stack()) - e.mu.RLock() - for key, val := range e.metadata { - if key == "recovery_suggestion" { - logData = e.appendRecoverySuggestion(logData, val) - - continue - } - logData = append(logData, key, val) } e.mu.RUnlock() - log.Error("error occurred", logData...) + if e.recovery != nil { + logData = appendRecoverySuggestion(logData, e.recovery) + } + + e.logger.Error("error occurred", logData...) } -// CaptureStack captures the current stack trace. +// CaptureStack captures the current stack trace at the call site using the +// default depth. func CaptureStack() []uintptr { - const depth = 32 - - var pcs [depth]uintptr - - n := runtime.Callers(runtimeCallers, pcs[:]) - - return pcs[:n] + return capturePCs(callerSkipNew, defaultStackDepth) } -// Is reports whether target matches err in the error chain. -func (e *Error) Is(target error) bool { - if target == nil { - return false +// capturePCs returns the program counters of the current call stack starting +// skip frames up. The slice is sized to depth so callers with shallow stacks +// don't carry empty trailing slots. +func capturePCs(skip, depth int) []uintptr { + if depth <= 0 { + return nil } - err := e - for err != nil { - if err.msg == target.Error() { - return true - } + pcs := make([]uintptr, depth) + n := runtime.Callers(skip, pcs) - if err.cause == nil { - return false - } + return pcs[:n] +} - if causeErr, ok := err.cause.(*Error); ok { - err = causeErr +// Unwrap provides compatibility with Go 1.13 error chains. errors.Is and +// errors.As walk the chain via this method; the package-level Is method is +// intentionally not implemented so the stdlib semantics apply unchanged. +func (e *Error) Unwrap() error { + return e.cause +} - continue - } +// isInternalFrame returns true for frames the user shouldn't see in a stack +// trace: runtime internals and ewrap's own non-test implementation. Test +// files in the same package are allowed through so users running ewrap's +// own tests still see useful traces. +func isInternalFrame(frame runtime.Frame) bool { + if strings.HasPrefix(frame.Function, "runtime.") { + return true + } - return err.cause.Error() == target.Error() + if !strings.HasPrefix(frame.Function, "github.com/hyp3rd/ewrap.") { + return false } - return false -} + if strings.HasSuffix(frame.File, "_test.go") { + return false + } -// Unwrap provides compatibility with Go 1.13 error chains. -func (e *Error) Unwrap() error { - return e.cause + return true } // appendRecoverySuggestion extracts recovery suggestion data for logging. -func (*Error) appendRecoverySuggestion(logData []any, val any) []any { - rs, ok := val.(*RecoverySuggestion) - if !ok { - return logData - } - +func appendRecoverySuggestion(logData []any, rs *RecoverySuggestion) []any { logData = append(logData, "recovery_message", rs.Message) if len(rs.Actions) > 0 { diff --git a/errors_test.go b/errors_test.go index 3d53e24..3d802e4 100644 --- a/errors_test.go +++ b/errors_test.go @@ -76,8 +76,16 @@ func TestNew(t *testing.T) { t.Error("expected stack trace to be captured") } + // metadata is initialized lazily on first WithMetadata; nil here is + // the expected steady state for an error with no user metadata. + if err.metadata != nil { + t.Errorf("expected nil metadata before first write, got %v", err.metadata) + } + + err.WithMetadata("k", "v") + if err.metadata == nil { - t.Error("expected metadata to be initialized") + t.Error("expected metadata to be initialized after WithMetadata") } }) @@ -291,9 +299,9 @@ func TestWithRecoverySuggestion(t *testing.T) { t.Error("expected info log when adding recovery suggestion") } - retrieved, ok := GetMetadataValue[*RecoverySuggestion](err, "recovery_suggestion") - if !ok || retrieved.Message != rs.Message { - t.Error("expected recovery suggestion metadata to be set") + retrieved := err.Recovery() + if retrieved == nil || retrieved.Message != rs.Message { + t.Error("expected recovery suggestion to be set") } err.Log() diff --git a/format.go b/format.go index 9cd5768..add984f 100644 --- a/format.go +++ b/format.go @@ -1,11 +1,12 @@ package ewrap import ( + "encoding/json" "errors" "fmt" + "maps" "time" - "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) @@ -42,7 +43,6 @@ func WithTimestampFormat(format string) FormatOption { return } - // Attempt to parse existing timestamp and reformat t, err := time.Parse(time.RFC3339, eo.Timestamp) if err == nil { eo.Timestamp = t.Format(format) @@ -62,91 +62,72 @@ func WithStackTrace(include bool) FormatOption { // toErrorOutput converts an Error to ErrorOutput format. func (e *Error) toErrorOutput(opts ...FormatOption) *ErrorOutput { e.mu.RLock() - defer e.mu.RUnlock() - - // Extract error context if available - var ( - ctx *ErrorContext - contextMap map[string]any - recovery *RecoverySuggestion - ) - - if rawCtx, ok := e.metadata["error_context"]; ok { - if ctx, ok = rawCtx.(*ErrorContext); ok { - contextMap = map[string]any{ - "request_id": ctx.RequestID, - "user": ctx.User, - "component": ctx.Component, - "operation": ctx.Operation, - "file": ctx.File, - "line": ctx.Line, - "environment": ctx.Environment, - } - } - } - if rawRec, ok := e.metadata["recovery_suggestion"]; ok { - recovery, ok = rawRec.(*RecoverySuggestion) - if !ok { - recovery = nil - } - } + metadataCopy := make(map[string]any, len(e.metadata)) + maps.Copy(metadataCopy, e.metadata) + + e.mu.RUnlock() - // Create base output structure output := &ErrorOutput{ Message: e.msg, Timestamp: time.Now().Format(time.RFC3339), Type: "unknown", Severity: "error", Stack: e.Stack(), - Context: contextMap, - Metadata: make(map[string]any), - Recovery: recovery, + Metadata: metadataCopy, + Recovery: e.recovery, } - // Copy metadata excluding internal keys - copyMetadata(e, output) - - // Set error type and severity if available - if ctx != nil { + if ctx := e.errorContext; ctx != nil { output.Type = ctx.Type.String() output.Severity = ctx.Severity.String() + output.Context = map[string]any{ + "request_id": ctx.RequestID, + "user": ctx.User, + "component": ctx.Component, + "operation": ctx.Operation, + "file": ctx.File, + "line": ctx.Line, + "environment": ctx.Environment, + } } - // Handle wrapped errors if e.cause != nil { var wrappedErr *Error if errors.As(e.cause, &wrappedErr) { output.Cause = wrappedErr.toErrorOutput(opts...) } else { - output.Cause = &ErrorOutput{ - Message: e.cause.Error(), - Type: "unknown", - Severity: "error", - } + output.Cause = standardErrorOutput(e.cause) } } - // Apply formatting options - applyFormatOptions(output, opts...) + for _, opt := range opts { + opt(output) + } return output } -// applyFormatOptions applies the given formatting options to the ErrorOutput. -func applyFormatOptions(output *ErrorOutput, opts ...FormatOption) { - for _, opt := range opts { - opt(output) +// standardErrorOutput renders a non-ewrap error and walks any further chain +// via errors.Unwrap so JSON/YAML output preserves the full cause history. +func standardErrorOutput(err error) *ErrorOutput { + out := &ErrorOutput{ + Message: err.Error(), + Type: "unknown", + Severity: "error", } -} -// copyMetadata copies user-defined metadata from the Error to the ErrorOutput. -func copyMetadata(e *Error, output *ErrorOutput) { - for k, v := range e.metadata { - if k != "error_context" && k != "recovery_suggestion" { - output.Metadata[k] = v + cause := errors.Unwrap(err) + if cause != nil { + var wrappedErr *Error + if errors.As(cause, &wrappedErr) { + out.Cause = wrappedErr.toErrorOutput() + } else { + out.Cause = standardErrorOutput(cause) } } + + return out } // ToJSON converts the error to a JSON string. diff --git a/format_test.go b/format_test.go index 74f007d..e058c8c 100644 --- a/format_test.go +++ b/format_test.go @@ -1,12 +1,12 @@ package ewrap import ( + "encoding/json" "errors" "strings" "testing" "time" - "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) @@ -181,6 +181,7 @@ func TestToJSON(t *testing.T) { // Verify it's valid JSON var output ErrorOutput + unmarshalErr := json.Unmarshal([]byte(jsonStr), &output) if unmarshalErr != nil { t.Errorf("Failed to unmarshal JSON: %v", unmarshalErr) @@ -221,6 +222,7 @@ func TestToYAML(t *testing.T) { // Verify it's valid YAML var output ErrorOutput + unmarshalErr := yaml.Unmarshal([]byte(yamlStr), &output) if unmarshalErr != nil { t.Errorf("Failed to unmarshal YAML: %v", unmarshalErr) diff --git a/format_verb.go b/format_verb.go new file mode 100644 index 0000000..f24cd1b --- /dev/null +++ b/format_verb.go @@ -0,0 +1,78 @@ +package ewrap + +import ( + "fmt" + "io" + "log/slog" +) + +// Format implements fmt.Formatter. It supports the canonical pkg/errors-style +// verbs: +// +// %s the error message (same as Error()) +// %q double-quoted error message +// %v the error message (default) +// %+v the error message followed by the stack trace +func (e *Error) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + if s.Flag('+') { + _, _ = io.WriteString(s, e.Error()) + _, _ = io.WriteString(s, "\n") + _, _ = io.WriteString(s, e.Stack()) + + return + } + + fallthrough + case 's': + _, _ = io.WriteString(s, e.Error()) + case 'q': + _, _ = fmt.Fprintf(s, "%q", e.Error()) + } +} + +// LogValue implements slog.LogValuer so structured loggers receive the error +// as a group of fields rather than an opaque string. +func (e *Error) LogValue() slog.Value { + attrs := []slog.Attr{ + slog.String("message", e.Error()), + } + + if ctx := e.errorContext; ctx != nil { + attrs = append(attrs, + slog.String("type", ctx.Type.String()), + slog.String("severity", ctx.Severity.String()), + ) + + if ctx.Component != "" { + attrs = append(attrs, slog.String("component", ctx.Component)) + } + + if ctx.Operation != "" { + attrs = append(attrs, slog.String("operation", ctx.Operation)) + } + + if ctx.RequestID != "" { + attrs = append(attrs, slog.String("request_id", ctx.RequestID)) + } + } + + if rs := e.recovery; rs != nil { + attrs = append(attrs, slog.String("recovery", rs.Message)) + } + + e.mu.RLock() + + for k, v := range e.metadata { + attrs = append(attrs, slog.Any(k, v)) + } + + e.mu.RUnlock() + + if e.cause != nil { + attrs = append(attrs, slog.String("cause", e.cause.Error())) + } + + return slog.GroupValue(attrs...) +} diff --git a/go.mod b/go.mod index fa45267..20bce03 100644 --- a/go.mod +++ b/go.mod @@ -2,26 +2,4 @@ module github.com/hyp3rd/ewrap go 1.26.2 -require ( - emperror.dev/emperror v0.33.0 - emperror.dev/errors v0.8.1 - github.com/goccy/go-json v0.10.6 - github.com/hashicorp/go-multierror v1.1.1 - github.com/rs/zerolog v1.35.1 - github.com/sirupsen/logrus v1.9.4 - github.com/stretchr/testify v1.10.0 - go.uber.org/multierr v1.11.0 - go.uber.org/zap v1.28.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.22 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - golang.org/x/sys v0.43.0 // indirect -) +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 53134d7..a62c313 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,3 @@ -emperror.dev/emperror v0.33.0 h1:urYop6KLYxKVpZbt9ADC4eVG3WDnJFE6Ye3j07wUu/I= -emperror.dev/emperror v0.33.0/go.mod h1:CeOIKPcppTE8wn+3xBNcdzdHMMIP77sLOHS0Ik56m+w= -emperror.dev/errors v0.8.0/go.mod h1:YcRvLPh626Ubn2xqtoprejnA5nFha+TJ+2vew48kWuE= -emperror.dev/errors v0.8.1 h1:UavXZ5cSX/4u9iyvH6aDcuGkVjeexUGJ7Ij7G4VfQT0= -emperror.dev/errors v0.8.1/go.mod h1:YcRvLPh626Ubn2xqtoprejnA5nFha+TJ+2vew48kWuE= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= -github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= -github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= -github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/hardening_test.go b/hardening_test.go new file mode 100644 index 0000000..5aca344 --- /dev/null +++ b/hardening_test.go @@ -0,0 +1,132 @@ +package ewrap + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" +) + +// TestDeepChain verifies Error()/Stack()/Unwrap walk arbitrarily deep chains +// without exhausting the stack or losing context. +func TestDeepChain(t *testing.T) { + t.Parallel() + + const depth = 200 + + root := errors.New("root") + + err := root + for i := range depth { + err = Wrap(err, fmt.Sprintf("layer-%d", i)) + } + + if !errors.Is(err, root) { + t.Fatal("errors.Is should find root through deep chain") + } + + msg := err.Error() + if !strings.Contains(msg, "root") { + t.Errorf("expected root in message, got %q", msg) + } + + if !strings.Contains(msg, fmt.Sprintf("layer-%d", depth-1)) { + t.Errorf("expected outermost layer in message, got %q", msg) + } + + var ewrapErr *Error + if !errors.As(err, &ewrapErr) { + t.Fatal("errors.As should find an ewrap.Error") + } + + if stack := ewrapErr.Stack(); stack == "" { + t.Error("expected non-empty stack on outermost layer") + } +} + +// TestErrorsIs_ContractWithSentinel covers the contract that the previous +// broken Is() implementation violated. +func TestErrorsIs_ContractWithSentinel(t *testing.T) { + t.Parallel() + + sentinel := errors.New("sentinel") + wrapped := Wrap(sentinel, "outer") + + if !errors.Is(wrapped, sentinel) { + t.Error("expected sentinel match through ewrap.Wrap") + } + + other := errors.New("sentinel") // same text, different identity + if errors.Is(wrapped, other) { + t.Error("must not match a different error with the same text") + } +} + +// TestNewfWithW asserts that %w preserves the cause chain and message. +func TestNewfWithW(t *testing.T) { + t.Parallel() + + root := errors.New("root cause") + err := Newf("wrapped: %w", root) + + if !errors.Is(err, root) { + t.Error("errors.Is must walk through %w") + } + + if got := err.Error(); got != "wrapped: root cause" { + t.Errorf("got %q, want %q", got, "wrapped: root cause") + } +} + +// TestWrapStackCapturesWrapSite verifies Wrap captures its own frames. +func TestWrapStackCapturesWrapSite(t *testing.T) { + t.Parallel() + + root := New("root") + + wrapped := wrapHelper(root) + + if len(wrapped.stack) == 0 { + t.Fatal("wrapper stack must not be empty") + } + + if wrapped.Stack() == root.Stack() { + t.Error("wrapper stack must differ from root stack") + } +} + +// wrapHelper exists to give wrapHelper a distinct frame from the test. +// +//go:noinline +func wrapHelper(err error) *Error { + return Wrap(err, "from helper") +} + +// FuzzJSONRoundTrip checks that ToJSON is robust against arbitrary inputs. +func FuzzJSONRoundTrip(f *testing.F) { + seeds := []string{"", "boom", "weird \x00 byte", strings.Repeat("a", 1024)} + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, msg string) { + err := New(msg).WithMetadata("fuzzed", true) + + jsonStr, jerr := err.ToJSON() + if jerr != nil { + t.Fatalf("ToJSON failed: %v", jerr) + } + + var out ErrorOutput + + uerr := json.Unmarshal([]byte(jsonStr), &out) + if uerr != nil { + t.Fatalf("invalid JSON produced: %v", uerr) + } + + if out.Message != msg { + t.Errorf("message round-trip lost data: got %q, want %q", out.Message, msg) + } + }) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go deleted file mode 100644 index 4be0752..0000000 --- a/internal/logger/logger.go +++ /dev/null @@ -1,17 +0,0 @@ -// Package logger provides a standardized logging interface for the application. -// It defines a Logger interface that supports logging at different severity levels (Error, Debug, Info) -// with support for structured logging through key-value pairs. -// -// The logger package is designed to be implementation-agnostic, allowing different -// logging backends to be used as long as they implement the Logger interface. -package logger - -// Logger defines the interface for error logging. -type Logger interface { - // Error logs an error message with optional key-value pairs - Error(msg string, keysAndValues ...any) - // Debug logs a debug message with optional key-value pairs - Debug(msg string, keysAndValues ...any) - // Info logs an info message with optional key-value pairs - Info(msg string, keysAndValues ...any) -} diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go deleted file mode 100644 index d00d304..0000000 --- a/internal/logger/logger_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package logger - -import ( - "testing" - - "github.com/stretchr/testify/mock" -) - -type MockLogger struct { - mock.Mock -} - -func (m *MockLogger) Error(msg string, keysAndValues ...any) { - args := []any{msg} - args = append(args, keysAndValues...) - m.Called(args...) -} - -func (m *MockLogger) Debug(msg string, keysAndValues ...any) { - args := []any{msg} - args = append(args, keysAndValues...) - m.Called(args...) -} - -func (m *MockLogger) Info(msg string, keysAndValues ...any) { - args := []any{msg} - args = append(args, keysAndValues...) - m.Called(args...) -} - -func TestLoggerInterface(t *testing.T) { - mockLogger := new(MockLogger) - - t.Run("EmptyKeyValues", func(t *testing.T) { - mockLogger.On("Error", "error message").Return() - mockLogger.On("Debug", "debug message").Return() - mockLogger.On("Info", "info message").Return() - - mockLogger.Error("error message") - mockLogger.Debug("debug message") - mockLogger.Info("info message") - - mockLogger.AssertExpectations(t) - }) - - t.Run("NilKeyValues", func(t *testing.T) { - mockLogger := new(MockLogger) - mockLogger.On("Error", "error message", nil).Return() - mockLogger.On("Debug", "debug message", nil).Return() - mockLogger.On("Info", "info message", nil).Return() - - mockLogger.Error("error message", nil) - mockLogger.Debug("debug message", nil) - mockLogger.Info("info message", nil) - - mockLogger.AssertExpectations(t) - }) - - t.Run("MultipleKeyValuePairs", func(t *testing.T) { - mockLogger := new(MockLogger) - mockLogger.On("Error", "error message", "key1", "value1", "key2", 42).Return() - mockLogger.On("Debug", "debug message", "key1", true, "key2", 3.14).Return() - mockLogger.On("Info", "info message", "key1", []string{"val1", "val2"}, "key2", map[string]int{"a": 1}).Return() - - mockLogger.Error("error message", "key1", "value1", "key2", 42) - mockLogger.Debug("debug message", "key1", true, "key2", 3.14) - mockLogger.Info("info message", "key1", []string{"val1", "val2"}, "key2", map[string]int{"a": 1}) - - mockLogger.AssertExpectations(t) - }) - - t.Run("EmptyMessage", func(t *testing.T) { - mockLogger := new(MockLogger) - mockLogger.On("Error", "", "key1", "value1").Return() - mockLogger.On("Debug", "", "key1", "value1").Return() - mockLogger.On("Info", "", "key1", "value1").Return() - - mockLogger.Error("", "key1", "value1") - mockLogger.Debug("", "key1", "value1") - mockLogger.Info("", "key1", "value1") - - mockLogger.AssertExpectations(t) - }) -} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..09c1140 --- /dev/null +++ b/logger.go @@ -0,0 +1,16 @@ +package ewrap + +// Logger defines the minimal logging interface ewrap depends on. Any logging +// library can satisfy it with a small adapter; no external logger is bundled. +// +// Implementations must accept structured key/value pairs as the variadic +// arguments. Adapters for slog live in subpackages; for zap, zerolog, logrus, +// users write their own (≤10 lines) and pass them via WithLogger. +type Logger interface { + // Error logs an error message with optional key-value pairs. + Error(msg string, keysAndValues ...any) + // Debug logs a debug message with optional key-value pairs. + Debug(msg string, keysAndValues ...any) + // Info logs an info message with optional key-value pairs. + Info(msg string, keysAndValues ...any) +} diff --git a/pkg/.gitkeep b/pkg/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/pkg/logger/adapters/logger.go b/pkg/logger/adapters/logger.go deleted file mode 100644 index 925803f..0000000 --- a/pkg/logger/adapters/logger.go +++ /dev/null @@ -1,151 +0,0 @@ -// Package adapters provides logging adapters for popular logging frameworks -package adapters - -import ( - "github.com/rs/zerolog" - "github.com/sirupsen/logrus" - "go.uber.org/zap" -) - -const keyValuePairSize = 2 - -// ZapAdapter adapts Zap logger to the ewrap.Logger interface. -type ZapAdapter struct { - logger *zap.Logger -} - -// NewZapAdapter creates a new Zap logger adapter. -func NewZapAdapter(logger *zap.Logger) *ZapAdapter { - return &ZapAdapter{logger: logger} -} - -// Error logs an error message with optional key-value pairs. -func (z *ZapAdapter) Error(msg string, keysAndValues ...any) { - fields := convertToZapFields(keysAndValues...) - z.logger.Error(msg, fields...) -} - -// Debug logs a debug message with optional key-value pairs. -func (z *ZapAdapter) Debug(msg string, keysAndValues ...any) { - fields := convertToZapFields(keysAndValues...) - z.logger.Debug(msg, fields...) -} - -// Info logs an info message with optional key-value pairs. -func (z *ZapAdapter) Info(msg string, keysAndValues ...any) { - fields := convertToZapFields(keysAndValues...) - z.logger.Info(msg, fields...) -} - -// LogrusAdapter adapts Logrus logger to the ewrap.Logger interface. -type LogrusAdapter struct { - logger *logrus.Logger -} - -// NewLogrusAdapter creates a new Logrus logger adapter. -func NewLogrusAdapter(logger *logrus.Logger) *LogrusAdapter { - return &LogrusAdapter{logger: logger} -} - -// Error logs an error message with optional key-value pairs. -func (l *LogrusAdapter) Error(msg string, keysAndValues ...any) { - fields := convertToLogrusFields(keysAndValues...) - l.logger.WithFields(fields).Error(msg) -} - -// Debug logs a debug message with optional key-value pairs. -func (l *LogrusAdapter) Debug(msg string, keysAndValues ...any) { - fields := convertToLogrusFields(keysAndValues...) - l.logger.WithFields(fields).Debug(msg) -} - -// Info logs an info message with optional key-value pairs. -func (l *LogrusAdapter) Info(msg string, keysAndValues ...any) { - fields := convertToLogrusFields(keysAndValues...) - l.logger.WithFields(fields).Info(msg) -} - -// ZerologAdapter adapts Zerolog logger to the ewrap.Logger interface. -type ZerologAdapter struct { - logger zerolog.Logger -} - -// NewZerologAdapter creates a new Zerolog logger adapter. -func NewZerologAdapter(logger zerolog.Logger) *ZerologAdapter { - return &ZerologAdapter{logger: logger} -} - -// Error logs an error message with optional key-value pairs. -func (z *ZerologAdapter) Error(msg string, keysAndValues ...any) { - event := z.logger.Error() - addZerologFields(event, keysAndValues...) - event.Msg(msg) -} - -// Debug logs a debug message with optional key-value pairs. -func (z *ZerologAdapter) Debug(msg string, keysAndValues ...any) { - event := z.logger.Debug() - addZerologFields(event, keysAndValues...) - event.Msg(msg) -} - -// Info logs an info message with optional key-value pairs. -func (z *ZerologAdapter) Info(msg string, keysAndValues ...any) { - event := z.logger.Info() - addZerologFields(event, keysAndValues...) - event.Msg(msg) -} - -// Helper functions to convert key-value pairs to logger-specific formats. -func convertToZapFields(keysAndValues ...any) []zap.Field { - fields := make([]zap.Field, 0, len(keysAndValues)/keyValuePairSize) - - for i := 0; i < len(keysAndValues); i += keyValuePairSize { - if i+1 < len(keysAndValues) { - key, ok := keysAndValues[i].(string) - if !ok { - continue - } - - fields = append(fields, zap.Any(key, keysAndValues[i+1])) - } - } - - return fields -} - -// convertToLogrusFields converts key-value pairs to Logrus fields. -// It iterates over the provided key-value pairs and adds them to a Logrus fields map. -// If the number of arguments is odd, the last argument is ignored. -func convertToLogrusFields(keysAndValues ...any) logrus.Fields { - fields := make(logrus.Fields) - - for i := 0; i < len(keysAndValues); i += 2 { - if i+1 < len(keysAndValues) { - key, ok := keysAndValues[i].(string) - if !ok { - continue - } - - fields[key] = keysAndValues[i+1] - } - } - - return fields -} - -// addZerologFields adds key-value pairs to a Zerolog event. -// It iterates over the provided key-value pairs and adds them to the event. -// If the number of arguments is odd, the last argument is ignored. -func addZerologFields(event *zerolog.Event, keysAndValues ...any) { - for i := 0; i < len(keysAndValues); i += 2 { - if i+1 < len(keysAndValues) { - key, ok := keysAndValues[i].(string) - if !ok { - continue - } - - event.Interface(key, keysAndValues[i+1]) - } - } -} diff --git a/pkg/logger/adapters/logger_test.go b/pkg/logger/adapters/logger_test.go deleted file mode 100644 index cec498d..0000000 --- a/pkg/logger/adapters/logger_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package adapters - -import ( - "bytes" - "testing" - - "github.com/rs/zerolog" - "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest/observer" -) - -func TestZapAdapter(t *testing.T) { - core, recorded := observer.New(zapcore.InfoLevel) - logger := zap.New(core) - adapter := NewZapAdapter(logger) - - t.Run("LogLevels", func(t *testing.T) { - adapter.Error("error message", "key1", "value1") - adapter.Info("info message", "key2", "value2") - adapter.Debug("debug message", "key3", "value3") - - entries := recorded.All() - assert.Len(t, entries, 2) - assert.Equal(t, "error message", entries[0].Message) - assert.Equal(t, "info message", entries[1].Message) - }) - - t.Run("InvalidKeyValuePairs", func(t *testing.T) { - recorded.TakeAll() - adapter.Info("test", 123, "value", "extra") - - entries := recorded.All() - assert.Len(t, entries, 1) - assert.Empty(t, entries[0].ContextMap()) - }) -} - -func TestLogrusAdapter(t *testing.T) { - var buf bytes.Buffer - - logger := logrus.New() - logger.Out = &buf - adapter := NewLogrusAdapter(logger) - - t.Run("LogLevels", func(t *testing.T) { - buf.Reset() - adapter.Error("error message", "key1", "value1") - assert.Contains(t, buf.String(), "error message") - assert.Contains(t, buf.String(), "key1") - assert.Contains(t, buf.String(), "value1") - - buf.Reset() - adapter.Info("info message", "key2", "value2") - assert.Contains(t, buf.String(), "info message") - assert.Contains(t, buf.String(), "key2") - assert.Contains(t, buf.String(), "value2") - }) - - t.Run("MalformedKeyValuePairs", func(t *testing.T) { - buf.Reset() - adapter.Info("test", "single_key") - assert.Contains(t, buf.String(), "test") - assert.NotContains(t, buf.String(), "single_key") - }) -} - -func TestZerologAdapter(t *testing.T) { - var buf bytes.Buffer - - logger := zerolog.New(&buf) - adapter := NewZerologAdapter(logger) - - t.Run("LogLevels", func(t *testing.T) { - buf.Reset() - adapter.Error("error message", "key1", "value1") - - output := buf.String() - assert.Contains(t, output, "error message") - assert.Contains(t, output, "key1") - assert.Contains(t, output, "value1") - - buf.Reset() - adapter.Info("info message", "key2", 42) - - output = buf.String() - assert.Contains(t, output, "info message") - assert.Contains(t, output, "key2") - assert.Contains(t, output, "42") - }) - - t.Run("NonStringKeys", func(t *testing.T) { - buf.Reset() - adapter.Info("test", 123, "value") - - output := buf.String() - assert.Contains(t, output, "test") - assert.NotContains(t, output, "123") - assert.NotContains(t, output, "value") - }) -} diff --git a/pkg/logger/adapters/slog.go b/pkg/logger/adapters/slog.go deleted file mode 100644 index 71dc5d0..0000000 --- a/pkg/logger/adapters/slog.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build go1.21 - -package adapters - -import "log/slog" - -// SlogAdapter adapts slog.Logger to the ewrap.Logger interface. -type SlogAdapter struct { - logger *slog.Logger -} - -// NewSlogAdapter creates a new slog logger adapter. -func NewSlogAdapter(logger *slog.Logger) *SlogAdapter { - return &SlogAdapter{logger: logger} -} - -// Error logs an error message with optional key-value pairs. -func (s *SlogAdapter) Error(msg string, keysAndValues ...any) { - s.logger.Error(msg, keysAndValues...) -} - -// Debug logs a debug message with optional key-value pairs. -func (s *SlogAdapter) Debug(msg string, keysAndValues ...any) { - s.logger.Debug(msg, keysAndValues...) -} - -// Info logs an info message with optional key-value pairs. -func (s *SlogAdapter) Info(msg string, keysAndValues ...any) { - s.logger.Info(msg, keysAndValues...) -} diff --git a/pkg/logger/adapters/slog_test.go b/pkg/logger/adapters/slog_test.go deleted file mode 100644 index acc3c98..0000000 --- a/pkg/logger/adapters/slog_test.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build go1.21 - -package adapters - -import ( - "bytes" - "log/slog" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSlogAdapter(t *testing.T) { - var buf bytes.Buffer - - handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) - logger := slog.New(handler) - adapter := NewSlogAdapter(logger) - - t.Run("LogLevels", func(t *testing.T) { - buf.Reset() - adapter.Error("error message", "key1", "value1") - - output := buf.String() - assert.Contains(t, output, "error message") - assert.Contains(t, output, "key1") - assert.Contains(t, output, "value1") - - buf.Reset() - adapter.Debug("debug message", "key2", "value2") - - output = buf.String() - assert.Contains(t, output, "debug message") - assert.Contains(t, output, "key2") - assert.Contains(t, output, "value2") - }) -} diff --git a/retry.go b/retry.go index 4f2c353..5f42c29 100644 --- a/retry.go +++ b/retry.go @@ -36,9 +36,7 @@ func WithRetry(maxAttempts int, delay time.Duration, opts ...RetryOption) Option opt(retryInfo) } - err.mu.Lock() - err.metadata["retry_info"] = retryInfo - err.mu.Unlock() + err.retry = retryInfo } } @@ -52,13 +50,11 @@ func WithRetryShould(fn func(error) bool) RetryOption { } // defaultShouldRetry is the default retry decision function. +// Validation errors are not retried by default. func defaultShouldRetry(err error) bool { - // Don't retry validation errors var wrappedErr *Error - if errors.As(err, &wrappedErr) { - if ctx, ok := wrappedErr.metadata["error_context"].(*ErrorContext); ok { - return ctx.Type != ErrorTypeValidation - } + if errors.As(err, &wrappedErr) && wrappedErr.errorContext != nil { + return wrappedErr.errorContext.Type != ErrorTypeValidation } return true @@ -67,10 +63,10 @@ func defaultShouldRetry(err error) bool { // CanRetry checks if the error can be retried. func (e *Error) CanRetry() bool { e.mu.RLock() - defer e.mu.RUnlock() + retryInfo := e.retry + e.mu.RUnlock() - retryInfo, ok := e.metadata["retry_info"].(*RetryInfo) - if !ok { + if retryInfo == nil { return false } @@ -83,8 +79,10 @@ func (e *Error) IncrementRetry() { e.mu.Lock() defer e.mu.Unlock() - if retryInfo, ok := e.metadata["retry_info"].(*RetryInfo); ok { - retryInfo.CurrentAttempt++ - retryInfo.LastAttempt = time.Now() + if e.retry == nil { + return } + + e.retry.CurrentAttempt++ + e.retry.LastAttempt = time.Now() } diff --git a/retry_test.go b/retry_test.go index d3c4e19..05c5cec 100644 --- a/retry_test.go +++ b/retry_test.go @@ -3,8 +3,6 @@ package ewrap import ( "testing" "time" - - "github.com/stretchr/testify/assert" ) func TestWithRetry(t *testing.T) { @@ -12,33 +10,63 @@ func TestWithRetry(t *testing.T) { delay := time.Second err := New("test error", WithRetry(maxAttempts, delay)) - retryInfo, ok := err.metadata["retry_info"].(*RetryInfo) - assert.True(t, ok) - assert.Equal(t, maxAttempts, retryInfo.MaxAttempts) - assert.Equal(t, delay, retryInfo.Delay) - assert.Equal(t, 0, retryInfo.CurrentAttempt) - assert.NotZero(t, retryInfo.LastAttempt) - assert.NotNil(t, retryInfo.ShouldRetry) + retryInfo := err.Retry() + if retryInfo == nil { + t.Fatal("expected non-nil retry info") + } + + if retryInfo.MaxAttempts != maxAttempts { + t.Errorf("MaxAttempts: got %d, want %d", retryInfo.MaxAttempts, maxAttempts) + } + + if retryInfo.Delay != delay { + t.Errorf("Delay: got %v, want %v", retryInfo.Delay, delay) + } + + if retryInfo.CurrentAttempt != 0 { + t.Errorf("CurrentAttempt: got %d, want 0", retryInfo.CurrentAttempt) + } + + if retryInfo.LastAttempt.IsZero() { + t.Error("LastAttempt: expected non-zero time") + } + + if retryInfo.ShouldRetry == nil { + t.Error("ShouldRetry: expected non-nil predicate") + } } func TestCanRetry(t *testing.T) { t.Run("WithValidRetryInfo", func(t *testing.T) { err := New("test error", WithRetry(3, time.Second)) - assert.True(t, err.CanRetry()) + if !err.CanRetry() { + t.Error("expected CanRetry true with attempts remaining") + } err.IncrementRetry() - assert.True(t, err.CanRetry()) + + if !err.CanRetry() { + t.Error("expected CanRetry true after first increment") + } err.IncrementRetry() - assert.True(t, err.CanRetry()) + + if !err.CanRetry() { + t.Error("expected CanRetry true after second increment") + } err.IncrementRetry() - assert.False(t, err.CanRetry()) + + if err.CanRetry() { + t.Error("expected CanRetry false after maxAttempts increments") + } }) t.Run("WithoutRetryInfo", func(t *testing.T) { err := New("test error") - assert.False(t, err.CanRetry()) + if err.CanRetry() { + t.Error("expected CanRetry false without retry info") + } }) } @@ -46,39 +74,52 @@ func TestWithRetryCustomShouldRetry(t *testing.T) { shouldRetry := func(error) bool { return false } err := New("test error", WithRetry(3, time.Second, WithRetryShould(shouldRetry))) - assert.False(t, err.CanRetry()) + if err.CanRetry() { + t.Error("expected CanRetry false with predicate returning false") + } } func TestDefaultShouldRetry(t *testing.T) { t.Run("ValidationError", func(t *testing.T) { err := New("validation error"). - WithMetadata("error_context", &ErrorContext{Type: ErrorTypeValidation}) - assert.False(t, defaultShouldRetry(err)) + WithContext(&ErrorContext{Type: ErrorTypeValidation}) + if defaultShouldRetry(err) { + t.Error("expected defaultShouldRetry false for validation error") + } }) t.Run("OtherError", func(t *testing.T) { err := New("other error"). - WithMetadata("error_context", &ErrorContext{Type: ErrorTypeInternal}) - assert.True(t, defaultShouldRetry(err)) + WithContext(&ErrorContext{Type: ErrorTypeInternal}) + if !defaultShouldRetry(err) { + t.Error("expected defaultShouldRetry true for internal error") + } }) t.Run("NoContext", func(t *testing.T) { err := New("no context error") - assert.True(t, defaultShouldRetry(err)) + if !defaultShouldRetry(err) { + t.Error("expected defaultShouldRetry true when no context set") + } }) } func TestIncrementRetry(t *testing.T) { t.Run("WithRetryInfo", func(t *testing.T) { err := New("test error", WithRetry(3, time.Second)) - initialTime := err.metadata["retry_info"].(*RetryInfo).LastAttempt + initialTime := err.Retry().LastAttempt time.Sleep(time.Millisecond) err.IncrementRetry() - retryInfo := err.metadata["retry_info"].(*RetryInfo) - assert.Equal(t, 1, retryInfo.CurrentAttempt) - assert.True(t, retryInfo.LastAttempt.After(initialTime)) + retryInfo := err.Retry() + if retryInfo.CurrentAttempt != 1 { + t.Errorf("CurrentAttempt: got %d, want 1", retryInfo.CurrentAttempt) + } + + if !retryInfo.LastAttempt.After(initialTime) { + t.Error("LastAttempt: expected to advance after increment") + } }) t.Run("WithoutRetryInfo", func(t *testing.T) { diff --git a/slog/slog.go b/slog/slog.go new file mode 100644 index 0000000..92a2770 --- /dev/null +++ b/slog/slog.go @@ -0,0 +1,30 @@ +// Package slog provides an adapter that lets a stdlib *slog.Logger satisfy +// the ewrap.Logger interface. +package slog + +import "log/slog" + +// Adapter wraps a *slog.Logger so it can be passed to ewrap.WithLogger. +type Adapter struct { + logger *slog.Logger +} + +// New returns an Adapter backed by logger. +func New(logger *slog.Logger) *Adapter { + return &Adapter{logger: logger} +} + +// Error logs an error message with optional key-value pairs. +func (a *Adapter) Error(msg string, keysAndValues ...any) { + a.logger.Error(msg, keysAndValues...) +} + +// Debug logs a debug message with optional key-value pairs. +func (a *Adapter) Debug(msg string, keysAndValues ...any) { + a.logger.Debug(msg, keysAndValues...) +} + +// Info logs an info message with optional key-value pairs. +func (a *Adapter) Info(msg string, keysAndValues ...any) { + a.logger.Info(msg, keysAndValues...) +} diff --git a/slog/slog_test.go b/slog/slog_test.go new file mode 100644 index 0000000..e0111b8 --- /dev/null +++ b/slog/slog_test.go @@ -0,0 +1,36 @@ +package slog + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestAdapter(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + adapter := New(slog.New(handler)) + + cases := []struct { + level string + emit func() + }{ + {level: "ERROR", emit: func() { adapter.Error("error message", "key1", "value1") }}, + {level: "DEBUG", emit: func() { adapter.Debug("debug message", "key2", "value2") }}, + {level: "INFO", emit: func() { adapter.Info("info message", "key3", "value3") }}, + } + + for _, tc := range cases { + buf.Reset() + tc.emit() + + out := buf.String() + if !strings.Contains(out, tc.level) { + t.Errorf("expected level %q in output, got %q", tc.level, out) + } + } +} diff --git a/stack.go b/stack.go index 7d439ca..f73147d 100644 --- a/stack.go +++ b/stack.go @@ -2,7 +2,6 @@ package ewrap import ( "runtime" - "strings" ) // StackFrame represents a single frame in a stack trace. @@ -34,9 +33,7 @@ func NewStackIterator(pcs []uintptr) *StackIterator { for { frame, more := callersFrames.Next() - // Skip runtime frames and error package frames - if !strings.Contains(frame.File, "runtime/") && - !strings.Contains(frame.File, "ewrap/errors.go") { + if !isInternalFrame(frame) { frames = append(frames, StackFrame{ Function: frame.Function, File: frame.File, diff --git a/stack_test.go b/stack_test.go index 4ee23e3..e21a540 100644 --- a/stack_test.go +++ b/stack_test.go @@ -1,11 +1,11 @@ package ewrap import ( + "encoding/json" "errors" "strings" "testing" - "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) @@ -16,11 +16,13 @@ func TestStackIterator(t *testing.T) { // Test HasNext and Next frameCount := 0 + for iterator.HasNext() { frame := iterator.Next() if frame == nil { t.Error("Expected frame, got nil") } + frameCount++ } @@ -35,6 +37,7 @@ func TestStackIterator(t *testing.T) { // Test Reset iterator.Reset() + if !iterator.HasNext() { t.Error("Expected frames after reset") } @@ -58,12 +61,15 @@ func TestStackFrameStructure(t *testing.T) { if frame.Function == "" { t.Error("Expected function name") } + if frame.File == "" { t.Error("Expected file name") } + if frame.Line == 0 { t.Error("Expected line number") } + if frame.PC == 0 { t.Error("Expected program counter") } @@ -248,13 +254,14 @@ func TestEmptyErrorGroupSerialization(t *testing.T) { func BenchmarkErrorGroupSerialization(b *testing.B) { eg := NewErrorGroup() - for i := 0; i < 10; i++ { + for i := range 10 { eg.Add(New("error").WithMetadata("index", i)) } b.Run("JSON", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + + for range b.N { _, err := eg.ToJSON() if err != nil { b.Fatal(err) @@ -264,7 +271,8 @@ func BenchmarkErrorGroupSerialization(b *testing.B) { b.Run("YAML", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + + for range b.N { _, err := eg.ToYAML() if err != nil { b.Fatal(err) diff --git a/test/benchmark_test.go b/test/benchmark_test.go index 247c08c..0200b0b 100644 --- a/test/benchmark_test.go +++ b/test/benchmark_test.go @@ -42,7 +42,7 @@ func BenchmarkNew(b *testing.B) { b.Run("WithLogger", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = ewrap.New("error with logger", ewrap.WithLogger(logger)) } @@ -70,7 +70,7 @@ func BenchmarkWrap(b *testing.B) { b.Run("Simple", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = ewrap.Wrap(baseErr, "wrapped error") } }) @@ -78,7 +78,7 @@ func BenchmarkWrap(b *testing.B) { b.Run("NestedWraps", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { err1 := ewrap.Wrap(baseErr, "level 1") err2 := ewrap.Wrap(err1, "level 2") _ = ewrap.Wrap(err2, "level 3") @@ -88,7 +88,7 @@ func BenchmarkWrap(b *testing.B) { b.Run("WithContext", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = ewrap.Wrap(baseErr, "wrapped with context", ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) } @@ -97,7 +97,7 @@ func BenchmarkWrap(b *testing.B) { b.Run("FullFeatures", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _ = ewrap.Wrap(baseErr, "full featured wrap", ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError), ewrap.WithLogger(logger)). @@ -111,7 +111,7 @@ func BenchmarkErrorGroup(b *testing.B) { b.Run("AddErrors", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { group := ewrap.NewErrorGroup() for j := range 10 { @@ -151,7 +151,7 @@ func BenchmarkFormatting(b *testing.B) { b.Run("ToJSONWithOptions", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { _, _ = err.ToJSON( ewrap.WithTimestampFormat(time.RFC3339), ewrap.WithStackTrace(true), @@ -175,7 +175,7 @@ func BenchmarkCircuitBreaker(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for i := range b.N { cb.RecordFailure() if i%5 == 0 { @@ -204,7 +204,7 @@ func BenchmarkMetadataOperations(b *testing.B) { b.Run("AddMetadata", func(b *testing.B) { b.ReportAllocs() - for i := 0; i < b.N; i++ { + for range b.N { err := ewrap.New("test error") for j := range 5 { err.WithMetadata(fmt.Sprintf("key%d", j), j) @@ -221,7 +221,7 @@ func BenchmarkMetadataOperations(b *testing.B) { b.ReportAllocs() b.ResetTimer() - for i := 0; i < b.N; i++ { + for range b.N { _, _ = err.GetMetadata("key3") } }) diff --git a/test/comparison_benchmark_test.go b/test/comparison_benchmark_test.go deleted file mode 100644 index aaecf0b..0000000 --- a/test/comparison_benchmark_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package test - -import ( - "fmt" - "testing" - - "emperror.dev/emperror" - "emperror.dev/errors" - "github.com/hashicorp/go-multierror" - "go.uber.org/multierr" - - "github.com/hyp3rd/ewrap" -) - -// This test suite compares our implementation against popular error handling libraries -// to provide performance insights and identify optimization opportunities. - -func BenchmarkErrorCreation(b *testing.B) { - const msg = "test error" - - b.Run("ewrap/New", func(b *testing.B) { - b.ReportAllocs() - - for b.Loop() { - _ = ewrap.New(msg) - } - }) - - b.Run("pkg/errors/New", func(b *testing.B) { - b.ReportAllocs() - - for b.Loop() { - _ = errors.New(msg) - } - }) - - // b.Run("emperror/errors", func(b *testing.B) { - // b.ReportAllocs() - // var handler emperror.Handler = newHandler() - // // Recover from panics and handle them as errors - // defer emperror.HandleRecover(handler) - // for i := 0; i < b.N; i++ { - // _ = emperror.WithDetails(msg, - // keyval.Pairs{"operation": "test"}) - // } - // }) -} - -func BenchmarkErrorWrapping(b *testing.B) { - baseErr := errors.New("base error") - - const wrapMsg = "wrapped error" - - b.Run("ewrap/Wrap", func(b *testing.B) { - b.ReportAllocs() - - for b.Loop() { - _ = ewrap.Wrap(baseErr, wrapMsg) - } - }) - - b.Run("pkg/errors/Wrap", func(b *testing.B) { - b.ReportAllocs() - - for b.Loop() { - _ = errors.Wrap(baseErr, wrapMsg) - } - }) - - b.Run("emperror/Wrap", func(b *testing.B) { - b.ReportAllocs() - - for b.Loop() { - _ = emperror.Wrap(baseErr, wrapMsg) - } - }) -} - -func BenchmarkErrorGroups(b *testing.B) { - errs := make([]error, 10) - for i := range errs { - errs[i] = fmt.Errorf("error %d", i) - } - - b.Run("ewrap/ErrorGroup", func(b *testing.B) { - b.ReportAllocs() - - for range b.N { - group := ewrap.NewErrorGroup() - for _, err := range errs { - group.Add(err) - } - - _ = group.Error() - } - }) - - b.Run("hashicorp/multierror", func(b *testing.B) { - b.ReportAllocs() - - for range b.N { - var result *multierror.Error - - for _, err := range errs { - result = multierror.Append(result, err) - } - - _ = result.Error() - } - }) - - b.Run("uber/multierr", func(b *testing.B) { - b.ReportAllocs() - - for range b.N { - var err error - - for _, e := range errs { - err = multierr.Append(err, e) - } - - _ = err.Error() - } - }) -} diff --git a/test/load_test.go b/test/load_test.go deleted file mode 100644 index 367f143..0000000 --- a/test/load_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package test - -// import ( -// "context" -// "math/rand" -// "sync" -// "testing" -// "time" - -// "github.com/hyp3rd/ewrap/internal/logger" -// ) - -// // LoadTest simulates real-world error handling scenarios under load -// type LoadTest struct { -// duration time.Duration -// concurrency int -// errorRate float64 -// circuitBreaker *CircuitBreaker -// errorGroup *ErrorGroup -// logger logger.Logger -// stats *LoadTestStats -// wg sync.WaitGroup -// } - -// // LoadTestStats tracks statistics during load testing -// type LoadTestStats struct { -// totalOperations int64 -// successfulOps int64 -// failedOps int64 -// avgResponseTime time.Duration -// maxResponseTime time.Duration -// errorsGenerated int64 -// circuitBreaks int64 -// mu sync.Mutex -// } - -// func NewLoadTest(duration time.Duration, concurrency int, errorRate float64) *LoadTest { -// return &LoadTest{ -// duration: duration, -// concurrency: concurrency, -// errorRate: errorRate, -// circuitBreaker: NewCircuitBreaker("loadtest", 100, time.Second), -// errorGroup: NewErrorGroup(), -// logger: &mockLogger{}, -// stats: &LoadTestStats{}, -// } -// } - -// func (lt *LoadTest) Run(t *testing.T) { -// start := time.Now() -// done := make(chan struct{}) - -// // Start monitoring goroutine -// go lt.monitor(t, start, done) - -// // Start worker goroutines -// for i := 0; i < lt.concurrency; i++ { -// lt.wg.Add(1) -// go lt.worker(i) -// } - -// // Wait for duration -// time.Sleep(lt.duration) -// close(done) -// lt.wg.Wait() - -// // Report results -// lt.reportResults(t) -// } - -// func (lt *LoadTest) worker(id int) { -// defer lt.wg.Done() - -// ctx := context.Background() -// rand.Seed(time.Now().UnixNano()) - -// for { -// start := time.Now() - -// if lt.circuitBreaker.CanExecute() { -// if rand.Float64() < lt.errorRate { -// // Simulate error scenario -// err := New("simulated error", -// WithContext(ctx, ErrorTypeDatabase, SeverityCritical), -// WithLogger(lt.logger)) - -// lt.errorGroup.Add(err) -// lt.circuitBreaker.RecordFailure() - -// lt.updateStats(false, time.Since(start)) -// } else { -// // Simulate success scenario -// lt.circuitBreaker.RecordSuccess() -// lt.updateStats(true, time.Since(start)) -// } -// } else { -// lt.stats.mu.Lock() -// lt.stats.circuitBreaks++ -// lt.stats.mu.Unlock() -// } - -// // Simulate variable processing time -// time.Sleep(time.Duration(rand.Intn(10)) * time.Millisecond) -// } -// } - -// func (lt *LoadTest) monitor(t *testing.T, start time.Time, done chan struct{}) { -// ticker := time.NewTicker(time.Second) -// defer ticker.Stop() - -// for { -// select { -// case <-ticker.C: -// lt.stats.mu.Lock() -// t.Logf("Operations: %d, Success: %d, Failed: %d, Circuit Breaks: %d", -// lt.stats.totalOperations, -// lt.stats.successfulOps, -// lt.stats.failedOps, -// lt.stats.circuitBreaks) -// lt.stats.mu.Unlock() -// case <-done: -// return -// } -// } -// } - -// func (lt *LoadTest) updateStats(success bool, duration time.Duration) { -// lt.stats.mu.Lock() -// defer lt.stats.mu.Unlock() - -// lt.stats.totalOperations++ -// if success { -// lt.stats.successfulOps++ -// } else { -// lt.stats.failedOps++ -// lt.stats.errorsGenerated++ -// } - -// if duration > lt.stats.maxResponseTime { -// lt.stats.maxResponseTime = duration -// } - -// // Update average response time -// lt.stats.avgResponseTime = time.Duration( -// (int64(lt.stats.avgResponseTime)*lt.stats.totalOperations + -// int64(duration)) / (lt.stats.totalOperations + 1)) -// } - -// func (lt *LoadTest) reportResults(t *testing.T) { -// lt.stats.mu.Lock() -// defer lt.stats.mu.Unlock() - -// t.Logf("\nLoad Test Results:") -// t.Logf("================") -// t.Logf("Duration: %v", lt.duration) -// t.Logf("Concurrency: %d", lt.concurrency) -// t.Logf("Total Operations: %d", lt.stats.totalOperations) -// t.Logf("Successful Operations: %d", lt.stats.successfulOps) -// t.Logf("Failed Operations: %d", lt.stats.failedOps) -// t.Logf("Circuit Breaks: %d", lt.stats.circuitBreaks) -// t.Logf("Average Response Time: %v", lt.stats.avgResponseTime) -// t.Logf("Max Response Time: %v", lt.stats.maxResponseTime) -// t.Logf("Error Rate: %.2f%%", float64(lt.stats.failedOps)/float64(lt.stats.totalOperations)*100) -// } - -// func TestUnderLoad(t *testing.T) { -// if testing.Short() { -// t.Skip("Skipping load test in short mode") -// } - -// scenarios := []struct { -// name string -// duration time.Duration -// concurrency int -// errorRate float64 -// }{ -// {"LowConcurrency", 5 * time.Second, 10, 0.1}, -// {"HighConcurrency", 5 * time.Second, 100, 0.1}, -// {"HighErrorRate", 5 * time.Second, 50, 0.5}, -// {"StressTest", 10 * time.Second, 200, 0.3}, -// } - -// for _, scenario := range scenarios { -// t.Run(scenario.name, func(t *testing.T) { -// loadTest := NewLoadTest( -// scenario.duration, -// scenario.concurrency, -// scenario.errorRate, -// ) -// loadTest.Run(t) -// }) -// } -// } diff --git a/threshold.go b/threshold.go index 91c0817..218b9e4 100644 --- a/threshold.go +++ b/threshold.go @@ -6,6 +6,10 @@ import ( ) // CircuitBreaker implements the circuit breaker pattern for error handling. +// +// Notifications (observer and OnStateChange callback) are fired synchronously +// after the lock has been released, so callers must not invoke the breaker +// recursively from a callback. type CircuitBreaker struct { name string maxFailures int @@ -14,7 +18,7 @@ type CircuitBreaker struct { lastFailure time.Time state CircuitState observer Observer - mu sync.RWMutex + mu sync.Mutex onStateChange func(name string, from, to CircuitState) } @@ -30,6 +34,15 @@ const ( CircuitHalfOpen ) +// transitionEvent captures a state change so observer/callback dispatch can +// happen outside the breaker lock. +type transitionEvent struct { + name string + from, to CircuitState + observer Observer + callback func(string, CircuitState, CircuitState) +} + // NewCircuitBreaker creates a new circuit breaker. func NewCircuitBreaker(name string, maxFailures int, timeout time.Duration) *CircuitBreaker { return NewCircuitBreakerWithObserver(name, maxFailures, timeout, nil) @@ -59,79 +72,103 @@ func (cb *CircuitBreaker) OnStateChange(callback func(name string, from, to Circ // SetObserver sets an observer for the circuit breaker. func (cb *CircuitBreaker) SetObserver(observer Observer) { - cb.mu.Lock() - defer cb.mu.Unlock() - if observer == nil { observer = newNoopObserver() } + cb.mu.Lock() cb.observer = observer + cb.mu.Unlock() } // RecordFailure records a failure and potentially opens the circuit. func (cb *CircuitBreaker) RecordFailure() { cb.mu.Lock() - defer cb.mu.Unlock() - cb.failureCount++ cb.lastFailure = time.Now() + var event *transitionEvent if cb.state == CircuitClosed && cb.failureCount >= cb.maxFailures { - cb.transitionTo(CircuitOpen) + event = cb.setStateLocked(CircuitOpen) } + cb.mu.Unlock() + + cb.fireTransition(event) } // RecordSuccess records a success and potentially closes the circuit. func (cb *CircuitBreaker) RecordSuccess() { cb.mu.Lock() - defer cb.mu.Unlock() + + var event *transitionEvent if cb.state == CircuitHalfOpen { cb.failureCount = 0 - cb.transitionTo(CircuitClosed) + event = cb.setStateLocked(CircuitClosed) } + cb.mu.Unlock() + + cb.fireTransition(event) } -// CanExecute checks if the operation can be executed. +// CanExecute checks if the operation can be executed. When the breaker is +// open and the timeout has elapsed it transitions to half-open atomically. func (cb *CircuitBreaker) CanExecute() bool { - cb.mu.RLock() - defer cb.mu.RUnlock() + cb.mu.Lock() + + var ( + can bool + event *transitionEvent + ) switch cb.state { case CircuitClosed, CircuitHalfOpen: - return true + can = true case CircuitOpen: if time.Since(cb.lastFailure) > cb.timeout { - cb.mu.RUnlock() - cb.mu.Lock() - cb.transitionTo(CircuitHalfOpen) - cb.mu.Unlock() - cb.mu.RLock() - - return true + event = cb.setStateLocked(CircuitHalfOpen) + can = true } - - return false - default: - return false } + cb.mu.Unlock() + + cb.fireTransition(event) + + return can } -// transitionTo changes the circuit breaker state. -func (cb *CircuitBreaker) transitionTo(newState CircuitState) { +// setStateLocked must be called with cb.mu held. Returns a transitionEvent +// when the state actually changes; nil otherwise. The caller is responsible +// for releasing the lock and calling fireTransition. +func (cb *CircuitBreaker) setStateLocked(newState CircuitState) *transitionEvent { if cb.state == newState { - return + return nil } oldState := cb.state cb.state = newState - if cb.observer != nil { - cb.observer.RecordCircuitStateTransition(cb.name, oldState, newState) + return &transitionEvent{ + name: cb.name, + from: oldState, + to: newState, + observer: cb.observer, + callback: cb.onStateChange, + } +} + +// fireTransition dispatches observer and callback notifications for a +// completed transition. Must be called without the lock held. +func (*CircuitBreaker) fireTransition(event *transitionEvent) { + if event == nil { + return + } + + if event.observer != nil { + event.observer.RecordCircuitStateTransition(event.name, event.from, event.to) } - if cb.onStateChange != nil { - go cb.onStateChange(cb.name, oldState, newState) + if event.callback != nil { + event.callback(event.name, event.from, event.to) } } diff --git a/threshold_test.go b/threshold_test.go index 6073098..5b9589e 100644 --- a/threshold_test.go +++ b/threshold_test.go @@ -111,9 +111,9 @@ func TestCircuitBreakerCanExecute(t *testing.T) { } // Verify state is now half-open - cb.mu.RLock() + cb.mu.Lock() state := cb.state - cb.mu.RUnlock() + cb.mu.Unlock() if state != CircuitHalfOpen { t.Errorf("Expected state %v after timeout, got %v", CircuitHalfOpen, state) @@ -144,12 +144,10 @@ func TestCircuitBreakerOnStateChange(t *testing.T) { mu.Unlock() }) - // Record failure to trigger state change + // Record failure to trigger state change. Callback fires synchronously + // once the breaker lock has been released, so no sleep is needed. cb.RecordFailure() - // Give goroutine time to execute - time.Sleep(10 * time.Millisecond) - mu.Lock() if len(stateChanges) != 1 { @@ -172,26 +170,21 @@ func TestCircuitBreakerOnStateChange(t *testing.T) { mu.Unlock() } -func TestCircuitBreakerTransitionTo(t *testing.T) { +func TestCircuitBreakerTransitionViaPublicAPI(t *testing.T) { cb := NewCircuitBreaker("test", 1, 5*time.Second) - // Test transition from closed to open - cb.mu.Lock() - cb.transitionTo(CircuitOpen) - cb.mu.Unlock() + // Single failure trips the circuit (maxFailures=1). + cb.RecordFailure() if cb.state != CircuitOpen { t.Errorf("Expected state %v, got %v", CircuitOpen, cb.state) } - // Test no transition when same state - cb.mu.Lock() - oldState := cb.state - cb.transitionTo(CircuitOpen) - cb.mu.Unlock() + // A second failure does not change state away from Open. + cb.RecordFailure() - if cb.state != oldState { - t.Error("Expected no state change when transitioning to same state") + if cb.state != CircuitOpen { + t.Error("Expected state to remain Open on subsequent failure") } } From baa88460c4ba91a3b1e606ed29f773b56d4a4f03 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 09:43:55 +0200 Subject: [PATCH 03/17] refactor: replace encoding/json, centralize test fixtures, and fix linter findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap encoding/json for github.com/goccy/go-json in error_group.go, format.go, and all test files for improved serialization performance - Add test_helpers_test.go with shared string constants and package-level sentinel errors to silence goconst/revive warnings and improve readability - Enable t.Parallel on all tests and subtests; extract oversized test bodies into named helpers to satisfy the funlen linter - Pin ErrorType and Severity string representations as named constants in types.go, removing duplicate free-floating string literals - Add defensive default case in CircuitBreaker.CanExecute switch - Simplify format_verb.go Format() using fmt.Fprintf/Fprint; rename param s→state; add explicit default case - Suppress intentional err113 finding in errors.go with a nolint pragma - Update .golangci.yaml: enable test-file linting, disable check-blank, add fmt.Fprint to errcheck exclusions - Refactor profile_test.go: extract profileCase named type, add filepath.Clean, add nolint pragmas for intentional runtime.GC calls --- .golangci.yaml | 11 +- attributes_test.go | 35 ++-- error_group.go | 2 +- error_group_test.go | 139 ++++++++------ errors.go | 4 +- errors_test.go | 409 +++++++++++++++++++++++++---------------- format.go | 10 +- format_test.go | 168 ++++++++++------- format_verb.go | 17 +- go.mod | 2 + go.sum | 2 + hardening_test.go | 46 ++--- observability_test.go | 37 ++-- retry_test.go | 41 ++++- stack_test.go | 156 ++++++++-------- test/benchmark_test.go | 84 +++++---- test/profile_test.go | 275 ++++++++++++++------------- test_helpers_test.go | 48 +++++ threshold.go | 4 + threshold_test.go | 115 ++++++------ types.go | 52 ++++-- types_test.go | 74 +++++--- 22 files changed, 1028 insertions(+), 703 deletions(-) create mode 100644 test_helpers_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 0dce4e0..b073c6e 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -14,7 +14,7 @@ run: issues-exit-code: 2 # Include test files or not. # Default: true - tests: false + tests: true # List of build tags, all linters use it. # Default: [] # build-tags: @@ -76,10 +76,10 @@ linters: # Such cases aren't reported by default. # Default: false check-type-assertions: true - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. - # Such cases aren't reported by default. - # Default: false - check-blank: true + # Reporting blank-identifier assignments is overly strict — discarding to + # `_` is the documented idiom for "intentional ignore". We still catch + # truly unchecked returns (no assignment at all) via the default rules. + check-blank: false # To disable the errcheck built-in exclude list. # See `-excludeonly` option in https://github.com/kisielk/errcheck#excluding-functions for details. # Default: false @@ -89,6 +89,7 @@ linters: exclude-functions: - fmt.Fprintf - fmt.Fprintln + - fmt.Fprint funlen: lines: 100 diff --git a/attributes_test.go b/attributes_test.go index fe3f1e5..2079a00 100644 --- a/attributes_test.go +++ b/attributes_test.go @@ -1,7 +1,6 @@ package ewrap import ( - "errors" "fmt" "net/http" "testing" @@ -13,7 +12,7 @@ func TestHTTPStatus(t *testing.T) { t.Run("unset returns zero", func(t *testing.T) { t.Parallel() - err := New("plain") + err := New(msgPlain) if got := HTTPStatus(err); got != 0 { t.Errorf("got %d, want 0", got) } @@ -42,7 +41,7 @@ func TestHTTPStatus(t *testing.T) { t.Run("non-ewrap error returns zero", func(t *testing.T) { t.Parallel() - if got := HTTPStatus(errors.New("plain")); got != 0 { + if got := HTTPStatus(errPlain); got != 0 { t.Errorf("got %d, want 0", got) } }) @@ -72,7 +71,7 @@ func TestIsRetryable(t *testing.T) { t.Run("unset and no Temporary defaults false", func(t *testing.T) { t.Parallel() - if IsRetryable(New("plain")) { + if IsRetryable(New(msgPlain)) { t.Error("expected retryable false for unclassified error") } }) @@ -146,22 +145,34 @@ func TestSafeError(t *testing.T) { func TestFormatVerbs(t *testing.T) { t.Parallel() - err := New("boom") + err := New(msgBoom) - if got := fmt.Sprintf("%s", err); got != "boom" { - t.Errorf("%%s: got %q, want %q", got, "boom") + if got := fmt.Sprintf("%s", err); got != msgBoom { + t.Errorf("%%s: got %q, want %q", got, msgBoom) } - if got := fmt.Sprintf("%v", err); got != "boom" { - t.Errorf("%%v: got %q, want %q", got, "boom") + if got := fmt.Sprintf("%v", err); got != msgBoom { + t.Errorf("%%v: got %q, want %q", got, msgBoom) } - if got := fmt.Sprintf("%q", err); got != `"boom"` { - t.Errorf("%%q: got %q, want %q", got, `"boom"`) + const quotedBoom = `"boom"` + + if got := fmt.Sprintf("%q", err); got != quotedBoom { + t.Errorf("%%q: got %q, want %q", got, quotedBoom) } plus := fmt.Sprintf("%+v", err) - if plus == "boom" || plus[:len("boom")] != "boom" { + if !errorStartsWithMessage(plus, msgBoom) { t.Errorf("%%+v: got %q, expected message followed by stack", plus) } } + +// errorStartsWithMessage tests that the formatted output begins with the +// expected error message and includes additional content (the stack trace). +func errorStartsWithMessage(formatted, message string) bool { + if len(formatted) <= len(message) { + return false + } + + return formatted[:len(message)] == message +} diff --git a/error_group.go b/error_group.go index 6ae0aec..0c79632 100644 --- a/error_group.go +++ b/error_group.go @@ -1,7 +1,6 @@ package ewrap import ( - "encoding/json" "errors" "fmt" "log/slog" @@ -11,6 +10,7 @@ import ( "sync" "time" + "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) diff --git a/error_group_test.go b/error_group_test.go index c734428..fbb832f 100644 --- a/error_group_test.go +++ b/error_group_test.go @@ -7,63 +7,79 @@ import ( "testing" ) +const ( + concurrentPoolGoroutines = 100 + smallCapacity = 2 + smallErrorCount = 5 + exactCapacity = 4 + largeCapacity = 8 + largeErrorCount = 3 +) + func TestErrorGroupPool(t *testing.T) { - // Test pool with different capacities + t.Parallel() + tests := []struct { name string initialCapacity int numErrors int }{ - {"SmallCapacity", 2, 5}, - {"ExactCapacity", 4, 4}, - {"LargeCapacity", 8, 3}, - {"InvalidCapacity", -1, 4}, // Should use default capacity + {"SmallCapacity", smallCapacity, smallErrorCount}, + {"ExactCapacity", exactCapacity, exactCapacity}, + {"LargeCapacity", largeCapacity, largeErrorCount}, + {"InvalidCapacity", -1, exactCapacity}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - pool := NewErrorGroupPool(tt.initialCapacity) + t.Parallel() + runPoolCase(t, tt.initialCapacity, tt.numErrors) + }) + } +} - // Get multiple groups from the pool - groups := make([]*ErrorGroup, 3) - for i := range groups { - groups[i] = pool.Get() +func runPoolCase(t *testing.T, initialCapacity, numErrors int) { + t.Helper() - // Add errors - for j := range tt.numErrors { - groups[i].Add(fmt.Errorf("error %d", j)) - } - } + pool := NewErrorGroupPool(initialCapacity) - // Verify each group works correctly - for _, eg := range groups { - if got := len(eg.errors); got != tt.numErrors { - t.Errorf("Expected %d errors, got %d", tt.numErrors, got) - } + const groupCount = 3 - eg.Release() - } - }) + groups := make([]*ErrorGroup, groupCount) + for i := range groups { + groups[i] = pool.Get() + + for j := range numErrors { + groups[i].Add(fmt.Errorf("%w %d", errIndexed, j)) + } + } + + for _, eg := range groups { + if got := len(eg.errors); got != numErrors { + t.Errorf("Expected %d errors, got %d", numErrors, got) + } + + eg.Release() } } func TestConcurrentPoolUsage(t *testing.T) { - pool := NewErrorGroupPool(4) + t.Parallel() - var wg sync.WaitGroup + pool := NewErrorGroupPool(exactCapacity) - numGoroutines := 100 + var wg sync.WaitGroup - wg.Add(numGoroutines) + wg.Add(concurrentPoolGoroutines) - for i := range numGoroutines { + for i := range concurrentPoolGoroutines { go func(id int) { defer wg.Done() eg := pool.Get() defer eg.Release() - eg.Add(fmt.Errorf("error from goroutine %d", id)) + eg.Add(fmt.Errorf("%w %d", errFromGoroutine, id)) if !eg.HasErrors() { t.Errorf("Expected errors in group %d", id) @@ -75,58 +91,71 @@ func TestConcurrentPoolUsage(t *testing.T) { } func BenchmarkErrorGroupPool(b *testing.B) { - sampleErrors := make([]error, 5) + const sampleCount = 5 + + sampleErrors := make([]error, sampleCount) for i := range sampleErrors { - sampleErrors[i] = fmt.Errorf("error %d", i) + sampleErrors[i] = fmt.Errorf("%w %d", errIndexed, i) } b.Run("WithPool", func(b *testing.B) { - pool := NewErrorGroupPool(4) + benchPoolWithPool(b, sampleErrors) + }) - b.ReportAllocs() + b.Run("WithoutPool", func(b *testing.B) { + benchPoolWithoutPool(b, sampleErrors) + }) +} - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - eg := pool.Get() - for _, err := range sampleErrors { - eg.Add(err) - } +func benchPoolWithPool(b *testing.B, sampleErrors []error) { + b.Helper() - _ = eg.Error() - eg.Release() - } - }) - }) + pool := NewErrorGroupPool(exactCapacity) - b.Run("WithoutPool", func(b *testing.B) { - b.ReportAllocs() + b.ReportAllocs() - for range b.N { - eg := NewErrorGroup() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + eg := pool.Get() for _, err := range sampleErrors { eg.Add(err) } _ = eg.Error() + eg.Release() } }) } +func benchPoolWithoutPool(b *testing.B, sampleErrors []error) { + b.Helper() + + b.ReportAllocs() + + for range b.N { + eg := NewErrorGroup() + for _, err := range sampleErrors { + eg.Add(err) + } + + _ = eg.Error() + } +} + func TestErrorGroupJoin(t *testing.T) { - eg := NewErrorGroup() - err1 := errors.New("first") - err2 := errors.New("second") + t.Parallel() - eg.Add(err1) - eg.Add(err2) + eg := NewErrorGroup() + eg.Add(errFirst) + eg.Add(errSecond) joined := eg.Join() if joined == nil { t.Fatal("expected joined error") } - if !errors.Is(joined, err1) || !errors.Is(joined, err2) { - t.Fatalf("joined error does not contain original errors") + if !errors.Is(joined, errFirst) || !errors.Is(joined, errSecond) { + t.Fatal("joined error does not contain original errors") } eg.Clear() diff --git a/errors.go b/errors.go index 7962a8f..d011c15 100644 --- a/errors.go +++ b/errors.go @@ -142,7 +142,9 @@ func newfAt(skip int, format string, args ...any) *Error { return newAt(skip+1, fmt.Sprintf(format, args...)) } - formatted := fmt.Errorf(format, args...) + // fmt.Errorf is the only way to extract the cause produced by %w; the + // dynamic error it returns is transient and never escapes this function. + formatted := fmt.Errorf(format, args...) //nolint:err113 // intentional %w extraction var cause error if u, ok := formatted.(interface{ Unwrap() error }); ok { diff --git a/errors_test.go b/errors_test.go index 3d802e4..b8f4217 100644 --- a/errors_test.go +++ b/errors_test.go @@ -8,24 +8,41 @@ import ( "testing" ) -// MockLogger implements the logger.Logger interface for testing. +const ( + concurrentMetadataIters = 100 + formatNumber = 42 + wrapfFormat = "wrapped %d" + expectedDebugCalls = 1 + metadataIntValue = 5 +) + +// MockLogger implements the Logger interface for testing. type MockLogger struct { mu sync.Mutex logs []LogEntry called map[string]int } +// LogEntry captures one logged message and its arguments. type LogEntry struct { Level string Msg string Args []any } +// NewMockLogger constructs a fresh MockLogger. +func NewMockLogger() *MockLogger { + return &MockLogger{ + logs: make([]LogEntry, 0), + called: make(map[string]int), + } +} + func (m *MockLogger) Info(msg string, args ...any) { m.mu.Lock() defer m.mu.Unlock() - m.logs = append(m.logs, LogEntry{Level: "info", Msg: msg, Args: args}) + m.logs = append(m.logs, LogEntry{Level: severityInfoStr, Msg: msg, Args: args}) } func (m *MockLogger) Debug(msg string, args ...any) { @@ -40,15 +57,8 @@ func (m *MockLogger) Error(msg string, args ...any) { m.mu.Lock() defer m.mu.Unlock() - m.logs = append(m.logs, LogEntry{Level: "error", Msg: msg, Args: args}) - m.called["error"]++ -} - -func NewMockLogger() *MockLogger { - return &MockLogger{ - logs: make([]LogEntry, 0), - called: make(map[string]int), - } + m.logs = append(m.logs, LogEntry{Level: severityErrorStr, Msg: msg, Args: args}) + m.called[severityErrorStr]++ } func (m *MockLogger) GetLogs() []LogEntry { @@ -66,168 +76,206 @@ func (m *MockLogger) GetCallCount(level string) int { } func TestNew(t *testing.T) { - t.Run("creates error with message", func(t *testing.T) { - err := New("test error") - if err.Error() != "test error" { - t.Errorf("expected 'test error', got '%s'", err.Error()) - } + t.Parallel() - if len(err.stack) == 0 { - t.Error("expected stack trace to be captured") - } + t.Run("creates error with message", testNewCreates) + t.Run("applies options", testNewAppliesOptions) +} - // metadata is initialized lazily on first WithMetadata; nil here is - // the expected steady state for an error with no user metadata. - if err.metadata != nil { - t.Errorf("expected nil metadata before first write, got %v", err.metadata) - } +func testNewCreates(t *testing.T) { + t.Parallel() - err.WithMetadata("k", "v") + err := New(msgTestError) + if err.Error() != msgTestError { + t.Errorf("expected %q, got %q", msgTestError, err.Error()) + } - if err.metadata == nil { - t.Error("expected metadata to be initialized after WithMetadata") - } - }) + if len(err.stack) == 0 { + t.Error("expected stack trace to be captured") + } - t.Run("applies options", func(t *testing.T) { - mockLogger := NewMockLogger() + if err.metadata != nil { + t.Errorf("expected nil metadata before first write, got %v", err.metadata) + } - err := New("test error", WithLogger(mockLogger)) - if err.logger != mockLogger { - t.Error("expected logger to be set") - } + _ = err.WithMetadata("k", "v") - if mockLogger.GetCallCount("debug") != 1 { - t.Error("expected logger debug to be called once") - } - }) + if err.metadata == nil { + t.Error("expected metadata to be initialized after WithMetadata") + } +} + +func testNewAppliesOptions(t *testing.T) { + t.Parallel() + + mockLogger := NewMockLogger() + + err := New(msgTestError, WithLogger(mockLogger)) + if err.logger != mockLogger { + t.Error("expected logger to be set") + } + + if mockLogger.GetCallCount("debug") != expectedDebugCalls { + t.Error("expected logger debug to be called once") + } } func TestNewf(t *testing.T) { - err := Newf("test error %d", 42) + t.Parallel() + + err := Newf("test error %d", formatNumber) - expected := "test error 42" + expected := fmt.Sprintf("test error %d", formatNumber) if err.Error() != expected { - t.Errorf("expected '%s', got '%s'", expected, err.Error()) + t.Errorf("expected %q, got %q", expected, err.Error()) } } func TestWrap(t *testing.T) { - t.Run("wraps nil error returns nil", func(t *testing.T) { - result := Wrap(nil, "test") - if result != nil { - t.Error("expected nil when wrapping nil error") - } - }) + t.Parallel() - t.Run("wraps standard error", func(t *testing.T) { - originalErr := errors.New("original error") - wrapped := Wrap(originalErr, "wrapped") + t.Run("wraps nil error returns nil", testWrapNil) + t.Run("wraps standard error", testWrapStandard) + t.Run("wraps custom Error preserving stack and metadata", testWrapCustom) +} - if wrapped.msg != "wrapped" { - t.Errorf("expected message 'wrapped', got '%s'", wrapped.msg) - } +func testWrapNil(t *testing.T) { + t.Parallel() - if !errors.Is(wrapped.cause, originalErr) { - t.Error("expected cause to be set to original error") - } + result := Wrap(nil, msgTest) + if result != nil { + t.Error("expected nil when wrapping nil error") + } +} - expected := "wrapped: original error" - if wrapped.Error() != expected { - t.Errorf("expected '%s', got '%s'", expected, wrapped.Error()) - } - }) +func testWrapStandard(t *testing.T) { + t.Parallel() - t.Run("wraps custom Error preserving stack and metadata", func(t *testing.T) { - original := New("original").WithMetadata("key", "value") - wrapped := Wrap(original, "wrapped") + wrapped := Wrap(errOriginalLong, msgWrapped) - if len(wrapped.stack) == 0 { - t.Error("expected stack trace to be preserved") - } + if wrapped.msg != msgWrapped { + t.Errorf("expected message %q, got %q", msgWrapped, wrapped.msg) + } - if val, ok := wrapped.GetMetadata("key"); !ok || val != "value" { - t.Error("expected metadata to be preserved") - } - }) + if !errors.Is(wrapped.cause, errOriginalLong) { + t.Error("expected cause to be set to original error") + } + + expected := msgWrapped + ": " + msgOriginalErr + if wrapped.Error() != expected { + t.Errorf("expected %q, got %q", expected, wrapped.Error()) + } +} + +func testWrapCustom(t *testing.T) { + t.Parallel() + + original := New(msgOriginal).WithMetadata(msgKey, msgValue) + wrapped := Wrap(original, msgWrapped) + + if len(wrapped.stack) == 0 { + t.Error("expected stack trace to be preserved") + } + + if val, ok := wrapped.GetMetadata(msgKey); !ok || val != msgValue { + t.Error("expected metadata to be preserved") + } } func TestWrapf(t *testing.T) { + t.Parallel() + t.Run("wraps nil error returns nil", func(t *testing.T) { - result := Wrapf(nil, "test %d", 42) + t.Parallel() + + result := Wrapf(nil, "test %d", formatNumber) if result != nil { t.Error("expected nil when wrapping nil error") } }) t.Run("wraps with formatted message", func(t *testing.T) { - originalErr := errors.New("original") - wrapped := Wrapf(originalErr, "wrapped %d", 42) + t.Parallel() + + wrapped := Wrapf(errOriginal, wrapfFormat, formatNumber) - expected := "wrapped 42: original" + expected := fmt.Sprintf(wrapfFormat+": "+msgOriginal, formatNumber) if wrapped.Error() != expected { - t.Errorf("expected '%s', got '%s'", expected, wrapped.Error()) + t.Errorf("expected %q, got %q", expected, wrapped.Error()) } }) } func TestError_Error(t *testing.T) { + t.Parallel() + t.Run("returns message when no cause", func(t *testing.T) { + t.Parallel() + err := New("test message") if err.Error() != "test message" { - t.Errorf("expected 'test message', got '%s'", err.Error()) + t.Errorf("expected 'test message', got %q", err.Error()) } }) t.Run("returns message with cause", func(t *testing.T) { - cause := errors.New("cause error") - err := Wrap(cause, "wrapped") + t.Parallel() - expected := "wrapped: cause error" + err := Wrap(errCause, msgWrapped) + + expected := msgWrapped + ": " + msgCauseError if err.Error() != expected { - t.Errorf("expected '%s', got '%s'", expected, err.Error()) + t.Errorf("expected %q, got %q", expected, err.Error()) } }) } func TestError_Cause(t *testing.T) { + t.Parallel() + t.Run("returns nil for new error", func(t *testing.T) { - err := New("test") + t.Parallel() + + err := New(msgTest) if err.Cause() != nil { t.Error("expected nil cause for new error") } }) t.Run("returns cause for wrapped error", func(t *testing.T) { - cause := errors.New("original") + t.Parallel() - wrapped := Wrap(cause, "wrapped") - if !errors.Is(wrapped.Cause(), cause) { + wrapped := Wrap(errOriginal, msgWrapped) + if !errors.Is(wrapped.Cause(), errOriginal) { t.Error("expected cause to match original error") } }) } func TestError_WithMetadata(t *testing.T) { - err := New("test") - result := err.WithMetadata("key", "value") + t.Parallel() + + err := New(msgTest) + result := err.WithMetadata(msgKey, msgValue) if result != err { t.Error("expected WithMetadata to return same error instance") } - val, ok := err.GetMetadata("key") + val, ok := err.GetMetadata(msgKey) if !ok { t.Error("expected metadata to be set") } - if val != "value" { - t.Errorf("expected 'value', got '%v'", val) + if val != msgValue { + t.Errorf("expected %q, got %v", msgValue, val) } } func TestError_WithContext(t *testing.T) { - err := New("test") + t.Parallel() + + err := New(msgTest) ctx := &ErrorContext{} result := err.WithContext(ctx) @@ -242,37 +290,45 @@ func TestError_WithContext(t *testing.T) { } func TestError_GetMetadata(t *testing.T) { - err := New("test").WithMetadata("key", "value") + t.Parallel() + + err := New(msgTest).WithMetadata(msgKey, msgValue) t.Run("existing key", func(t *testing.T) { - val, ok := err.GetMetadata("key") + t.Parallel() + + val, ok := err.GetMetadata(msgKey) if !ok { t.Error("expected key to exist") } - if val != "value" { - t.Errorf("expected 'value', got '%v'", val) + if val != msgValue { + t.Errorf("expected %q, got %v", msgValue, val) } }) t.Run("non-existing key", func(t *testing.T) { + t.Parallel() + val, ok := err.GetMetadata("nonexistent") if ok { t.Error("expected key to not exist") } if val != nil { - t.Errorf("expected nil value, got '%v'", val) + t.Errorf("expected nil value, got %v", val) } }) } func TestError_GetMetadataValue(t *testing.T) { - err := New("test").WithMetadata("count", 5) + t.Parallel() + + err := New(msgTest).WithMetadata("count", metadataIntValue) val, ok := GetMetadataValue[int](err, "count") - if !ok || val != 5 { - t.Errorf("expected typed metadata 5, got %v (ok=%v)", val, ok) + if !ok || val != metadataIntValue { + t.Errorf("expected typed metadata %d, got %v (ok=%v)", metadataIntValue, val, ok) } _, ok = GetMetadataValue[int](err, "missing") @@ -282,20 +338,13 @@ func TestError_GetMetadataValue(t *testing.T) { } func TestWithRecoverySuggestion(t *testing.T) { + t.Parallel() + mockLogger := NewMockLogger() rs := &RecoverySuggestion{Message: "restart"} - err := New("test", WithLogger(mockLogger), WithRecoverySuggestion(rs)) - - logs := mockLogger.GetLogs() - infoCount := 0 + err := New(msgTest, WithLogger(mockLogger), WithRecoverySuggestion(rs)) - for _, l := range logs { - if l.Level == "info" { - infoCount++ - } - } - - if infoCount != 1 { + if countLogsAtLevel(mockLogger.GetLogs(), severityInfoStr) != 1 { t.Error("expected info log when adding recovery suggestion") } @@ -306,33 +355,52 @@ func TestWithRecoverySuggestion(t *testing.T) { err.Log() - logs = mockLogger.GetLogs() - found := false + if !findRecoveryMessageInLogs(mockLogger.GetLogs(), rs.Message) { + t.Error("expected recovery_message in error log") + } +} + +// countLogsAtLevel counts how many entries match the given level. +func countLogsAtLevel(logs []LogEntry, level string) int { + count := 0 + + for _, l := range logs { + if l.Level == level { + count++ + } + } + + return count +} +// findRecoveryMessageInLogs scans error-level entries for a "recovery_message" +// key whose value matches want. +func findRecoveryMessageInLogs(logs []LogEntry, want string) bool { for _, entry := range logs { - if entry.Level == "error" { - for i := 0; i < len(entry.Args); i += 2 { - if entry.Args[i] == "recovery_message" && entry.Args[i+1] == rs.Message { - found = true - } + if entry.Level != severityErrorStr { + continue + } + + for i := 0; i < len(entry.Args); i += 2 { + if entry.Args[i] == "recovery_message" && entry.Args[i+1] == want { + return true } } } - if !found { - t.Error("expected recovery_message in error log") - } + return false } func TestError_Stack(t *testing.T) { - err := New("test") + t.Parallel() + + err := New(msgTest) stack := err.Stack() if stack == "" { t.Error("expected non-empty stack trace") } - // Should not contain runtime frames or error package frames if strings.Contains(stack, "runtime/") { t.Error("stack should not contain runtime frames") } @@ -343,39 +411,48 @@ func TestError_Stack(t *testing.T) { } func TestError_Log(t *testing.T) { + t.Parallel() + t.Run("does nothing when no logger", func(t *testing.T) { - err := New("test") + t.Parallel() + + err := New(msgTest) err.Log() // Should not panic }) t.Run("logs with logger", func(t *testing.T) { + t.Parallel() + mockLogger := NewMockLogger() - err := New("test", WithLogger(mockLogger)).WithMetadata("key", "value") + err := New(msgTest, WithLogger(mockLogger)).WithMetadata(msgKey, msgValue) err.Log() - if mockLogger.GetCallCount("error") != 1 { + if mockLogger.GetCallCount(severityErrorStr) != 1 { t.Error("expected error log to be called once") } logs := mockLogger.GetLogs() - if len(logs) < 2 { // At least creation debug log and error log + if len(logs) < 2 { t.Error("expected at least 2 log entries") } }) t.Run("logs with cause", func(t *testing.T) { + t.Parallel() + mockLogger := NewMockLogger() - cause := errors.New("original") - err := Wrap(cause, "wrapped", WithLogger(mockLogger)) + err := Wrap(errOriginal, msgWrapped, WithLogger(mockLogger)) err.Log() - if mockLogger.GetCallCount("error") != 1 { + if mockLogger.GetCallCount(severityErrorStr) != 1 { t.Error("expected error log to be called once") } }) } func TestCaptureStack(t *testing.T) { + t.Parallel() + stack := CaptureStack() if len(stack) == 0 { t.Error("expected non-empty stack trace") @@ -383,73 +460,88 @@ func TestCaptureStack(t *testing.T) { } func TestError_Is(t *testing.T) { + t.Parallel() + t.Run("returns false for nil target", func(t *testing.T) { - err := New("test") + t.Parallel() + + err := New(msgTest) if errors.Is(err, nil) { t.Error("expected false for nil target") } }) t.Run("matches sentinel error", func(t *testing.T) { - sentinel := errors.New("sentinel") + t.Parallel() - wrapped := Wrap(sentinel, "wrapped") - if !errors.Is(wrapped, sentinel) { + wrapped := Wrap(errSentinel, msgWrapped) + if !errors.Is(wrapped, errSentinel) { t.Error("expected true for sentinel error in chain") } }) t.Run("matches ewrap sentinel", func(t *testing.T) { - sentinel := New("sentinel") + t.Parallel() + + sentinel := New(msgSentinel) - wrapped := Wrap(sentinel, "wrapped") + wrapped := Wrap(sentinel, msgWrapped) if !errors.Is(wrapped, sentinel) { t.Error("expected true for ewrap sentinel in chain") } }) t.Run("prevents infinite recursion with self-reference", func(t *testing.T) { + t.Parallel() + err1 := New("error1") err2 := New("error2") - // This would create a cycle if not handled properly + if errors.Is(err1, err2) { t.Error("expected false for different errors") } }) t.Run("non-matching error", func(t *testing.T) { - err := New("test error") + t.Parallel() - target := errors.New("other") - if errors.Is(err, target) { + err := New(msgTestError) + + if errors.Is(err, errOther) { t.Error("expected false for non-matching error") } }) } func TestError_Unwrap(t *testing.T) { + t.Parallel() + t.Run("returns nil for new error", func(t *testing.T) { - err := New("test") + t.Parallel() + + err := New(msgTest) if err.Unwrap() != nil { t.Error("expected nil for new error") } }) t.Run("returns cause for wrapped error", func(t *testing.T) { - cause := errors.New("original") + t.Parallel() - wrapped := Wrap(cause, "wrapped") - if !errors.Is(wrapped.Unwrap(), cause) { + wrapped := Wrap(errOriginal, msgWrapped) + if !errors.Is(wrapped.Unwrap(), errOriginal) { t.Error("expected unwrap to return cause") } }) } func TestWithLogger(t *testing.T) { + t.Parallel() + mockLogger := NewMockLogger() option := WithLogger(mockLogger) err := &Error{ - msg: "test", + msg: msgTest, metadata: make(map[string]any), stack: CaptureStack(), } @@ -460,32 +552,27 @@ func TestWithLogger(t *testing.T) { t.Error("expected logger to be set") } - if mockLogger.GetCallCount("debug") != 1 { + if mockLogger.GetCallCount("debug") != expectedDebugCalls { t.Error("expected debug log to be called once") } } func TestConcurrentAccess(t *testing.T) { - err := New("test") + t.Parallel() - // Test concurrent metadata access - var wg sync.WaitGroup - for i := range 100 { - wg.Add(2) + err := New(msgTest) - go func(i int) { - defer wg.Done() + var wg sync.WaitGroup - err.WithMetadata(fmt.Sprintf("key%d", i), i) - }(i) - go func(i int) { - defer wg.Done() + for i := range concurrentMetadataIters { + wg.Go(func() { + _ = err.WithMetadata(fmt.Sprintf("key%d", i), i) + }) - err.GetMetadata(fmt.Sprintf("key%d", i)) - }(i) + wg.Go(func() { + _, _ = err.GetMetadata(fmt.Sprintf("key%d", i)) + }) } wg.Wait() - - // Should not panic or race } diff --git a/format.go b/format.go index add984f..7ac5cc2 100644 --- a/format.go +++ b/format.go @@ -1,12 +1,12 @@ package ewrap import ( - "encoding/json" "errors" "fmt" "maps" "time" + "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) @@ -71,8 +71,8 @@ func (e *Error) toErrorOutput(opts ...FormatOption) *ErrorOutput { output := &ErrorOutput{ Message: e.msg, Timestamp: time.Now().Format(time.RFC3339), - Type: "unknown", - Severity: "error", + Type: typeUnknownStr, + Severity: severityErrorStr, Stack: e.Stack(), Metadata: metadataCopy, Recovery: e.recovery, @@ -113,8 +113,8 @@ func (e *Error) toErrorOutput(opts ...FormatOption) *ErrorOutput { func standardErrorOutput(err error) *ErrorOutput { out := &ErrorOutput{ Message: err.Error(), - Type: "unknown", - Severity: "error", + Type: typeUnknownStr, + Severity: severityErrorStr, } cause := errors.Unwrap(err) diff --git a/format_test.go b/format_test.go index e058c8c..3a3a639 100644 --- a/format_test.go +++ b/format_test.go @@ -1,31 +1,42 @@ package ewrap import ( - "encoding/json" - "errors" "strings" "testing" "time" + "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) +const ( + formatTestYear = 2024 + formatTestLine = 42 + formatTestMonth = 1 + formatTestDay = 2 + formatTestHour = 15 + formatTestMin = 4 + formatTestSec = 5 + dateOnlyLayout = "2006-01-02" + unexpectedErrFn = "Unexpected error: %v" +) + func TestWithTimestampFormat(t *testing.T) { - tFixed := time.Date(2024, 1, 2, 15, 4, 5, 0, time.UTC) + t.Parallel() + + tFixed := time.Date(formatTestYear, formatTestMonth, formatTestDay, formatTestHour, formatTestMin, formatTestSec, 0, time.UTC) output := &ErrorOutput{ Timestamp: tFixed.Format(time.RFC3339), } - // Test with non-empty format - opt := WithTimestampFormat("2006-01-02") + opt := WithTimestampFormat(dateOnlyLayout) opt(output) - expected := tFixed.Format("2006-01-02") + expected := tFixed.Format(dateOnlyLayout) if output.Timestamp != expected { t.Errorf("Expected timestamp %s, got %s", expected, output.Timestamp) } - // Test with empty format output.Timestamp = tFixed.Format(time.RFC3339) opt = WithTimestampFormat("") opt(output) @@ -36,11 +47,12 @@ func TestWithTimestampFormat(t *testing.T) { } func TestWithStackTrace(t *testing.T) { + t.Parallel() + output := &ErrorOutput{ - Stack: "some stack trace", + Stack: msgSomeStack, } - // Test excluding stack trace opt := WithStackTrace(false) opt(output) @@ -48,32 +60,32 @@ func TestWithStackTrace(t *testing.T) { t.Error("Expected stack trace to be empty when excluded") } - // Test including stack trace - output.Stack = "some stack trace" + output.Stack = msgSomeStack opt = WithStackTrace(true) opt(output) - if output.Stack != "some stack trace" { + if output.Stack != msgSomeStack { t.Error("Expected stack trace to remain when included") } } func TestToErrorOutput(t *testing.T) { - // Create a basic error - err := New("test error") + t.Parallel() + + err := New(msgTestError) output := err.toErrorOutput() - if output.Message != "test error" { - t.Errorf("Expected message 'test error', got '%s'", output.Message) + if output.Message != msgTestError { + t.Errorf("Expected message %q, got %q", msgTestError, output.Message) } - if output.Type != "unknown" { - t.Errorf("Expected type 'unknown', got '%s'", output.Type) + if output.Type != typeUnknownStr { + t.Errorf("Expected type %q, got %q", typeUnknownStr, output.Type) } - if output.Severity != "error" { - t.Errorf("Expected severity 'error', got '%s'", output.Severity) + if output.Severity != severityErrorStr { + t.Errorf("Expected severity %q, got %q", severityErrorStr, output.Severity) } if output.Metadata == nil { @@ -82,43 +94,46 @@ func TestToErrorOutput(t *testing.T) { } func TestToErrorOutputWithContext(t *testing.T) { + t.Parallel() + ctx := &ErrorContext{ RequestID: "req-123", User: "testuser", Component: "test-component", Operation: "test-op", File: "test.go", - Line: 42, - Environment: "test", + Line: formatTestLine, + Environment: msgTest, Type: ErrorTypeInternal, Severity: SeverityCritical, } - err := New("test error") - err.WithContext(ctx) + err := New(msgTestError) + _ = err.WithContext(ctx) output := err.GetErrorContext() - - if output.Type.String() != "internal" { - t.Errorf("Expected type 'internal', got '%s'", output.Type) + if output == nil { + t.Fatal("Expected context to be set") } - if output.Severity.String() != "critical" { - t.Errorf("Expected severity 'critical', got '%s'", output.Severity) + if output.Type.String() != typeInternalStr { + t.Errorf("Expected type %q, got %q", typeInternalStr, output.Type) } - if output == nil { - t.Fatal("Expected context to be set") + if output.Severity.String() != severityCriticalStr { + t.Errorf("Expected severity %q, got %q", severityCriticalStr, output.Severity) } if output.RequestID != "req-123" { - t.Errorf("Expected request_id 'req-123', got '%v'", output.RequestID) + t.Errorf("Expected request_id 'req-123', got %q", output.RequestID) } } func TestToErrorOutputWithCause(t *testing.T) { - rootErr := New("root error") - wrappedErr := Wrap(rootErr, "wrapped error") + t.Parallel() + + rootErr := New(msgRoot) + wrappedErr := Wrap(rootErr, msgWrapped) output := wrappedErr.toErrorOutput() @@ -126,14 +141,15 @@ func TestToErrorOutputWithCause(t *testing.T) { t.Fatal("Expected cause to be set") } - if output.Cause.Message != "root error" { - t.Errorf("Expected cause message 'root error', got '%s'", output.Cause.Message) + if output.Cause.Message != msgRoot { + t.Errorf("Expected cause message %q, got %q", msgRoot, output.Cause.Message) } } func TestToErrorOutputWithStandardError(t *testing.T) { - stdErr := errors.New("standard error") - wrappedErr := Wrap(stdErr, "wrapped standard error") + t.Parallel() + + wrappedErr := Wrap(errStandard, "wrapped standard error") output := wrappedErr.toErrorOutput() @@ -141,45 +157,49 @@ func TestToErrorOutputWithStandardError(t *testing.T) { t.Fatal("Expected cause to be set") } - if output.Cause.Message != "standard error" { - t.Errorf("Expected cause message 'standard error', got '%s'", output.Cause.Message) + if output.Cause.Message != msgStandardErr { + t.Errorf("Expected cause message %q, got %q", msgStandardErr, output.Cause.Message) } - if output.Cause.Type != "unknown" { - t.Errorf("Expected cause type 'unknown', got '%s'", output.Cause.Type) + if output.Cause.Type != typeUnknownStr { + t.Errorf("Expected cause type %q, got %q", typeUnknownStr, output.Cause.Type) } } func TestToErrorOutputWithOptions(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) output := err.toErrorOutput( WithStackTrace(false), - WithTimestampFormat("2006-01-02"), + WithTimestampFormat(dateOnlyLayout), ) if output.Stack != "" { t.Error("Expected stack trace to be empty") } - if _, err := time.Parse("2006-01-02", output.Timestamp); err != nil { + _, parseErr := time.Parse(dateOnlyLayout, output.Timestamp) + if parseErr != nil { t.Errorf("Expected timestamp in format 2006-01-02, got %s", output.Timestamp) } } func TestToJSON(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) jsonStr, jsonErr := err.ToJSON() if jsonErr != nil { - t.Fatalf("Unexpected error: %v", jsonErr) + t.Fatalf(unexpectedErrFn, jsonErr) } if jsonStr == "" { t.Error("Expected non-empty JSON string") } - // Verify it's valid JSON var output ErrorOutput unmarshalErr := json.Unmarshal([]byte(jsonStr), &output) @@ -187,40 +207,42 @@ func TestToJSON(t *testing.T) { t.Errorf("Failed to unmarshal JSON: %v", unmarshalErr) } - if output.Message != "test error" { - t.Errorf("Expected message 'test error', got '%s'", output.Message) + if output.Message != msgTestError { + t.Errorf("Expected message %q, got %q", msgTestError, output.Message) } } func TestToJSONWithOptions(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) jsonStr, jsonErr := err.ToJSON(WithStackTrace(false)) if jsonErr != nil { - t.Fatalf("Unexpected error: %v", jsonErr) + t.Fatalf(unexpectedErrFn, jsonErr) } t.Logf("err: %v", jsonStr) - // Verify stack trace is not included if strings.Contains(jsonStr, "stack") && !strings.Contains(jsonStr, `"stack": ""`) { t.Error("Expected stack trace to be excluded or empty") } } func TestToYAML(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) yamlStr, yamlErr := err.ToYAML() if yamlErr != nil { - t.Fatalf("Unexpected error: %v", yamlErr) + t.Fatalf(unexpectedErrFn, yamlErr) } if yamlStr == "" { t.Error("Expected non-empty YAML string") } - // Verify it's valid YAML var output ErrorOutput unmarshalErr := yaml.Unmarshal([]byte(yamlStr), &output) @@ -228,54 +250,58 @@ func TestToYAML(t *testing.T) { t.Errorf("Failed to unmarshal YAML: %v", unmarshalErr) } - if output.Message != "test error" { - t.Errorf("Expected message 'test error', got '%s'", output.Message) + if output.Message != msgTestError { + t.Errorf("Expected message %q, got %q", msgTestError, output.Message) } } func TestToYAMLWithOptions(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) yamlStr, yamlErr := err.ToYAML(WithStackTrace(false)) if yamlErr != nil { - t.Fatalf("Unexpected error: %v", yamlErr) + t.Fatalf(unexpectedErrFn, yamlErr) } - // Verify stack trace is not included if strings.Contains(yamlStr, "stack:") && !strings.Contains(yamlStr, "stack: \"\"") { t.Error("Expected stack trace to be excluded or empty") } } func TestToErrorOutputWithMetadata(t *testing.T) { - err := New("test error") - err.WithMetadata("custom_field", "custom_value") - err.WithMetadata("another_field", 42) + t.Parallel() + + err := New(msgTestError) + _ = err.WithMetadata("custom_field", "custom_value") + _ = err.WithMetadata("another_field", formatTestLine) output := err.toErrorOutput() if output.Metadata["custom_field"] != "custom_value" { - t.Errorf("Expected custom_field 'custom_value', got '%v'", output.Metadata["custom_field"]) + t.Errorf("Expected custom_field 'custom_value', got %v", output.Metadata["custom_field"]) } - if output.Metadata["another_field"] != 42 { - t.Errorf("Expected another_field 42, got '%v'", output.Metadata["another_field"]) + if output.Metadata["another_field"] != formatTestLine { + t.Errorf("Expected another_field %d, got %v", formatTestLine, output.Metadata["another_field"]) } - // Ensure error_context is not included in metadata if _, exists := output.Metadata["error_context"]; exists { t.Error("Expected error_context to be excluded from metadata") } } func TestToErrorOutputWithRecoverySuggestion(t *testing.T) { + t.Parallel() + rs := &RecoverySuggestion{ Message: "restart service", Actions: []string{"restart"}, Documentation: "https://example.com/recover", } - err := New("test error", WithRecoverySuggestion(rs)) + err := New(msgTestError, WithRecoverySuggestion(rs)) output := err.toErrorOutput() if output.Recovery == nil { @@ -283,10 +309,10 @@ func TestToErrorOutputWithRecoverySuggestion(t *testing.T) { } if output.Recovery.Message != rs.Message { - t.Errorf("expected recovery message '%s', got '%s'", rs.Message, output.Recovery.Message) + t.Errorf("expected recovery message %q, got %q", rs.Message, output.Recovery.Message) } if output.Recovery.Documentation != rs.Documentation { - t.Errorf("expected documentation '%s', got '%s'", rs.Documentation, output.Recovery.Documentation) + t.Errorf("expected documentation %q, got %q", rs.Documentation, output.Recovery.Documentation) } } diff --git a/format_verb.go b/format_verb.go index f24cd1b..ac415a6 100644 --- a/format_verb.go +++ b/format_verb.go @@ -2,7 +2,6 @@ package ewrap import ( "fmt" - "io" "log/slog" ) @@ -13,22 +12,20 @@ import ( // %q double-quoted error message // %v the error message (default) // %+v the error message followed by the stack trace -func (e *Error) Format(s fmt.State, verb rune) { +func (e *Error) Format(state fmt.State, verb rune) { switch verb { case 'v': - if s.Flag('+') { - _, _ = io.WriteString(s, e.Error()) - _, _ = io.WriteString(s, "\n") - _, _ = io.WriteString(s, e.Stack()) + if state.Flag('+') { + fmt.Fprintf(state, "%s\n%s", e.Error(), e.Stack()) return } - fallthrough - case 's': - _, _ = io.WriteString(s, e.Error()) + fmt.Fprint(state, e.Error()) case 'q': - _, _ = fmt.Fprintf(s, "%q", e.Error()) + fmt.Fprintf(state, "%q", e.Error()) + default: + fmt.Fprint(state, e.Error()) } } diff --git a/go.mod b/go.mod index 20bce03..795b358 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module github.com/hyp3rd/ewrap go 1.26.2 require gopkg.in/yaml.v3 v3.0.1 + +require github.com/goccy/go-json v0.10.6 diff --git a/go.sum b/go.sum index a62c313..37d67fb 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/hardening_test.go b/hardening_test.go index 5aca344..b1dc5e3 100644 --- a/hardening_test.go +++ b/hardening_test.go @@ -1,11 +1,18 @@ package ewrap import ( - "encoding/json" "errors" "fmt" "strings" "testing" + + "github.com/goccy/go-json" +) + +const ( + deepChainDepth = 200 + expectedHardeningSeed = 1024 + hardeningExpectedSuffix = "boom" ) // TestDeepChain verifies Error()/Stack()/Unwrap walk arbitrarily deep chains @@ -13,25 +20,21 @@ import ( func TestDeepChain(t *testing.T) { t.Parallel() - const depth = 200 - - root := errors.New("root") - - err := root - for i := range depth { + err := errRoot + for i := range deepChainDepth { err = Wrap(err, fmt.Sprintf("layer-%d", i)) } - if !errors.Is(err, root) { + if !errors.Is(err, errRoot) { t.Fatal("errors.Is should find root through deep chain") } msg := err.Error() - if !strings.Contains(msg, "root") { + if !strings.Contains(msg, msgRoot) { t.Errorf("expected root in message, got %q", msg) } - if !strings.Contains(msg, fmt.Sprintf("layer-%d", depth-1)) { + if !strings.Contains(msg, fmt.Sprintf("layer-%d", deepChainDepth-1)) { t.Errorf("expected outermost layer in message, got %q", msg) } @@ -50,15 +53,13 @@ func TestDeepChain(t *testing.T) { func TestErrorsIs_ContractWithSentinel(t *testing.T) { t.Parallel() - sentinel := errors.New("sentinel") - wrapped := Wrap(sentinel, "outer") + wrapped := Wrap(errSentinel, "outer") - if !errors.Is(wrapped, sentinel) { + if !errors.Is(wrapped, errSentinel) { t.Error("expected sentinel match through ewrap.Wrap") } - other := errors.New("sentinel") // same text, different identity - if errors.Is(wrapped, other) { + if errors.Is(wrapped, errOtherSentinel) { t.Error("must not match a different error with the same text") } } @@ -67,15 +68,16 @@ func TestErrorsIs_ContractWithSentinel(t *testing.T) { func TestNewfWithW(t *testing.T) { t.Parallel() - root := errors.New("root cause") - err := Newf("wrapped: %w", root) + err := Newf("wrapped: %w", errRootCause) - if !errors.Is(err, root) { + if !errors.Is(err, errRootCause) { t.Error("errors.Is must walk through %w") } - if got := err.Error(); got != "wrapped: root cause" { - t.Errorf("got %q, want %q", got, "wrapped: root cause") + const want = "wrapped: root cause" + + if got := err.Error(); got != want { + t.Errorf("got %q, want %q", got, want) } } @@ -83,7 +85,7 @@ func TestNewfWithW(t *testing.T) { func TestWrapStackCapturesWrapSite(t *testing.T) { t.Parallel() - root := New("root") + root := New(msgRoot) wrapped := wrapHelper(root) @@ -105,7 +107,7 @@ func wrapHelper(err error) *Error { // FuzzJSONRoundTrip checks that ToJSON is robust against arbitrary inputs. func FuzzJSONRoundTrip(f *testing.F) { - seeds := []string{"", "boom", "weird \x00 byte", strings.Repeat("a", 1024)} + seeds := []string{"", hardeningExpectedSuffix, "weird \x00 byte", strings.Repeat("a", expectedHardeningSeed)} for _, s := range seeds { f.Add(s) } diff --git a/observability_test.go b/observability_test.go index 03939ee..00282cb 100644 --- a/observability_test.go +++ b/observability_test.go @@ -25,6 +25,8 @@ func (t *testObserver) RecordCircuitStateTransition(name string, from, to Circui } func TestErrorLogRecordsObserver(t *testing.T) { + t.Parallel() + obs := &testObserver{} err := New("boom", WithObserver(obs)) @@ -36,24 +38,26 @@ func TestErrorLogRecordsObserver(t *testing.T) { } func TestCircuitBreakerObserver(t *testing.T) { + t.Parallel() + obs := &testObserver{} timeout := 10 * time.Millisecond - cb := NewCircuitBreakerWithObserver("test", 1, timeout, obs) + cb := NewCircuitBreakerWithObserver(msgTest, 1, timeout, obs) cb.RecordFailure() time.Sleep(timeout + time.Millisecond) if !cb.CanExecute() { - t.Fatalf("expected circuit breaker to allow execution after timeout") + t.Fatal("expected circuit breaker to allow execution after timeout") } cb.RecordSuccess() expected := []stateChange{ - {name: "test", from: CircuitClosed, to: CircuitOpen}, - {name: "test", from: CircuitOpen, to: CircuitHalfOpen}, - {name: "test", from: CircuitHalfOpen, to: CircuitClosed}, + {name: msgTest, from: CircuitClosed, to: CircuitOpen}, + {name: msgTest, from: CircuitOpen, to: CircuitHalfOpen}, + {name: msgTest, from: CircuitHalfOpen, to: CircuitClosed}, } if len(obs.transitions) != len(expected) { @@ -69,38 +73,35 @@ func TestCircuitBreakerObserver(t *testing.T) { } func TestObserverInheritanceInWrap(t *testing.T) { + t.Parallel() + obs := &testObserver{} - // Create original error with observer original := New("original error", WithObserver(obs)) - // Wrap the error - should inherit the observer wrapped := Wrap(original, "wrapped error") - // Log both errors original.Log() wrapped.Log() - // Should record 2 errors if obs.errorCount != 2 { t.Fatalf("expected 2 errors recorded, got %d", obs.errorCount) } } func TestCircuitBreakerSetObserver(t *testing.T) { + t.Parallel() + obs := &testObserver{} - // Create circuit breaker without observer - cb := NewCircuitBreaker("test", 1, 10*time.Millisecond) + cb := NewCircuitBreaker(msgTest, 1, 10*time.Millisecond) - // Set observer later cb.SetObserver(obs) - // Trigger a state transition cb.RecordFailure() expected := []stateChange{ - {name: "test", from: CircuitClosed, to: CircuitOpen}, + {name: msgTest, from: CircuitClosed, to: CircuitOpen}, } if len(obs.transitions) != len(expected) { @@ -113,11 +114,11 @@ func TestCircuitBreakerSetObserver(t *testing.T) { } func TestObserverIsOptional(t *testing.T) { - // Create error without observer - should not panic - err := New("test error") + t.Parallel() + + err := New(msgTestError) err.Log() // Should not panic - // Create circuit breaker without observer - should not panic - cb := NewCircuitBreaker("test", 1, 10*time.Millisecond) + cb := NewCircuitBreaker(msgTest, 1, 10*time.Millisecond) cb.RecordFailure() // Should not panic } diff --git a/retry_test.go b/retry_test.go index 05c5cec..662ca06 100644 --- a/retry_test.go +++ b/retry_test.go @@ -6,17 +6,18 @@ import ( ) func TestWithRetry(t *testing.T) { - maxAttempts := 3 + t.Parallel() + delay := time.Second - err := New("test error", WithRetry(maxAttempts, delay)) + err := New(msgTestError, WithRetry(defaultMaxAttempts, delay)) retryInfo := err.Retry() if retryInfo == nil { t.Fatal("expected non-nil retry info") } - if retryInfo.MaxAttempts != maxAttempts { - t.Errorf("MaxAttempts: got %d, want %d", retryInfo.MaxAttempts, maxAttempts) + if retryInfo.MaxAttempts != defaultMaxAttempts { + t.Errorf("MaxAttempts: got %d, want %d", retryInfo.MaxAttempts, defaultMaxAttempts) } if retryInfo.Delay != delay { @@ -37,8 +38,12 @@ func TestWithRetry(t *testing.T) { } func TestCanRetry(t *testing.T) { + t.Parallel() + t.Run("WithValidRetryInfo", func(t *testing.T) { - err := New("test error", WithRetry(3, time.Second)) + t.Parallel() + + err := New(msgTestError, WithRetry(defaultMaxAttempts, time.Second)) if !err.CanRetry() { t.Error("expected CanRetry true with attempts remaining") } @@ -63,7 +68,9 @@ func TestCanRetry(t *testing.T) { }) t.Run("WithoutRetryInfo", func(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) if err.CanRetry() { t.Error("expected CanRetry false without retry info") } @@ -71,8 +78,10 @@ func TestCanRetry(t *testing.T) { } func TestWithRetryCustomShouldRetry(t *testing.T) { + t.Parallel() + shouldRetry := func(error) bool { return false } - err := New("test error", WithRetry(3, time.Second, WithRetryShould(shouldRetry))) + err := New(msgTestError, WithRetry(defaultMaxAttempts, time.Second, WithRetryShould(shouldRetry))) if err.CanRetry() { t.Error("expected CanRetry false with predicate returning false") @@ -80,7 +89,11 @@ func TestWithRetryCustomShouldRetry(t *testing.T) { } func TestDefaultShouldRetry(t *testing.T) { + t.Parallel() + t.Run("ValidationError", func(t *testing.T) { + t.Parallel() + err := New("validation error"). WithContext(&ErrorContext{Type: ErrorTypeValidation}) if defaultShouldRetry(err) { @@ -89,6 +102,8 @@ func TestDefaultShouldRetry(t *testing.T) { }) t.Run("OtherError", func(t *testing.T) { + t.Parallel() + err := New("other error"). WithContext(&ErrorContext{Type: ErrorTypeInternal}) if !defaultShouldRetry(err) { @@ -97,6 +112,8 @@ func TestDefaultShouldRetry(t *testing.T) { }) t.Run("NoContext", func(t *testing.T) { + t.Parallel() + err := New("no context error") if !defaultShouldRetry(err) { t.Error("expected defaultShouldRetry true when no context set") @@ -105,8 +122,12 @@ func TestDefaultShouldRetry(t *testing.T) { } func TestIncrementRetry(t *testing.T) { + t.Parallel() + t.Run("WithRetryInfo", func(t *testing.T) { - err := New("test error", WithRetry(3, time.Second)) + t.Parallel() + + err := New(msgTestError, WithRetry(defaultMaxAttempts, time.Second)) initialTime := err.Retry().LastAttempt time.Sleep(time.Millisecond) @@ -123,7 +144,9 @@ func TestIncrementRetry(t *testing.T) { }) t.Run("WithoutRetryInfo", func(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) err.IncrementRetry() // Should not panic }) } diff --git a/stack_test.go b/stack_test.go index e21a540..3dff69f 100644 --- a/stack_test.go +++ b/stack_test.go @@ -1,20 +1,19 @@ package ewrap import ( - "encoding/json" - "errors" "strings" "testing" + "github.com/goccy/go-json" "gopkg.in/yaml.v3" ) func TestStackIterator(t *testing.T) { - // Create an error to get a stack trace - err := New("test error") + t.Parallel() + + err := New(msgTestError) iterator := err.GetStackIterator() - // Test HasNext and Next frameCount := 0 for iterator.HasNext() { @@ -30,19 +29,16 @@ func TestStackIterator(t *testing.T) { t.Error("Expected at least one frame") } - // Test that Next returns nil after iteration is complete if iterator.Next() != nil { t.Error("Expected nil after iteration complete") } - // Test Reset iterator.Reset() if !iterator.HasNext() { t.Error("Expected frames after reset") } - // Test AllFrames allFrames := iterator.AllFrames() if len(allFrames) != frameCount { t.Errorf("Expected %d frames, got %d", frameCount, len(allFrames)) @@ -50,7 +46,9 @@ func TestStackIterator(t *testing.T) { } func TestStackFrameStructure(t *testing.T) { - err := New("test error") + t.Parallel() + + err := New(msgTestError) frames := err.GetStackFrames() if len(frames) == 0 { @@ -76,34 +74,35 @@ func TestStackFrameStructure(t *testing.T) { } func TestErrorGroupSerialization(t *testing.T) { + t.Parallel() + eg := NewErrorGroup() - // Add different types of errors - eg.Add(New("ewrap error").WithMetadata("key", "value")) + eg.Add(New("ewrap error").WithMetadata(msgKey, msgValue)) eg.Add(New("another error")) - // Test ToSerialization serializable := eg.ToSerialization() - if serializable.ErrorCount != 2 { - t.Errorf("Expected 2 errors, got %d", serializable.ErrorCount) + const wantCount = 2 + + if serializable.ErrorCount != wantCount { + t.Errorf("Expected %d errors, got %d", wantCount, serializable.ErrorCount) } - if len(serializable.Errors) != 2 { - t.Errorf("Expected 2 serialized errors, got %d", len(serializable.Errors)) + if len(serializable.Errors) != wantCount { + t.Errorf("Expected %d serialized errors, got %d", wantCount, len(serializable.Errors)) } - // Check first error firstErr := serializable.Errors[0] if firstErr.Type != "ewrap" { - t.Errorf("Expected type 'ewrap', got '%s'", firstErr.Type) + t.Errorf("Expected type 'ewrap', got %q", firstErr.Type) } if firstErr.Message != "ewrap error" { - t.Errorf("Expected message 'ewrap error', got '%s'", firstErr.Message) + t.Errorf("Expected message 'ewrap error', got %q", firstErr.Message) } - if firstErr.Metadata == nil || firstErr.Metadata["key"] != "value" { + if firstErr.Metadata == nil || firstErr.Metadata[msgKey] != msgValue { t.Error("Expected metadata to be preserved") } @@ -113,10 +112,11 @@ func TestErrorGroupSerialization(t *testing.T) { } func TestErrorGroupJSON(t *testing.T) { + t.Parallel() + eg := NewErrorGroup() - eg.Add(New("test error")) + eg.Add(New(msgTestError)) - // Test ToJSON jsonStr, err := eg.ToJSON() if err != nil { t.Fatalf("Failed to convert to JSON: %v", err) @@ -126,16 +126,16 @@ func TestErrorGroupJSON(t *testing.T) { t.Error("Expected non-empty JSON string") } - // Verify it's valid JSON by unmarshaling var result map[string]any - if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { - t.Errorf("Failed to unmarshal JSON: %v", err) + + unmarshalErr := json.Unmarshal([]byte(jsonStr), &result) + if unmarshalErr != nil { + t.Errorf("Failed to unmarshal JSON: %v", unmarshalErr) } - // Test MarshalJSON interface - jsonBytes, err := json.Marshal(eg) - if err != nil { - t.Fatalf("Failed to marshal using json.Marshal: %v", err) + jsonBytes, marshalErr := json.Marshal(eg) + if marshalErr != nil { + t.Fatalf("Failed to marshal using json.Marshal: %v", marshalErr) } if len(jsonBytes) == 0 { @@ -144,10 +144,11 @@ func TestErrorGroupJSON(t *testing.T) { } func TestErrorGroupYAML(t *testing.T) { + t.Parallel() + eg := NewErrorGroup() - eg.Add(New("test error")) + eg.Add(New(msgTestError)) - // Test ToYAML yamlStr, err := eg.ToYAML() if err != nil { t.Fatalf("Failed to convert to YAML: %v", err) @@ -157,16 +158,16 @@ func TestErrorGroupYAML(t *testing.T) { t.Error("Expected non-empty YAML string") } - // Verify it's valid YAML by unmarshaling var result map[string]any - if err := yaml.Unmarshal([]byte(yamlStr), &result); err != nil { - t.Errorf("Failed to unmarshal YAML: %v", err) + + unmarshalErr := yaml.Unmarshal([]byte(yamlStr), &result) + if unmarshalErr != nil { + t.Errorf("Failed to unmarshal YAML: %v", unmarshalErr) } - // Test MarshalYAML interface - yamlData, err := yaml.Marshal(eg) - if err != nil { - t.Fatalf("Failed to marshal using yaml.Marshal: %v", err) + yamlData, marshalErr := yaml.Marshal(eg) + if marshalErr != nil { + t.Fatalf("Failed to marshal using yaml.Marshal: %v", marshalErr) } if len(yamlData) == 0 { @@ -175,10 +176,11 @@ func TestErrorGroupYAML(t *testing.T) { } func TestErrorGroupSerializationWithWrappedErrors(t *testing.T) { + t.Parallel() + eg := NewErrorGroup() - // Create a chain of wrapped errors - rootErr := New("root cause") + rootErr := New(msgRootCause) wrappedErr := Wrap(rootErr, "wrapped error") eg.Add(wrappedErr) @@ -188,25 +190,27 @@ func TestErrorGroupSerializationWithWrappedErrors(t *testing.T) { t.Fatalf("Expected 1 error, got %d", len(serializable.Errors)) } - err := serializable.Errors[0] - if err.Message != "wrapped error: root cause" { - t.Errorf("Expected wrapped message, got '%s'", err.Message) + const want = "wrapped error: root cause" + + got := serializable.Errors[0] + if got.Message != want { + t.Errorf("Expected message %q, got %q", want, got.Message) } - if err.Cause == nil { + if got.Cause == nil { t.Error("Expected cause to be serialized") } - if err.Cause.Message != "root cause" { - t.Errorf("Expected cause message 'root cause', got '%s'", err.Cause.Message) + if got.Cause.Message != msgRootCause { + t.Errorf("Expected cause message %q, got %q", msgRootCause, got.Cause.Message) } } func TestErrorGroupSerializationWithStandardErrors(t *testing.T) { - eg := NewErrorGroup() + t.Parallel() - // Add standard Go error - eg.Add(errors.New("standard error")) + eg := NewErrorGroup() + eg.Add(errStandard) serializable := eg.ToSerialization() @@ -214,21 +218,23 @@ func TestErrorGroupSerializationWithStandardErrors(t *testing.T) { t.Fatalf("Expected 1 error, got %d", len(serializable.Errors)) } - err := serializable.Errors[0] - if err.Type != "standard" { - t.Errorf("Expected type 'standard', got '%s'", err.Type) + got := serializable.Errors[0] + if got.Type != "standard" { + t.Errorf("Expected type 'standard', got %q", got.Type) } - if len(err.StackTrace) != 0 { + if len(got.StackTrace) != 0 { t.Error("Expected no stack trace for standard error") } - if err.Metadata != nil { + if got.Metadata != nil { t.Error("Expected no metadata for standard error") } } func TestEmptyErrorGroupSerialization(t *testing.T) { + t.Parallel() + eg := NewErrorGroup() serializable := eg.ToSerialization() @@ -241,7 +247,6 @@ func TestEmptyErrorGroupSerialization(t *testing.T) { t.Errorf("Expected 0 serialized errors, got %d", len(serializable.Errors)) } - // Test JSON serialization of empty group jsonStr, err := eg.ToJSON() if err != nil { t.Fatalf("Failed to serialize empty group to JSON: %v", err) @@ -253,30 +258,37 @@ func TestEmptyErrorGroupSerialization(t *testing.T) { } func BenchmarkErrorGroupSerialization(b *testing.B) { + const errorCount = 10 + eg := NewErrorGroup() - for i := range 10 { + for i := range errorCount { eg.Add(New("error").WithMetadata("index", i)) } - b.Run("JSON", func(b *testing.B) { - b.ReportAllocs() + b.Run("JSON", func(b *testing.B) { benchSerializationJSON(b, eg) }) + b.Run("YAML", func(b *testing.B) { benchSerializationYAML(b, eg) }) +} + +func benchSerializationJSON(b *testing.B, eg *ErrorGroup) { + b.Helper() + b.ReportAllocs() - for range b.N { - _, err := eg.ToJSON() - if err != nil { - b.Fatal(err) - } + for range b.N { + _, err := eg.ToJSON() + if err != nil { + b.Fatal(err) } - }) + } +} - b.Run("YAML", func(b *testing.B) { - b.ReportAllocs() +func benchSerializationYAML(b *testing.B, eg *ErrorGroup) { + b.Helper() + b.ReportAllocs() - for range b.N { - _, err := eg.ToYAML() - if err != nil { - b.Fatal(err) - } + for range b.N { + _, err := eg.ToYAML() + if err != nil { + b.Fatal(err) } - }) + } } diff --git a/test/benchmark_test.go b/test/benchmark_test.go index 0200b0b..f3f48bf 100644 --- a/test/benchmark_test.go +++ b/test/benchmark_test.go @@ -10,12 +10,22 @@ import ( "github.com/hyp3rd/ewrap" ) +const ( + benchMetadataKeys = 5 + benchMetadataIntValue = 42 + benchAddErrorCount = 10 + benchBreakerFailLimit = 5 + benchBreakerLargeMax = 1000 +) + +var errBaseBench = errors.New("base error") + // mockLogger implements a minimal logger for benchmarking. type mockLogger struct{} -func (m *mockLogger) Error(msg string, keysAndValues ...any) {} -func (m *mockLogger) Debug(msg string, keysAndValues ...any) {} -func (m *mockLogger) Info(msg string, keysAndValues ...any) {} +func (*mockLogger) Error(string, ...any) {} +func (*mockLogger) Debug(string, ...any) {} +func (*mockLogger) Info(string, ...any) {} // BenchmarkNew measures the performance of creating new errors. func BenchmarkNew(b *testing.B) { @@ -56,7 +66,7 @@ func BenchmarkNew(b *testing.B) { ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError), ewrap.WithLogger(logger)). WithMetadata("key1", "value1"). - WithMetadata("key2", 42) + WithMetadata("key2", benchMetadataIntValue) } }) } @@ -65,13 +75,12 @@ func BenchmarkNew(b *testing.B) { func BenchmarkWrap(b *testing.B) { logger := &mockLogger{} ctx := context.Background() - baseErr := errors.New("base error") b.Run("Simple", func(b *testing.B) { b.ReportAllocs() for range b.N { - _ = ewrap.Wrap(baseErr, "wrapped error") + _ = ewrap.Wrap(errBaseBench, "wrapped error") } }) @@ -79,7 +88,7 @@ func BenchmarkWrap(b *testing.B) { b.ReportAllocs() for range b.N { - err1 := ewrap.Wrap(baseErr, "level 1") + err1 := ewrap.Wrap(errBaseBench, "level 1") err2 := ewrap.Wrap(err1, "level 2") _ = ewrap.Wrap(err2, "level 3") } @@ -89,7 +98,7 @@ func BenchmarkWrap(b *testing.B) { b.ReportAllocs() for range b.N { - _ = ewrap.Wrap(baseErr, "wrapped with context", + _ = ewrap.Wrap(errBaseBench, "wrapped with context", ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) } }) @@ -98,7 +107,7 @@ func BenchmarkWrap(b *testing.B) { b.ReportAllocs() for range b.N { - _ = ewrap.Wrap(baseErr, "full featured wrap", + _ = ewrap.Wrap(errBaseBench, "full featured wrap", ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError), ewrap.WithLogger(logger)). WithMetadata("key1", "value1") @@ -114,8 +123,8 @@ func BenchmarkErrorGroup(b *testing.B) { for range b.N { group := ewrap.NewErrorGroup() - for j := range 10 { - group.Add(fmt.Errorf("error %d", j)) + for j := range benchAddErrorCount { + group.Add(fmt.Errorf("%w %d", errBaseBench, j)) } } }) @@ -126,7 +135,7 @@ func BenchmarkErrorGroup(b *testing.B) { b.RunParallel(func(pb *testing.PB) { i := 0 for pb.Next() { - group.Add(fmt.Errorf("error %d", i)) + group.Add(fmt.Errorf("%w %d", errBaseBench, i)) i++ } }) @@ -138,7 +147,7 @@ func BenchmarkFormatting(b *testing.B) { err := ewrap.New("test error", ewrap.WithContext(context.Background(), ewrap.ErrorTypeDatabase, ewrap.SeverityError)). WithMetadata("key1", "value1"). - WithMetadata("key2", 42) + WithMetadata("key2", benchMetadataIntValue) b.Run("ToJSON", func(b *testing.B) { b.ReportAllocs() @@ -170,32 +179,35 @@ func BenchmarkFormatting(b *testing.B) { // BenchmarkCircuitBreaker measures the performance of circuit breaker operations. func BenchmarkCircuitBreaker(b *testing.B) { - b.Run("RecordFailure", func(b *testing.B) { - cb := ewrap.NewCircuitBreaker("test", 5, time.Second) + b.Run("RecordFailure", benchBreakerRecordFailure) + b.Run("ConcurrentOperations", benchBreakerConcurrent) +} - b.ReportAllocs() +func benchBreakerRecordFailure(b *testing.B) { + cb := ewrap.NewCircuitBreaker("test", benchBreakerFailLimit, time.Second) - for i := range b.N { - cb.RecordFailure() + b.ReportAllocs() - if i%5 == 0 { - cb.RecordSuccess() // Reset occasionally - } + for i := range b.N { + cb.RecordFailure() + + if i%benchBreakerFailLimit == 0 { + cb.RecordSuccess() } - }) + } +} - b.Run("ConcurrentOperations", func(b *testing.B) { - cb := ewrap.NewCircuitBreaker("test", 1000, time.Second) +func benchBreakerConcurrent(b *testing.B) { + cb := ewrap.NewCircuitBreaker("test", benchBreakerLargeMax, time.Second) - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - if cb.CanExecute() { - cb.RecordSuccess() - } else { - cb.RecordFailure() - } + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if cb.CanExecute() { + cb.RecordSuccess() + } else { + cb.RecordFailure() } - }) + } }) } @@ -206,16 +218,16 @@ func BenchmarkMetadataOperations(b *testing.B) { for range b.N { err := ewrap.New("test error") - for j := range 5 { - err.WithMetadata(fmt.Sprintf("key%d", j), j) + for j := range benchMetadataKeys { + _ = err.WithMetadata(fmt.Sprintf("key%d", j), j) } } }) b.Run("GetMetadata", func(b *testing.B) { err := ewrap.New("test error") - for i := range 5 { - err.WithMetadata(fmt.Sprintf("key%d", i), i) + for i := range benchMetadataKeys { + _ = err.WithMetadata(fmt.Sprintf("key%d", i), i) } b.ReportAllocs() diff --git a/test/profile_test.go b/test/profile_test.go index c1d990f..6af6c76 100644 --- a/test/profile_test.go +++ b/test/profile_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "runtime" "runtime/pprof" "testing" @@ -13,133 +14,155 @@ import ( "github.com/hyp3rd/ewrap" ) +const ( + profileIterations = 10000 + profileWrapDepth = 10 + profileGoroutines = 100 + profileGoroutineWork = 1000 + profileGroupAdds = 5 + profileBreakerMax = 1000 +) + +var ( + errHeapProfileMissing = errors.New("could not find heap profile") + errGoroutineProfileMissing = errors.New("could not find goroutine profile") +) + +type profileCase struct { + name string + setup func() + cleanup func() + profile func(file *os.File) error +} + +func cpuProfileCase() profileCase { + return profileCase{ + name: "cpu", + setup: func() {}, + cleanup: func() {}, + profile: func(file *os.File) error { + err := pprof.StartCPUProfile(file) + if err != nil { + return fmt.Errorf("could not start CPU profile: %w", err) + } + + profileCPU() + pprof.StopCPUProfile() + + return nil + }, + } +} + +func heapProfileCase() profileCase { + return profileCase{ + name: "heap", + setup: forceGC, + cleanup: forceGC, + profile: func(file *os.File) error { + prof := pprof.Lookup("heap") + if prof == nil { + return errHeapProfileMissing + } + + profileMemory() + + return prof.WriteTo(file, 0) + }, + } +} + +func goroutineProfileCase() profileCase { + return profileCase{ + name: "goroutine", + setup: func() {}, + cleanup: func() {}, + profile: func(file *os.File) error { + prof := pprof.Lookup("goroutine") + if prof == nil { + return errGoroutineProfileMissing + } + + profileGoroutinesFn() + + return prof.WriteTo(file, 0) + }, + } +} + +// forceGC explicitly triggers garbage collection so heap profiles capture a +// post-collection snapshot. Profile-only helper; production code never needs +// this. +func forceGC() { + runtime.GC() //nolint:revive // explicit GC is intentional for profile snapshots +} + // TestProfileErrorOperations runs a comprehensive profiling suite for error operations. // It generates CPU, memory, and goroutine profiles to analyze the performance characteristics // of our error handling implementation. +// +// This test mutates the global runtime.MemProfileRate and emits profile files +// to the working directory, so it cannot be parallelised. +// +//nolint:paralleltest // mutates runtime.MemProfileRate global state func TestProfileErrorOperations(t *testing.T) { - // Skip in normal testing if testing.Short() { t.Skip("Skipping profiling in short mode") } - // Enable memory profiling with a rate of 1 means we sample every allocation runtime.MemProfileRate = 1 - profiles := []struct { - name string - profName string // The name pprof uses internally - setup func() - cleanup func() - profile func(f *os.File) error - }{ - { - name: "cpu", - setup: func() { - // No specific setup needed for CPU profiling - }, - cleanup: func() { - // No specific cleanup needed for CPU profiling - }, - profile: func(f *os.File) error { - err := pprof.StartCPUProfile(f) - if err != nil { - return fmt.Errorf("could not start CPU profile: %w", err) - } - - profileCPU() - pprof.StopCPUProfile() - - return nil - }, - }, - { - name: "heap", - profName: "heap", - setup: func() { - // Force garbage collection before memory profiling - runtime.GC() - }, - cleanup: func() { - // Force garbage collection after memory profiling - runtime.GC() - }, - profile: func(f *os.File) error { - p := pprof.Lookup("heap") - if p == nil { - return errors.New("could not find heap profile") - } - - profileMemory() - - return p.WriteTo(f, 0) - }, - }, - { - name: "goroutine", - profName: "goroutine", - setup: func() {}, - cleanup: func() {}, - profile: func(f *os.File) error { - p := pprof.Lookup("goroutine") - if p == nil { - return errors.New("could not find goroutine profile") - } - - profileGoroutines() - - return p.WriteTo(f, 0) - }, - }, - } - - for _, profile := range profiles { + for _, profile := range []profileCase{cpuProfileCase(), heapProfileCase(), goroutineProfileCase()} { + //nolint:paralleltest // sequential by design — see TestProfileErrorOperations t.Run(profile.name, func(t *testing.T) { - // Create profile file - filename := fmt.Sprintf("profile_%s.prof", profile.name) + runProfileCase(t, profile) + }) + } +} - f, err := os.Create(filename) - if err != nil { - t.Fatalf("could not create %s profile file: %v", profile.name, err) - } +func runProfileCase(t *testing.T, profile profileCase) { + t.Helper() - defer func() { - err := f.Close() - if err != nil { - t.Errorf("error closing profile file: %v", err) - } - }() + filename := filepath.Clean(fmt.Sprintf("profile_%s.prof", profile.name)) - // Run setup - profile.setup() + file, err := os.Create(filename) + if err != nil { + t.Fatalf("could not create %s profile file: %v", profile.name, err) + } - // Run profiling - if err := profile.profile(f); err != nil { - t.Fatalf("error writing %s profile: %v", profile.name, err) - } + defer func() { + closeErr := file.Close() + if closeErr != nil { + t.Errorf("error closing profile file: %v", closeErr) + } + }() - // Run cleanup - profile.cleanup() + profile.setup() - t.Logf("Profile written to %s", filename) - }) + profileErr := profile.profile(file) + if profileErr != nil { + t.Fatalf("error writing %s profile: %v", profile.name, profileErr) } + + profile.cleanup() + + t.Logf("Profile written to %s", filename) } func profileCPU() { - // Simulate intensive error handling operations ctx := context.Background() logger := &mockLogger{} - for i := range 10000 { + for i := range profileIterations { err := ewrap.New(fmt.Sprintf("error %d", i), ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), ewrap.WithLogger(logger)) err = ewrap.Wrap(err, "wrapped") - err.WithMetadata("key", i) + _ = err.WithMetadata("key", i) group := ewrap.NewErrorGroup() - for range 5 { + for range profileGroupAdds { group.Add(err) } @@ -149,56 +172,54 @@ func profileCPU() { } func profileMemory() { - // Force GC before profiling - runtime.GC() - - // Simulate memory-intensive operations - var errors []*ewrap.Error + forceGC() + captured := make([]*ewrap.Error, 0, profileIterations) ctx := context.Background() - for i := range 10000 { + for i := range profileIterations { err := ewrap.New(fmt.Sprintf("error %d", i), ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) - for j := range 10 { + for j := range profileWrapDepth { err = ewrap.Wrap(err, fmt.Sprintf("layer %d", j)) - err.WithMetadata(fmt.Sprintf("key%d", j), j) + _ = err.WithMetadata(fmt.Sprintf("key%d", j), j) } - errors = append(errors, err) + captured = append(captured, err) } - for err := range errors { - fmt.Printf("err: %v\n", err) + if len(captured) == 0 { + panic("captured slice unexpectedly empty") } } -func profileGoroutines() { - // Simulate concurrent error handling - const numGoroutines = 100 - +func profileGoroutinesFn() { done := make(chan bool) - cb := ewrap.NewCircuitBreaker("test", 1000, time.Second) - - for i := range numGoroutines { - go func(id int) { - for j := range 1000 { - if cb.CanExecute() { - err := ewrap.New(fmt.Sprintf("error %d-%d", id, j)) - err.WithMetadata("goroutine", id) - cb.RecordSuccess() - } else { - cb.RecordFailure() - } - } + cb := ewrap.NewCircuitBreaker("test", profileBreakerMax, time.Second) - done <- true - }(i) + for i := range profileGoroutines { + go runProfileGoroutine(cb, i, done) } - for range numGoroutines { + for range profileGoroutines { <-done } } + +func runProfileGoroutine(cb *ewrap.CircuitBreaker, id int, done chan<- bool) { + for j := range profileGoroutineWork { + if cb.CanExecute() { + err := ewrap.New(fmt.Sprintf("error %d-%d", id, j)) + + _ = err.WithMetadata("goroutine", id) + + cb.RecordSuccess() + } else { + cb.RecordFailure() + } + } + + done <- true +} diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..91915a0 --- /dev/null +++ b/test_helpers_test.go @@ -0,0 +1,48 @@ +package ewrap + +import "errors" + +// Shared test fixtures. Centralising these silences goconst/revive +// add-constant warnings and keeps tests readable. + +const ( + msgTest = "test" + msgTestError = "test error" + msgBoom = "boom" + msgKey = "key" + msgValue = "value" + msgSomeStack = "some stack trace" + msgOriginal = "original" + msgOriginalErr = "original error" + msgCauseError = "cause error" + msgFirst = "first" + msgSecond = "second" + msgRoot = "root" + msgRootCause = "root cause" + msgPlain = "plain" + msgSentinel = "sentinel" + msgWrapped = "wrapped" + msgStandardErr = "standard error" + msgErrorMessage = "error message" + + defaultMaxAttempts = 3 + smallStringLength = 100 + concurrencyLimit = 100 +) + +var ( + errOriginal = errors.New(msgOriginal) + errOriginalLong = errors.New(msgOriginalErr) + errCause = errors.New(msgCauseError) + errFirst = errors.New(msgFirst) + errSecond = errors.New(msgSecond) + errSentinel = errors.New(msgSentinel) + errOtherSentinel = errors.New(msgSentinel) // distinct identity, identical text + errOther = errors.New("other") + errPlain = errors.New(msgPlain) + errStandard = errors.New(msgStandardErr) + errRoot = errors.New(msgRoot) + errRootCause = errors.New(msgRootCause) + errFromGoroutine = errors.New("error from goroutine") + errIndexed = errors.New("indexed error") +) diff --git a/threshold.go b/threshold.go index 218b9e4..b0ace53 100644 --- a/threshold.go +++ b/threshold.go @@ -129,7 +129,11 @@ func (cb *CircuitBreaker) CanExecute() bool { event = cb.setStateLocked(CircuitHalfOpen) can = true } + default: + // Unknown state — refuse to execute and record nothing. Defensive + // only; CircuitState is a closed enum. } + cb.mu.Unlock() cb.fireTransition(event) diff --git a/threshold_test.go b/threshold_test.go index 5b9589e..281a10e 100644 --- a/threshold_test.go +++ b/threshold_test.go @@ -6,19 +6,27 @@ import ( "time" ) +const ( + thresholdMaxFailures = 3 + thresholdConcurrency = 100 + thresholdTimeoutSeconds = 5 + thresholdConcurrencyLimit = 100 +) + func TestNewCircuitBreaker(t *testing.T) { - name := "test-circuit" - maxFailures := 3 - timeout := 5 * time.Second + t.Parallel() + + const name = "test-circuit" - cb := NewCircuitBreaker(name, maxFailures, timeout) + timeout := thresholdTimeoutSeconds * time.Second + cb := NewCircuitBreaker(name, thresholdMaxFailures, timeout) if cb.name != name { t.Errorf("Expected name %s, got %s", name, cb.name) } - if cb.maxFailures != maxFailures { - t.Errorf("Expected maxFailures %d, got %d", maxFailures, cb.maxFailures) + if cb.maxFailures != thresholdMaxFailures { + t.Errorf("Expected maxFailures %d, got %d", thresholdMaxFailures, cb.maxFailures) } if cb.timeout != timeout { @@ -35,9 +43,10 @@ func TestNewCircuitBreaker(t *testing.T) { } func TestCircuitBreakerRecordFailure(t *testing.T) { - cb := NewCircuitBreaker("test", 2, 5*time.Second) + t.Parallel() + + cb := NewCircuitBreaker(msgTest, 2, thresholdTimeoutSeconds*time.Second) - // First failure - should remain closed cb.RecordFailure() if cb.state != CircuitClosed { @@ -48,7 +57,6 @@ func TestCircuitBreakerRecordFailure(t *testing.T) { t.Errorf("Expected failure count 1, got %d", cb.failureCount) } - // Second failure - should open circuit cb.RecordFailure() if cb.state != CircuitOpen { @@ -61,21 +69,20 @@ func TestCircuitBreakerRecordFailure(t *testing.T) { } func TestCircuitBreakerRecordSuccess(t *testing.T) { - cb := NewCircuitBreaker("test", 1, 5*time.Second) + t.Parallel() + + cb := NewCircuitBreaker(msgTest, 1, thresholdTimeoutSeconds*time.Second) - // Record failure to open circuit cb.RecordFailure() if cb.state != CircuitOpen { t.Error("Expected circuit to be open") } - // Manually set to half-open cb.mu.Lock() cb.state = CircuitHalfOpen cb.mu.Unlock() - // Record success - should close circuit cb.RecordSuccess() if cb.state != CircuitClosed { @@ -88,29 +95,27 @@ func TestCircuitBreakerRecordSuccess(t *testing.T) { } func TestCircuitBreakerCanExecute(t *testing.T) { - timeout := 100 * time.Millisecond - cb := NewCircuitBreaker("test", 1, timeout) + t.Parallel() + + timeout := thresholdConcurrencyLimit * time.Millisecond + cb := NewCircuitBreaker(msgTest, 1, timeout) - // Initially closed - should allow execution if !cb.CanExecute() { t.Error("Expected CanExecute to return true for closed circuit") } - // Record failure to open circuit cb.RecordFailure() if cb.CanExecute() { t.Error("Expected CanExecute to return false for open circuit") } - // Wait for timeout and check transition to half-open time.Sleep(timeout + 10*time.Millisecond) if !cb.CanExecute() { t.Error("Expected CanExecute to return true after timeout (half-open)") } - // Verify state is now half-open cb.mu.Lock() state := cb.state cb.mu.Unlock() @@ -121,66 +126,65 @@ func TestCircuitBreakerCanExecute(t *testing.T) { } func TestCircuitBreakerOnStateChange(t *testing.T) { - cb := NewCircuitBreaker("test", 1, 5*time.Second) + t.Parallel() + + cb := NewCircuitBreaker(msgTest, 1, thresholdTimeoutSeconds*time.Second) + + type recordedChange struct { + name string + from CircuitState + to CircuitState + } var ( - stateChanges []struct { - name string - from CircuitState - to CircuitState - } - mu sync.Mutex + stateChanges []recordedChange + mu sync.Mutex ) cb.OnStateChange(func(name string, from, to CircuitState) { mu.Lock() - stateChanges = append(stateChanges, struct { - name string - from CircuitState - to CircuitState - }{name, from, to}) + stateChanges = append(stateChanges, recordedChange{name, from, to}) mu.Unlock() }) - // Record failure to trigger state change. Callback fires synchronously - // once the breaker lock has been released, so no sleep is needed. cb.RecordFailure() mu.Lock() + defer mu.Unlock() if len(stateChanges) != 1 { t.Errorf("Expected 1 state change, got %d", len(stateChanges)) - } else { - change := stateChanges[0] - if change.name != "test" { - t.Errorf("Expected name 'test', got %s", change.name) - } - if change.from != CircuitClosed { - t.Errorf("Expected from state %v, got %v", CircuitClosed, change.from) - } + return + } - if change.to != CircuitOpen { - t.Errorf("Expected to state %v, got %v", CircuitOpen, change.to) - } + change := stateChanges[0] + if change.name != msgTest { + t.Errorf("Expected name %q, got %s", msgTest, change.name) + } + + if change.from != CircuitClosed { + t.Errorf("Expected from state %v, got %v", CircuitClosed, change.from) } - mu.Unlock() + if change.to != CircuitOpen { + t.Errorf("Expected to state %v, got %v", CircuitOpen, change.to) + } } func TestCircuitBreakerTransitionViaPublicAPI(t *testing.T) { - cb := NewCircuitBreaker("test", 1, 5*time.Second) + t.Parallel() + + cb := NewCircuitBreaker(msgTest, 1, thresholdTimeoutSeconds*time.Second) - // Single failure trips the circuit (maxFailures=1). cb.RecordFailure() if cb.state != CircuitOpen { t.Errorf("Expected state %v, got %v", CircuitOpen, cb.state) } - // A second failure does not change state away from Open. cb.RecordFailure() if cb.state != CircuitOpen { @@ -189,21 +193,19 @@ func TestCircuitBreakerTransitionViaPublicAPI(t *testing.T) { } func TestCircuitBreakerConcurrency(t *testing.T) { - cb := NewCircuitBreaker("test", 5, 100*time.Millisecond) + t.Parallel() - var wg sync.WaitGroup + cb := NewCircuitBreaker(msgTest, thresholdTimeoutSeconds, thresholdConcurrencyLimit*time.Millisecond) - iterations := 100 + var wg sync.WaitGroup - // Test concurrent RecordFailure calls - for range iterations { + for range thresholdConcurrency { wg.Go(func() { cb.RecordFailure() }) } - // Test concurrent CanExecute calls - for range iterations { + for range thresholdConcurrency { wg.Go(func() { cb.CanExecute() }) @@ -211,13 +213,14 @@ func TestCircuitBreakerConcurrency(t *testing.T) { wg.Wait() - // Verify circuit is in expected state if cb.state != CircuitOpen { t.Errorf("Expected circuit to be open after many failures, got %v", cb.state) } } func TestCircuitStates(t *testing.T) { + t.Parallel() + tests := []struct { state CircuitState expected CircuitState diff --git a/types.go b/types.go index ce34a0c..decbbd6 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,25 @@ package ewrap +// Canonical string forms for ErrorType and Severity. These are the values +// returned by String() and used in serialized payloads, so they're worth +// pinning as named constants rather than free-floating literals. +const ( + typeUnknownStr = "unknown" + typeValidationStr = "validation" + typeNotFoundStr = "not_found" + typePermissionStr = "permission" + typeDatabaseStr = "database" + typeNetworkStr = "network" + typeConfigurationStr = "configuration" + typeInternalStr = "internal" + typeExternalStr = "external" + + severityInfoStr = "info" + severityWarningStr = "warning" + severityErrorStr = "error" + severityCriticalStr = "critical" +) + // ErrorType represents the type of error that occurred. type ErrorType int @@ -24,30 +44,30 @@ const ( ErrorTypeExternal ) -// String returns the string representation of the error type, -// useful for logging and error reporting. +// String returns the string representation of the error type, useful for +// logging and error reporting. func (et ErrorType) String() string { switch et { case ErrorTypeValidation: - return "validation" + return typeValidationStr case ErrorTypeNotFound: - return "not_found" + return typeNotFoundStr case ErrorTypePermission: - return "permission" + return typePermissionStr case ErrorTypeDatabase: - return "database" + return typeDatabaseStr case ErrorTypeNetwork: - return "network" + return typeNetworkStr case ErrorTypeConfiguration: - return "configuration" + return typeConfigurationStr case ErrorTypeInternal: - return "internal" + return typeInternalStr case ErrorTypeExternal: - return "external" + return typeExternalStr case ErrorTypeUnknown: fallthrough default: - return "unknown" + return typeUnknownStr } } @@ -69,15 +89,15 @@ const ( func (s Severity) String() string { switch s { case SeverityInfo: - return "info" + return severityInfoStr case SeverityWarning: - return "warning" + return severityWarningStr case SeverityError: - return "error" + return severityErrorStr case SeverityCritical: - return "critical" + return severityCriticalStr default: - return "unknown" + return typeUnknownStr } } diff --git a/types_test.go b/types_test.go index bd9ccec..4cae5cb 100644 --- a/types_test.go +++ b/types_test.go @@ -2,26 +2,36 @@ package ewrap import "testing" +const ( + invalidEnumValue = 999 + errorTypeExternalValue = 8 + severityCriticalValue = 3 +) + func TestErrorType_String(t *testing.T) { + t.Parallel() + tests := []struct { name string et ErrorType expected string }{ - {"Unknown", ErrorTypeUnknown, "unknown"}, - {"Validation", ErrorTypeValidation, "validation"}, - {"NotFound", ErrorTypeNotFound, "not_found"}, - {"Permission", ErrorTypePermission, "permission"}, - {"Database", ErrorTypeDatabase, "database"}, - {"Network", ErrorTypeNetwork, "network"}, - {"Configuration", ErrorTypeConfiguration, "configuration"}, - {"Internal", ErrorTypeInternal, "internal"}, - {"External", ErrorTypeExternal, "external"}, - {"Invalid", ErrorType(999), "unknown"}, + {"Unknown", ErrorTypeUnknown, typeUnknownStr}, + {"Validation", ErrorTypeValidation, typeValidationStr}, + {"NotFound", ErrorTypeNotFound, typeNotFoundStr}, + {"Permission", ErrorTypePermission, typePermissionStr}, + {"Database", ErrorTypeDatabase, typeDatabaseStr}, + {"Network", ErrorTypeNetwork, typeNetworkStr}, + {"Configuration", ErrorTypeConfiguration, typeConfigurationStr}, + {"Internal", ErrorTypeInternal, typeInternalStr}, + {"External", ErrorTypeExternal, typeExternalStr}, + {"Invalid", ErrorType(invalidEnumValue), typeUnknownStr}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.et.String(); got != tt.expected { t.Errorf("ErrorType.String() = %v, want %v", got, tt.expected) } @@ -30,20 +40,24 @@ func TestErrorType_String(t *testing.T) { } func TestSeverity_String(t *testing.T) { + t.Parallel() + tests := []struct { name string s Severity expected string }{ - {"Info", SeverityInfo, "info"}, - {"Warning", SeverityWarning, "warning"}, - {"Error", SeverityError, "error"}, - {"Critical", SeverityCritical, "critical"}, - {"Invalid", Severity(999), "unknown"}, + {"Info", SeverityInfo, severityInfoStr}, + {"Warning", SeverityWarning, severityWarningStr}, + {"Error", SeverityError, severityErrorStr}, + {"Critical", SeverityCritical, severityCriticalStr}, + {"Invalid", Severity(invalidEnumValue), typeUnknownStr}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.s.String(); got != tt.expected { t.Errorf("Severity.String() = %v, want %v", got, tt.expected) } @@ -52,7 +66,8 @@ func TestSeverity_String(t *testing.T) { } func TestErrorTypeConstants(t *testing.T) { - // Test that constants have expected values + t.Parallel() + if ErrorTypeUnknown != 0 { t.Errorf("ErrorTypeUnknown = %d, want 0", ErrorTypeUnknown) } @@ -61,13 +76,14 @@ func TestErrorTypeConstants(t *testing.T) { t.Errorf("ErrorTypeValidation = %d, want 1", ErrorTypeValidation) } - if ErrorTypeExternal != 8 { - t.Errorf("ErrorTypeExternal = %d, want 8", ErrorTypeExternal) + if ErrorTypeExternal != errorTypeExternalValue { + t.Errorf("ErrorTypeExternal = %d, want %d", ErrorTypeExternal, errorTypeExternalValue) } } func TestSeverityConstants(t *testing.T) { - // Test that constants have expected values + t.Parallel() + if SeverityInfo != 0 { t.Errorf("SeverityInfo = %d, want 0", SeverityInfo) } @@ -76,24 +92,30 @@ func TestSeverityConstants(t *testing.T) { t.Errorf("SeverityWarning = %d, want 1", SeverityWarning) } - if SeverityCritical != 3 { - t.Errorf("SeverityCritical = %d, want 3", SeverityCritical) + if SeverityCritical != severityCriticalValue { + t.Errorf("SeverityCritical = %d, want %d", SeverityCritical, severityCriticalValue) } } func TestRecoverySuggestion(t *testing.T) { + t.Parallel() + + const wantMessage = "Test message" + rs := RecoverySuggestion{ - Message: "Test message", + Message: wantMessage, Actions: []string{"action1", "action2"}, Documentation: "https://example.com/docs", } - if rs.Message != "Test message" { - t.Errorf("RecoverySuggestion.Message = %v, want %v", rs.Message, "Test message") + if rs.Message != wantMessage { + t.Errorf("RecoverySuggestion.Message = %v, want %v", rs.Message, wantMessage) } - if len(rs.Actions) != 2 { - t.Errorf("len(RecoverySuggestion.Actions) = %v, want %v", len(rs.Actions), 2) + const wantActionCount = 2 + + if len(rs.Actions) != wantActionCount { + t.Errorf("len(RecoverySuggestion.Actions) = %v, want %v", len(rs.Actions), wantActionCount) } if rs.Documentation != "https://example.com/docs" { From 1bde34f56bb51136eb3923ccc46af5a801608e8e Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 10:01:55 +0200 Subject: [PATCH 04/17] refactor(breaker): extract circuit-breaker into a standalone subpackage Move the CircuitBreaker implementation out of the root ewrap package and into a new breaker/ subpackage, so consumers who only need error wrapping do not pull in the circuit-breaker dependency. - Add breaker/breaker.go with Breaker, State, and Observer types; the subpackage defines its own no-op observer and transitionEvent helpers independent of the parent module. - Add breaker/breaker_test.go with a full test suite covering state transitions, concurrency safety, callbacks, and observer hooks. - Simplify ewrap.Observer to RecordError only; remove RecordCircuitStateTransition and the root-level noopObserver. - Delete threshold.go and threshold_test.go, now superseded by the new subpackage. - Update test/benchmark_test.go and test/profile_test.go to import and use breaker.New / breaker.Breaker instead of the removed ewrap constructors. - Replace the manual curl-based golangci-lint install in the lint workflow with the official golangci/golangci-lint-action@v9 action. --- .github/workflows/lint.yml | 6 +- breaker/breaker.go | 229 +++++++++++++++++++++++++ breaker/breaker_test.go | 332 +++++++++++++++++++++++++++++++++++++ observability.go | 19 +-- observability_test.go | 94 +---------- test/benchmark_test.go | 5 +- test/profile_test.go | 5 +- threshold.go | 178 -------------------- threshold_test.go | 238 -------------------------- 9 files changed, 586 insertions(+), 520 deletions(-) create mode 100644 breaker/breaker.go create mode 100644 breaker/breaker_test.go delete mode 100644 threshold.go delete mode 100644 threshold_test.go diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bb521c4..68ec746 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -41,7 +41,7 @@ jobs: go install github.com/daixiang0/gci@latest go install mvdan.cc/gofumpt@latest go install honnef.co/go/tools/cmd/staticcheck@latest - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" "${{ steps.settings.outputs.golangci_lint_version }}" + # curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" "${{ steps.settings.outputs.golangci_lint_version }}" - name: Modules run: go mod download - name: Tidy check @@ -60,4 +60,6 @@ jobs: - name: staticcheck run: staticcheck ./... - name: golangci-lint - run: golangci-lint run -v ./... + uses: golangci/golangci-lint-action@v9 + with: + version: "${{ steps.settings.outputs.golangci_lint_version }}" diff --git a/breaker/breaker.go b/breaker/breaker.go new file mode 100644 index 0000000..fc76ed1 --- /dev/null +++ b/breaker/breaker.go @@ -0,0 +1,229 @@ +// Package breaker implements the classic circuit-breaker pattern. It is +// independent of the parent ewrap module — consumers who only need error +// wrapping do not pay for it. +// +// The breaker is goroutine-safe. All state transitions happen under a single +// lock; observer and OnStateChange callbacks fire synchronously after the +// lock is released, so callbacks must not invoke the breaker recursively. +package breaker + +import ( + "sync" + "time" +) + +// Breaker implements the circuit-breaker pattern. +type Breaker struct { + name string + maxFailures int + timeout time.Duration + failureCount int + lastFailure time.Time + state State + observer Observer + mu sync.Mutex + onStateChange func(name string, from, to State) +} + +// State represents the breaker's operational state. +type State int + +const ( + // Closed indicates normal operation: requests pass through. + Closed State = iota + // Open indicates the breaker has tripped: requests are rejected fast. + Open + // HalfOpen indicates the breaker is probing recovery: a single request + // is allowed; success closes the breaker, failure re-opens it. + HalfOpen +) + +// String returns the canonical name of the state. +func (s State) String() string { + switch s { + case Closed: + return "closed" + case Open: + return "open" + case HalfOpen: + return "half-open" + default: + return "unknown" + } +} + +// Observer receives notifications when the breaker changes state. +type Observer interface { + // RecordTransition is called once per state change. Implementations + // must be goroutine-safe and must not invoke the breaker recursively. + RecordTransition(name string, from, to State) +} + +type noopObserver struct{} + +func (noopObserver) RecordTransition(string, State, State) {} + +// transitionEvent captures a state change so observer/callback dispatch can +// happen outside the breaker lock. +type transitionEvent struct { + name string + from, to State + observer Observer + callback func(string, State, State) +} + +// New creates a Breaker named name that opens after maxFailures consecutive +// failures and probes recovery after timeout has elapsed in the open state. +func New(name string, maxFailures int, timeout time.Duration) *Breaker { + return NewWithObserver(name, maxFailures, timeout, nil) +} + +// NewWithObserver creates a Breaker that emits transition events to observer. +// A nil observer is replaced with a no-op implementation. +func NewWithObserver(name string, maxFailures int, timeout time.Duration, observer Observer) *Breaker { + if observer == nil { + observer = noopObserver{} + } + + return &Breaker{ + name: name, + maxFailures: maxFailures, + timeout: timeout, + state: Closed, + observer: observer, + } +} + +// Name returns the breaker's identifier as supplied at construction. +func (cb *Breaker) Name() string { + return cb.name +} + +// State returns the current state. The result is a snapshot and may be stale +// by the time the caller acts on it. +func (cb *Breaker) State() State { + cb.mu.Lock() + defer cb.mu.Unlock() + + return cb.state +} + +// OnStateChange installs a callback fired after each state transition. The +// callback runs synchronously outside the breaker lock and must not invoke +// the breaker recursively. +func (cb *Breaker) OnStateChange(callback func(name string, from, to State)) { + cb.mu.Lock() + cb.onStateChange = callback + cb.mu.Unlock() +} + +// SetObserver replaces the observer. A nil value is replaced with a no-op +// implementation so callers never need to nil-check before recording. +func (cb *Breaker) SetObserver(observer Observer) { + if observer == nil { + observer = noopObserver{} + } + + cb.mu.Lock() + cb.observer = observer + cb.mu.Unlock() +} + +// RecordFailure records a failure and potentially opens the breaker. +func (cb *Breaker) RecordFailure() { + cb.mu.Lock() + cb.failureCount++ + cb.lastFailure = time.Now() + + var event *transitionEvent + if cb.state == Closed && cb.failureCount >= cb.maxFailures { + event = cb.setStateLocked(Open) + } + + cb.mu.Unlock() + + cb.fireTransition(event) +} + +// RecordSuccess records a success. In half-open state this closes the +// breaker; in any other state it is a no-op. +func (cb *Breaker) RecordSuccess() { + var event *transitionEvent + + cb.mu.Lock() + + if cb.state == HalfOpen { + cb.failureCount = 0 + event = cb.setStateLocked(Closed) + } + + cb.mu.Unlock() + + cb.fireTransition(event) +} + +// CanExecute reports whether the operation guarded by the breaker should be +// attempted. When the breaker is open and the timeout has elapsed it +// transitions to half-open atomically and returns true. +func (cb *Breaker) CanExecute() bool { + cb.mu.Lock() + + var ( + can bool + event *transitionEvent + ) + + switch cb.state { + case Closed, HalfOpen: + can = true + case Open: + if time.Since(cb.lastFailure) > cb.timeout { + event = cb.setStateLocked(HalfOpen) + can = true + } + default: + // Defensive only; State is a closed enum. + } + + cb.mu.Unlock() + + cb.fireTransition(event) + + return can +} + +// setStateLocked must be called with cb.mu held. It returns a transitionEvent +// when the state actually changes; nil otherwise. The caller is responsible +// for releasing the lock and calling fireTransition. +func (cb *Breaker) setStateLocked(newState State) *transitionEvent { + if cb.state == newState { + return nil + } + + oldState := cb.state + cb.state = newState + + return &transitionEvent{ + name: cb.name, + from: oldState, + to: newState, + observer: cb.observer, + callback: cb.onStateChange, + } +} + +// fireTransition dispatches observer and callback notifications for a +// completed transition. Must be called without the lock held. +func (*Breaker) fireTransition(event *transitionEvent) { + if event == nil { + return + } + + if event.observer != nil { + event.observer.RecordTransition(event.name, event.from, event.to) + } + + if event.callback != nil { + event.callback(event.name, event.from, event.to) + } +} diff --git a/breaker/breaker_test.go b/breaker/breaker_test.go new file mode 100644 index 0000000..267e1a7 --- /dev/null +++ b/breaker/breaker_test.go @@ -0,0 +1,332 @@ +package breaker + +import ( + "sync" + "testing" + "time" +) + +const ( + testName = "test" + testMaxFailures = 3 + testTimeoutSeconds = 5 + testConcurrencyLimit = 100 +) + +type recordedTransition struct { + name string + from State + to State +} + +// recordingObserver implements Observer for tests. +type recordingObserver struct { + mu sync.Mutex + transitions []recordedTransition +} + +func (r *recordingObserver) RecordTransition(name string, from, to State) { + r.mu.Lock() + defer r.mu.Unlock() + + r.transitions = append(r.transitions, recordedTransition{name, from, to}) +} + +func (r *recordingObserver) snapshot() []recordedTransition { + r.mu.Lock() + defer r.mu.Unlock() + + out := make([]recordedTransition, len(r.transitions)) + copy(out, r.transitions) + + return out +} + +func TestNew(t *testing.T) { + t.Parallel() + + const name = "test-circuit" + + timeout := testTimeoutSeconds * time.Second + cb := New(name, testMaxFailures, timeout) + + if cb.Name() != name { + t.Errorf("Name: got %s, want %s", cb.Name(), name) + } + + if cb.maxFailures != testMaxFailures { + t.Errorf("maxFailures: got %d, want %d", cb.maxFailures, testMaxFailures) + } + + if cb.timeout != timeout { + t.Errorf("timeout: got %v, want %v", cb.timeout, timeout) + } + + if cb.State() != Closed { + t.Errorf("State: got %v, want %v", cb.State(), Closed) + } + + if cb.failureCount != 0 { + t.Errorf("failureCount: got %d, want 0", cb.failureCount) + } +} + +func TestRecordFailure(t *testing.T) { + t.Parallel() + + cb := New(testName, 2, testTimeoutSeconds*time.Second) + + cb.RecordFailure() + + if cb.State() != Closed { + t.Errorf("State after first failure: got %v, want %v", cb.State(), Closed) + } + + if cb.failureCount != 1 { + t.Errorf("failureCount: got %d, want 1", cb.failureCount) + } + + cb.RecordFailure() + + if cb.State() != Open { + t.Errorf("State after max failures: got %v, want %v", cb.State(), Open) + } + + if cb.failureCount != 2 { + t.Errorf("failureCount: got %d, want 2", cb.failureCount) + } +} + +func TestRecordSuccess(t *testing.T) { + t.Parallel() + + cb := New(testName, 1, testTimeoutSeconds*time.Second) + + cb.RecordFailure() + + if cb.State() != Open { + t.Error("expected Breaker to be Open after first failure") + } + + cb.mu.Lock() + cb.state = HalfOpen + cb.mu.Unlock() + + cb.RecordSuccess() + + if cb.State() != Closed { + t.Errorf("State after success in half-open: got %v, want %v", cb.State(), Closed) + } + + if cb.failureCount != 0 { + t.Errorf("failureCount reset: got %d, want 0", cb.failureCount) + } +} + +func TestCanExecute(t *testing.T) { + t.Parallel() + + timeout := testConcurrencyLimit * time.Millisecond + cb := New(testName, 1, timeout) + + if !cb.CanExecute() { + t.Error("expected CanExecute true for closed breaker") + } + + cb.RecordFailure() + + if cb.CanExecute() { + t.Error("expected CanExecute false for open breaker") + } + + time.Sleep(timeout + 10*time.Millisecond) + + if !cb.CanExecute() { + t.Error("expected CanExecute true after timeout (half-open)") + } + + if got := cb.State(); got != HalfOpen { + t.Errorf("State after timeout: got %v, want %v", got, HalfOpen) + } +} + +func TestOnStateChange(t *testing.T) { + t.Parallel() + + cb := New(testName, 1, testTimeoutSeconds*time.Second) + + type recordedChange struct { + name string + from State + to State + } + + var ( + changes []recordedChange + mu sync.Mutex + ) + + cb.OnStateChange(func(name string, from, to State) { + mu.Lock() + + changes = append(changes, recordedChange{name, from, to}) + + mu.Unlock() + }) + + cb.RecordFailure() + + mu.Lock() + defer mu.Unlock() + + if len(changes) != 1 { + t.Errorf("expected 1 state change, got %d", len(changes)) + + return + } + + change := changes[0] + if change.name != testName { + t.Errorf("name: got %s, want %s", change.name, testName) + } + + if change.from != Closed { + t.Errorf("from: got %v, want %v", change.from, Closed) + } + + if change.to != Open { + t.Errorf("to: got %v, want %v", change.to, Open) + } +} + +func TestTransitionViaPublicAPI(t *testing.T) { + t.Parallel() + + cb := New(testName, 1, testTimeoutSeconds*time.Second) + + cb.RecordFailure() + + if cb.State() != Open { + t.Errorf("State: got %v, want %v", cb.State(), Open) + } + + cb.RecordFailure() + + if cb.State() != Open { + t.Error("expected State to remain Open on subsequent failure") + } +} + +func TestConcurrency(t *testing.T) { + t.Parallel() + + cb := New(testName, testTimeoutSeconds, testConcurrencyLimit*time.Millisecond) + + var wg sync.WaitGroup + + for range testConcurrencyLimit { + wg.Go(func() { + cb.RecordFailure() + }) + } + + for range testConcurrencyLimit { + wg.Go(func() { + cb.CanExecute() + }) + } + + wg.Wait() + + if cb.State() != Open { + t.Errorf("expected breaker to be Open after many failures, got %v", cb.State()) + } +} + +func TestStates(t *testing.T) { + t.Parallel() + + tests := []struct { + state State + expected State + name string + }{ + {Closed, Closed, "closed"}, + {Open, Open, "open"}, + {HalfOpen, HalfOpen, "half-open"}, + } + + for _, tt := range tests { + if tt.state != tt.expected { + t.Errorf("state mismatch: got %v, expected %v", tt.state, tt.expected) + } + + if tt.state.String() != tt.name { + t.Errorf("State.String(): got %q, want %q", tt.state.String(), tt.name) + } + } +} + +func TestObserverViaConstructor(t *testing.T) { + t.Parallel() + + obs := &recordingObserver{} + + timeout := 10 * time.Millisecond + cb := NewWithObserver(testName, 1, timeout, obs) + + cb.RecordFailure() + time.Sleep(timeout + time.Millisecond) + + if !cb.CanExecute() { + t.Fatal("expected breaker to allow execution after timeout") + } + + cb.RecordSuccess() + + expected := []recordedTransition{ + {name: testName, from: Closed, to: Open}, + {name: testName, from: Open, to: HalfOpen}, + {name: testName, from: HalfOpen, to: Closed}, + } + + got := obs.snapshot() + if len(got) != len(expected) { + t.Fatalf("expected %d transitions, got %d", len(expected), len(got)) + } + + for i, exp := range expected { + if got[i] != exp { + t.Errorf("transition %d: expected %+v, got %+v", i, exp, got[i]) + } + } +} + +func TestSetObserver(t *testing.T) { + t.Parallel() + + obs := &recordingObserver{} + + cb := New(testName, 1, 10*time.Millisecond) + + cb.SetObserver(obs) + + cb.RecordFailure() + + got := obs.snapshot() + if len(got) != 1 { + t.Fatalf("expected 1 transition, got %d", len(got)) + } + + want := recordedTransition{name: testName, from: Closed, to: Open} + if got[0] != want { + t.Errorf("transition: got %+v, want %+v", got[0], want) + } +} + +func TestNoObserverIsSafe(t *testing.T) { + t.Parallel() + + cb := New(testName, 1, 10*time.Millisecond) + cb.RecordFailure() // Must not panic with default no-op observer +} diff --git a/observability.go b/observability.go index d7fda8c..58659e8 100644 --- a/observability.go +++ b/observability.go @@ -1,19 +1,12 @@ package ewrap -// Observer defines hooks for observing errors and circuit breaker state transitions. +// Observer receives notifications about errors. Implementations must be +// goroutine-safe; calls happen synchronously from the goroutine that invoked +// (*Error).Log. +// +// Breaker-state observation lives in the ewrap/breaker subpackage so +// consumers who only need error wrapping do not depend on it. type Observer interface { // RecordError is called when an error is logged. RecordError(message string) - // RecordCircuitStateTransition is called when a circuit breaker changes state. - RecordCircuitStateTransition(name string, from, to CircuitState) } - -// noopObserver provides a no-op implementation of the Observer interface. -type noopObserver struct{} - -func newNoopObserver() Observer { - return noopObserver{} -} - -func (noopObserver) RecordError(string) {} -func (noopObserver) RecordCircuitStateTransition(string, CircuitState, CircuitState) {} diff --git a/observability_test.go b/observability_test.go index 00282cb..f2d5e19 100644 --- a/observability_test.go +++ b/observability_test.go @@ -1,33 +1,20 @@ package ewrap -import ( - "testing" - "time" -) +import "testing" -type testObserver struct { - errorCount int - transitions []stateChange +// recordingObserver implements Observer for tests. +type recordingObserver struct { + errorCount int } -type stateChange struct { - name string - from CircuitState - to CircuitState -} - -func (t *testObserver) RecordError(string) { - t.errorCount++ -} - -func (t *testObserver) RecordCircuitStateTransition(name string, from, to CircuitState) { - t.transitions = append(t.transitions, stateChange{name: name, from: from, to: to}) +func (r *recordingObserver) RecordError(string) { + r.errorCount++ } func TestErrorLogRecordsObserver(t *testing.T) { t.Parallel() - obs := &testObserver{} + obs := &recordingObserver{} err := New("boom", WithObserver(obs)) err.Log() @@ -37,48 +24,12 @@ func TestErrorLogRecordsObserver(t *testing.T) { } } -func TestCircuitBreakerObserver(t *testing.T) { - t.Parallel() - - obs := &testObserver{} - - timeout := 10 * time.Millisecond - cb := NewCircuitBreakerWithObserver(msgTest, 1, timeout, obs) - - cb.RecordFailure() - time.Sleep(timeout + time.Millisecond) - - if !cb.CanExecute() { - t.Fatal("expected circuit breaker to allow execution after timeout") - } - - cb.RecordSuccess() - - expected := []stateChange{ - {name: msgTest, from: CircuitClosed, to: CircuitOpen}, - {name: msgTest, from: CircuitOpen, to: CircuitHalfOpen}, - {name: msgTest, from: CircuitHalfOpen, to: CircuitClosed}, - } - - if len(obs.transitions) != len(expected) { - t.Fatalf("expected %d transitions, got %d", len(expected), len(obs.transitions)) - } - - for i, exp := range expected { - got := obs.transitions[i] - if got != exp { - t.Errorf("transition %d: expected %+v, got %+v", i, exp, got) - } - } -} - func TestObserverInheritanceInWrap(t *testing.T) { t.Parallel() - obs := &testObserver{} + obs := &recordingObserver{} original := New("original error", WithObserver(obs)) - wrapped := Wrap(original, "wrapped error") original.Log() @@ -89,36 +40,9 @@ func TestObserverInheritanceInWrap(t *testing.T) { } } -func TestCircuitBreakerSetObserver(t *testing.T) { - t.Parallel() - - obs := &testObserver{} - - cb := NewCircuitBreaker(msgTest, 1, 10*time.Millisecond) - - cb.SetObserver(obs) - - cb.RecordFailure() - - expected := []stateChange{ - {name: msgTest, from: CircuitClosed, to: CircuitOpen}, - } - - if len(obs.transitions) != len(expected) { - t.Fatalf("expected %d transitions, got %d", len(expected), len(obs.transitions)) - } - - if obs.transitions[0] != expected[0] { - t.Errorf("expected %+v, got %+v", expected[0], obs.transitions[0]) - } -} - func TestObserverIsOptional(t *testing.T) { t.Parallel() err := New(msgTestError) - err.Log() // Should not panic - - cb := NewCircuitBreaker(msgTest, 1, 10*time.Millisecond) - cb.RecordFailure() // Should not panic + err.Log() // Should not panic without an observer } diff --git a/test/benchmark_test.go b/test/benchmark_test.go index f3f48bf..31dac2e 100644 --- a/test/benchmark_test.go +++ b/test/benchmark_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/ewrap/breaker" ) const ( @@ -184,7 +185,7 @@ func BenchmarkCircuitBreaker(b *testing.B) { } func benchBreakerRecordFailure(b *testing.B) { - cb := ewrap.NewCircuitBreaker("test", benchBreakerFailLimit, time.Second) + cb := breaker.New("test", benchBreakerFailLimit, time.Second) b.ReportAllocs() @@ -198,7 +199,7 @@ func benchBreakerRecordFailure(b *testing.B) { } func benchBreakerConcurrent(b *testing.B) { - cb := ewrap.NewCircuitBreaker("test", benchBreakerLargeMax, time.Second) + cb := breaker.New("test", benchBreakerLargeMax, time.Second) b.RunParallel(func(pb *testing.PB) { for pb.Next() { diff --git a/test/profile_test.go b/test/profile_test.go index 6af6c76..2c44961 100644 --- a/test/profile_test.go +++ b/test/profile_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/ewrap/breaker" ) const ( @@ -197,7 +198,7 @@ func profileMemory() { func profileGoroutinesFn() { done := make(chan bool) - cb := ewrap.NewCircuitBreaker("test", profileBreakerMax, time.Second) + cb := breaker.New("test", profileBreakerMax, time.Second) for i := range profileGoroutines { go runProfileGoroutine(cb, i, done) @@ -208,7 +209,7 @@ func profileGoroutinesFn() { } } -func runProfileGoroutine(cb *ewrap.CircuitBreaker, id int, done chan<- bool) { +func runProfileGoroutine(cb *breaker.Breaker, id int, done chan<- bool) { for j := range profileGoroutineWork { if cb.CanExecute() { err := ewrap.New(fmt.Sprintf("error %d-%d", id, j)) diff --git a/threshold.go b/threshold.go deleted file mode 100644 index b0ace53..0000000 --- a/threshold.go +++ /dev/null @@ -1,178 +0,0 @@ -package ewrap - -import ( - "sync" - "time" -) - -// CircuitBreaker implements the circuit breaker pattern for error handling. -// -// Notifications (observer and OnStateChange callback) are fired synchronously -// after the lock has been released, so callers must not invoke the breaker -// recursively from a callback. -type CircuitBreaker struct { - name string - maxFailures int - timeout time.Duration - failureCount int - lastFailure time.Time - state CircuitState - observer Observer - mu sync.Mutex - onStateChange func(name string, from, to CircuitState) -} - -// CircuitState represents the state of a circuit breaker. -type CircuitState int - -const ( - // CircuitClosed indicates normal operation. - CircuitClosed CircuitState = iota - // CircuitOpen indicates the circuit is broken. - CircuitOpen - // CircuitHalfOpen indicates the circuit is testing recovery. - CircuitHalfOpen -) - -// transitionEvent captures a state change so observer/callback dispatch can -// happen outside the breaker lock. -type transitionEvent struct { - name string - from, to CircuitState - observer Observer - callback func(string, CircuitState, CircuitState) -} - -// NewCircuitBreaker creates a new circuit breaker. -func NewCircuitBreaker(name string, maxFailures int, timeout time.Duration) *CircuitBreaker { - return NewCircuitBreakerWithObserver(name, maxFailures, timeout, nil) -} - -// NewCircuitBreakerWithObserver creates a new circuit breaker with an observer. -func NewCircuitBreakerWithObserver(name string, maxFailures int, timeout time.Duration, observer Observer) *CircuitBreaker { - if observer == nil { - observer = newNoopObserver() - } - - return &CircuitBreaker{ - name: name, - maxFailures: maxFailures, - timeout: timeout, - state: CircuitClosed, - observer: observer, - } -} - -// OnStateChange sets a callback for state changes. -func (cb *CircuitBreaker) OnStateChange(callback func(name string, from, to CircuitState)) { - cb.mu.Lock() - cb.onStateChange = callback - cb.mu.Unlock() -} - -// SetObserver sets an observer for the circuit breaker. -func (cb *CircuitBreaker) SetObserver(observer Observer) { - if observer == nil { - observer = newNoopObserver() - } - - cb.mu.Lock() - cb.observer = observer - cb.mu.Unlock() -} - -// RecordFailure records a failure and potentially opens the circuit. -func (cb *CircuitBreaker) RecordFailure() { - cb.mu.Lock() - cb.failureCount++ - cb.lastFailure = time.Now() - - var event *transitionEvent - if cb.state == CircuitClosed && cb.failureCount >= cb.maxFailures { - event = cb.setStateLocked(CircuitOpen) - } - cb.mu.Unlock() - - cb.fireTransition(event) -} - -// RecordSuccess records a success and potentially closes the circuit. -func (cb *CircuitBreaker) RecordSuccess() { - cb.mu.Lock() - - var event *transitionEvent - - if cb.state == CircuitHalfOpen { - cb.failureCount = 0 - event = cb.setStateLocked(CircuitClosed) - } - cb.mu.Unlock() - - cb.fireTransition(event) -} - -// CanExecute checks if the operation can be executed. When the breaker is -// open and the timeout has elapsed it transitions to half-open atomically. -func (cb *CircuitBreaker) CanExecute() bool { - cb.mu.Lock() - - var ( - can bool - event *transitionEvent - ) - - switch cb.state { - case CircuitClosed, CircuitHalfOpen: - can = true - case CircuitOpen: - if time.Since(cb.lastFailure) > cb.timeout { - event = cb.setStateLocked(CircuitHalfOpen) - can = true - } - default: - // Unknown state — refuse to execute and record nothing. Defensive - // only; CircuitState is a closed enum. - } - - cb.mu.Unlock() - - cb.fireTransition(event) - - return can -} - -// setStateLocked must be called with cb.mu held. Returns a transitionEvent -// when the state actually changes; nil otherwise. The caller is responsible -// for releasing the lock and calling fireTransition. -func (cb *CircuitBreaker) setStateLocked(newState CircuitState) *transitionEvent { - if cb.state == newState { - return nil - } - - oldState := cb.state - cb.state = newState - - return &transitionEvent{ - name: cb.name, - from: oldState, - to: newState, - observer: cb.observer, - callback: cb.onStateChange, - } -} - -// fireTransition dispatches observer and callback notifications for a -// completed transition. Must be called without the lock held. -func (*CircuitBreaker) fireTransition(event *transitionEvent) { - if event == nil { - return - } - - if event.observer != nil { - event.observer.RecordCircuitStateTransition(event.name, event.from, event.to) - } - - if event.callback != nil { - event.callback(event.name, event.from, event.to) - } -} diff --git a/threshold_test.go b/threshold_test.go deleted file mode 100644 index 281a10e..0000000 --- a/threshold_test.go +++ /dev/null @@ -1,238 +0,0 @@ -package ewrap - -import ( - "sync" - "testing" - "time" -) - -const ( - thresholdMaxFailures = 3 - thresholdConcurrency = 100 - thresholdTimeoutSeconds = 5 - thresholdConcurrencyLimit = 100 -) - -func TestNewCircuitBreaker(t *testing.T) { - t.Parallel() - - const name = "test-circuit" - - timeout := thresholdTimeoutSeconds * time.Second - cb := NewCircuitBreaker(name, thresholdMaxFailures, timeout) - - if cb.name != name { - t.Errorf("Expected name %s, got %s", name, cb.name) - } - - if cb.maxFailures != thresholdMaxFailures { - t.Errorf("Expected maxFailures %d, got %d", thresholdMaxFailures, cb.maxFailures) - } - - if cb.timeout != timeout { - t.Errorf("Expected timeout %v, got %v", timeout, cb.timeout) - } - - if cb.state != CircuitClosed { - t.Errorf("Expected initial state %v, got %v", CircuitClosed, cb.state) - } - - if cb.failureCount != 0 { - t.Errorf("Expected initial failure count 0, got %d", cb.failureCount) - } -} - -func TestCircuitBreakerRecordFailure(t *testing.T) { - t.Parallel() - - cb := NewCircuitBreaker(msgTest, 2, thresholdTimeoutSeconds*time.Second) - - cb.RecordFailure() - - if cb.state != CircuitClosed { - t.Errorf("Expected state %v after first failure, got %v", CircuitClosed, cb.state) - } - - if cb.failureCount != 1 { - t.Errorf("Expected failure count 1, got %d", cb.failureCount) - } - - cb.RecordFailure() - - if cb.state != CircuitOpen { - t.Errorf("Expected state %v after max failures, got %v", CircuitOpen, cb.state) - } - - if cb.failureCount != 2 { - t.Errorf("Expected failure count 2, got %d", cb.failureCount) - } -} - -func TestCircuitBreakerRecordSuccess(t *testing.T) { - t.Parallel() - - cb := NewCircuitBreaker(msgTest, 1, thresholdTimeoutSeconds*time.Second) - - cb.RecordFailure() - - if cb.state != CircuitOpen { - t.Error("Expected circuit to be open") - } - - cb.mu.Lock() - cb.state = CircuitHalfOpen - cb.mu.Unlock() - - cb.RecordSuccess() - - if cb.state != CircuitClosed { - t.Errorf("Expected state %v after success in half-open, got %v", CircuitClosed, cb.state) - } - - if cb.failureCount != 0 { - t.Errorf("Expected failure count reset to 0, got %d", cb.failureCount) - } -} - -func TestCircuitBreakerCanExecute(t *testing.T) { - t.Parallel() - - timeout := thresholdConcurrencyLimit * time.Millisecond - cb := NewCircuitBreaker(msgTest, 1, timeout) - - if !cb.CanExecute() { - t.Error("Expected CanExecute to return true for closed circuit") - } - - cb.RecordFailure() - - if cb.CanExecute() { - t.Error("Expected CanExecute to return false for open circuit") - } - - time.Sleep(timeout + 10*time.Millisecond) - - if !cb.CanExecute() { - t.Error("Expected CanExecute to return true after timeout (half-open)") - } - - cb.mu.Lock() - state := cb.state - cb.mu.Unlock() - - if state != CircuitHalfOpen { - t.Errorf("Expected state %v after timeout, got %v", CircuitHalfOpen, state) - } -} - -func TestCircuitBreakerOnStateChange(t *testing.T) { - t.Parallel() - - cb := NewCircuitBreaker(msgTest, 1, thresholdTimeoutSeconds*time.Second) - - type recordedChange struct { - name string - from CircuitState - to CircuitState - } - - var ( - stateChanges []recordedChange - mu sync.Mutex - ) - - cb.OnStateChange(func(name string, from, to CircuitState) { - mu.Lock() - - stateChanges = append(stateChanges, recordedChange{name, from, to}) - - mu.Unlock() - }) - - cb.RecordFailure() - - mu.Lock() - defer mu.Unlock() - - if len(stateChanges) != 1 { - t.Errorf("Expected 1 state change, got %d", len(stateChanges)) - - return - } - - change := stateChanges[0] - if change.name != msgTest { - t.Errorf("Expected name %q, got %s", msgTest, change.name) - } - - if change.from != CircuitClosed { - t.Errorf("Expected from state %v, got %v", CircuitClosed, change.from) - } - - if change.to != CircuitOpen { - t.Errorf("Expected to state %v, got %v", CircuitOpen, change.to) - } -} - -func TestCircuitBreakerTransitionViaPublicAPI(t *testing.T) { - t.Parallel() - - cb := NewCircuitBreaker(msgTest, 1, thresholdTimeoutSeconds*time.Second) - - cb.RecordFailure() - - if cb.state != CircuitOpen { - t.Errorf("Expected state %v, got %v", CircuitOpen, cb.state) - } - - cb.RecordFailure() - - if cb.state != CircuitOpen { - t.Error("Expected state to remain Open on subsequent failure") - } -} - -func TestCircuitBreakerConcurrency(t *testing.T) { - t.Parallel() - - cb := NewCircuitBreaker(msgTest, thresholdTimeoutSeconds, thresholdConcurrencyLimit*time.Millisecond) - - var wg sync.WaitGroup - - for range thresholdConcurrency { - wg.Go(func() { - cb.RecordFailure() - }) - } - - for range thresholdConcurrency { - wg.Go(func() { - cb.CanExecute() - }) - } - - wg.Wait() - - if cb.state != CircuitOpen { - t.Errorf("Expected circuit to be open after many failures, got %v", cb.state) - } -} - -func TestCircuitStates(t *testing.T) { - t.Parallel() - - tests := []struct { - state CircuitState - expected CircuitState - }{ - {CircuitClosed, CircuitClosed}, - {CircuitOpen, CircuitOpen}, - {CircuitHalfOpen, CircuitHalfOpen}, - } - - for _, test := range tests { - if test.state != test.expected { - t.Errorf("Circuit state mismatch: got %v, expected %v", test.state, test.expected) - } - } -} From 0635f0bd60790cfe0037a022ca6b7482da9495e0 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 10:24:04 +0200 Subject: [PATCH 05/17] docs: overhaul README and advanced docs; harden lint workflow Rewrite the README from ~474 to ~283 lines: replace the verbose feature list with a concise Highlights section, add a focused Quick Tour with real code snippets, include an accurate benchmark table, and add a project layout tree. Update the GitHub Sponsors badge URL. Rewrite all docs/docs/advanced/ pages (context, error-strategies, error-types, formatting, performance, testing) with the same principle: remove padding and boilerplate, add comparison tables for quick scanning, and tighten examples to show only what the reader needs. Harden the lint CI workflow: - Pin golangci/golangci-lint-action from floating @v9 to @v9.2.0 - Add top-level `permissions: contents: read` (least-privilege) - Remove the redundant commented-out manual golangci-lint install --- .github/workflows/lint.yml | 6 +- README.md | 596 ++++++++-------------- docs/docs/advanced/context.md | 347 ++++--------- docs/docs/advanced/error-strategies.md | 349 +++++++------ docs/docs/advanced/error-types.md | 294 +++-------- docs/docs/advanced/formatting.md | 381 ++++---------- docs/docs/advanced/performance.md | 404 +++++---------- docs/docs/advanced/testing.md | 439 +++++++--------- docs/docs/api/error-types.md | 316 ++++++------ docs/docs/api/interfaces.md | 282 +++------- docs/docs/api/options.md | 274 +++++----- docs/docs/api/overview.md | 367 +++++-------- docs/docs/examples/advanced.md | 475 ++++++++++------- docs/docs/examples/basic.md | 323 ++++++------ docs/docs/features/circuit-breaker.md | 361 +++++-------- docs/docs/features/error-creation.md | 226 ++++---- docs/docs/features/error-groups.md | 358 +++++-------- docs/docs/features/error-wrapping.md | 292 ++++------- docs/docs/features/format-and-slog.md | 109 ++++ docs/docs/features/logging.md | 365 ++++--------- docs/docs/features/metadata.md | 355 +++++-------- docs/docs/features/observability.md | 377 +++----------- docs/docs/features/operational.md | 166 ++++++ docs/docs/features/serialization.md | 461 +++++------------ docs/docs/features/slog-adapter.md | 75 +++ docs/docs/features/stack-traces.md | 365 ++++--------- docs/docs/getting-started/installation.md | 108 ++-- docs/docs/getting-started/quickstart.md | 198 ++++--- docs/docs/index.md | 172 +++---- docs/mkdocs.yml | 11 +- 30 files changed, 3402 insertions(+), 5450 deletions(-) create mode 100644 docs/docs/features/format-and-slog.md create mode 100644 docs/docs/features/operational.md create mode 100644 docs/docs/features/slog-adapter.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68ec746..016098e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,7 +5,8 @@ on: pull_request: push: branches: [ main ] - +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest @@ -41,7 +42,6 @@ jobs: go install github.com/daixiang0/gci@latest go install mvdan.cc/gofumpt@latest go install honnef.co/go/tools/cmd/staticcheck@latest - # curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" "${{ steps.settings.outputs.golangci_lint_version }}" - name: Modules run: go mod download - name: Tidy check @@ -60,6 +60,6 @@ jobs: - name: staticcheck run: staticcheck ./... - name: golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@v9.2.0 with: version: "${{ steps.settings.outputs.golangci_lint_version }}" diff --git a/README.md b/README.md index 62e80e3..5102082 100644 --- a/README.md +++ b/README.md @@ -1,474 +1,283 @@ # ewrap -[![Go](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml/badge.svg)](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml) [![Docs](https://img.shields.io/badge/docs-passing-brightgreen)](https://hyp3rd.github.io/ewrap/) [![Go Report Card](https://goreportcard.com/badge/github.com/hyp3rd/ewrap)](https://goreportcard.com/report/github.com/hyp3rd/ewrap) [![Go Reference](https://pkg.go.dev/badge/github.com/hyp3rd/ewrap.svg)](https://pkg.go.dev/github.com/hyp3rd/ewrap) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![GitHub Sponsors](https://img.shields.io/github/sponsors/hyp3rd) +[![Go](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml/badge.svg)](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml) [![Docs](https://img.shields.io/badge/docs-passing-brightgreen)](https://hyp3rd.github.io/ewrap/) [![Go Report Card](https://goreportcard.com/badge/github.com/hyp3rd/ewrap)](https://goreportcard.com/report/github.com/hyp3rd/ewrap) [![Go Reference](https://pkg.go.dev/badge/github.com/hyp3rd/ewrap.svg)](https://pkg.go.dev/github.com/hyp3rd/ewrap) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![GitHub Sponsors](https://img.shields.io/github/sponsors/hyp3rd/sponsors) -A sophisticated, modern error handling library for Go applications that provides comprehensive error management with advanced features, observability hooks, and seamless integration with Go 1.25+ features. +A lightweight, modern Go error library: rich context, stack traces, structured +serialization, `slog`/`fmt.Formatter` integration, HTTP/retry classification, +PII-safe logging, and an opt-in circuit breaker — all in a tight dependency +footprint (yaml + a fast JSON encoder, nothing else). -## Core Features +## Highlights -### Error Management & Context +- **Stdlib-first.** Two direct deps in the core module: [`gopkg.in/yaml.v3`][yaml] for YAML, + [`github.com/goccy/go-json`][goccy] for the serialization hot path (~2.5× faster than `encoding/json`). +- **Correct by default.** `errors.Is` / `errors.As` work via `Unwrap()`; `Newf` honors `%w`; + every wrap captures its own stack frames. +- **Lazy & cached hot paths.** Lazy metadata map; `Error()` and `Stack()` cached via `sync.Once`. + After the first call, `Stack()` is ~1.7 ns/op, zero allocations. +- **Modern Go integrations.** `(*Error).Format` for `%+v`; `(*Error).LogValue` for `slog`; + `errors.Join`-aware `ErrorGroup`. +- **Operational features.** HTTP status, retryable / `Temporary()` classification, safe + (PII-redacted) messages, recovery suggestions, structured `ErrorContext`. +- **Opt-in subpackages.** Circuit breaker lives in [`ewrap/breaker`](breaker); `slog` adapter + in [`ewrap/slog`](slog). Importing `ewrap` alone pulls in only the core. -- **Advanced Stack Traces**: Programmatic stack frame inspection with iterators and structured access -- **Smart Error Wrapping**: Maintains error chains with unified context handling and metadata preservation -- **Rich Metadata**: Type-safe metadata attachment with optional generics support -- **Context Integration**: Unified context handling preventing divergence between error context and metadata +[yaml]: https://pkg.go.dev/gopkg.in/yaml.v3 +[goccy]: https://pkg.go.dev/github.com/goccy/go-json -### Logging & Observability - -- **Modern Logging**: Support for slog (Go 1.21+), logrus, zap, zerolog with structured output -- **Observability Hooks**: Built-in metrics and tracing for error frequencies and circuit-breaker states -- **Recovery Guidance**: Integrated recovery suggestions in error output and logging - -### Performance & Efficiency - -- **Go 1.25+ Optimizations**: Uses `maps.Clone` and `slices.Clone` for efficient copying operations -- **Pool-based Error Groups**: Memory-efficient error aggregation with `errors.Join` compatibility -- **Thread-Safe Operations**: Zero-allocation hot paths with minimal contention -- **Structured Serialization**: JSON/YAML export with full error group serialization - -### Advanced Features - -- **Circuit Breaker Pattern**: Protect systems from cascading failures with state transition monitoring -- **Custom Retry Logic**: Configurable per-error retry strategies with `RetryInfo` extension -- **Error Categorization**: Built-in types, severity levels, and optional generic type constraints -- **Timestamp Formatting**: Proper timestamp formatting with customizable formats - -## Installation +## Install ```bash go get github.com/hyp3rd/ewrap ``` -## Documentation +Requires Go 1.25+ (uses `maps.Clone`, `slices.Clone`, range-over-int, `b.Loop`). -`ewrap` provides comprehensive documentation covering all features and advanced usage patterns. Visit the [complete documentation](https://hyp3rd.github.io/ewrap/) for detailed guides, examples, and API reference. - -## Usage Examples - -### Basic Error Handling - -Create and wrap errors with context: +## Quick tour ```go -// Create a new error +import "github.com/hyp3rd/ewrap" + +// Plain error with stack trace err := ewrap.New("database connection failed") -// Wrap an existing error with context -if err != nil { - return ewrap.Wrap(err, "failed to process request") -} +// %w-aware formatted constructor +err := ewrap.Newf("query %q failed: %w", q, ioErr) // errors.Is(err, ioErr) == true -err = ewrap.Newf("failed to process request id: %v", requestID) -``` +// Wrap preserves the inner cause AND captures the wrap site +err := ewrap.Wrap(ioErr, "syncing replicas") -### Advanced Error Context with Unified Handling +// Nil-safe +ewrap.Wrap(nil, "ignored") == nil +ewrap.Wrapf(nil, "ignored %d", 42) == nil +``` -Add rich context and metadata with the new unified context system: +### Rich context ```go -err := ewrap.New("operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithLogger(logger), - ewrap.WithRecoverySuggestion("Check database connection and retry")). - WithMetadata("query", "SELECT * FROM users"). - WithMetadata("retry_count", 3). - WithMetadata("connection_pool_size", 10) - -// Log the error with all context and recovery suggestions -err.Log() +err := ewrap.New("payment authorization rejected", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true), + ewrap.WithSafeMessage("payment authorization rejected"), // omits PII + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Inspect upstream provider's queue and retry after backoff.", + Documentation: "https://runbooks.example.com/payments/timeout", + }), +). + WithMetadata("provider", "stripe"). + WithMetadata("attempt", 2) + +err.Log() // emits structured fields via the configured Logger ``` -### Modern Error Groups with errors.Join Integration - -Use error groups efficiently with Go 1.25+ features: +### Stack traces ```go -// Create an error group pool with initial capacity -pool := ewrap.NewErrorGroupPool(4) - -// Get an error group from the pool -eg := pool.Get() -defer eg.Release() // Return to pool when done - -// Add errors as needed -eg.Add(err1) -eg.Add(err2) +fmt.Printf("%+v\n", err) // message + filtered stack via fmt.Formatter -// Use errors.Join compatibility for standard library integration -if err := eg.Join(); err != nil { - return err +// Or inspect frames programmatically +for it := err.GetStackIterator(); it.HasNext(); { + f := it.Next() + fmt.Printf("%s:%d %s\n", f.File, f.Line, f.Function) } - -// Or serialize the entire error group -jsonOutput, _ := eg.ToJSON(ewrap.WithTimestampFormat(time.RFC3339)) ``` -### Stack Frame Inspection and Iteration +`Stack()` is computed once and cached. `WithStackDepth(n)` tunes capture; pass +`0` to disable. `NewSkip` / `WrapSkip` add caller-skip when wrapping `New`/`Wrap` +in helpers. -Programmatically inspect stack traces: +### Standard library compatibility ```go -if wrappedErr, ok := err.(*ewrap.Error); ok { - // Get a stack iterator for programmatic access - iterator := wrappedErr.GetStackIterator() - - for iterator.HasNext() { - frame := iterator.Next() - fmt.Printf("Function: %s\n", frame.Function) - fmt.Printf("File: %s:%d\n", frame.File, frame.Line) - fmt.Printf("PC: %x\n", frame.PC) - } - - // Or get all frames at once - frames := wrappedErr.GetStackFrames() - for _, frame := range frames { - // Process each frame... - } -} +errors.Is(err, ioErr) // walks the cause chain via Unwrap() +errors.As(err, &netErr) +errors.Unwrap(err) +fmt.Errorf("layered: %w", err) // also walks correctly ``` -### Custom Retry Logic with Extended RetryInfo - -Configure per-error retry strategies: +### Operational classification ```go -// Define custom retry logic -shouldRetry := func(err error, attempt int) bool { - if attempt >= 5 { - return false - } - - // Custom logic based on error type - if wrappedErr, ok := err.(*ewrap.Error); ok { - return wrappedErr.ErrorType() == ewrap.ErrorTypeNetwork - } - return false -} - -// Create error with custom retry configuration -err := ewrap.New("network timeout", - ewrap.WithRetryInfo(3, time.Second*2, shouldRetry)) - -// Use the retry information -if retryInfo := err.GetRetryInfo(); retryInfo != nil { - if retryInfo.ShouldRetry(err, currentAttempt) { - // Perform retry logic - } -} +ewrap.HTTPStatus(err) // walks chain; 0 if unset +ewrap.IsRetryable(err) // checks ewrap classification, then stdlib Temporary() +err.SafeError() // redacted variant for external sinks +err.Recovery() // typed accessor for the recovery suggestion +err.Retry() // typed accessor for retry metadata +err.GetErrorContext() // typed ErrorContext or nil ``` -### Observability Hooks and Monitoring - -Monitor error patterns and circuit breaker states: +### `slog` integration -```go -// Set up observability hooks -observer := &MyObserver{ - metricsClient: metricsClient, - tracer: tracer, -} - -// Create circuit breaker with observability -cb := ewrap.NewCircuitBreaker("payment-service", 5, time.Minute*2, - ewrap.WithObserver(observer)) - -// The observer will receive notifications for: -// - Error frequency changes -// - Circuit breaker state transitions -// - Recovery suggestions triggered -``` +`*Error` implements `slog.LogValuer`, so `slog.Error("boom", "err", err)` +emits the message, type, severity, component, request_id, metadata and +cause as structured fields. -### Circuit Breaker Pattern - -Protect your system from cascading failures: +For drivers that want an `ewrap.Logger`, the `slog` subpackage provides a +3-line adapter: ```go -// Create a circuit breaker for database operations -cb := ewrap.NewCircuitBreaker("database", 3, time.Minute) - -if cb.CanExecute() { - if err := performDatabaseOperation(); err != nil { - cb.RecordFailure() - return ewrap.Wrap(err, "database operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) - } - cb.RecordSuccess() -} -``` - -### Complete Example - -Here's a comprehensive example combining multiple features: +import ( + stdslog "log/slog" + ewrapslog "github.com/hyp3rd/ewrap/slog" +) -```go -func processOrder(ctx context.Context, orderID string) error { - // Get an error group from the pool - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Create a circuit breaker for database operations - cb := ewrap.NewCircuitBreaker("database", 3, time.Minute) - - // Validate order - if err := validateOrderID(orderID); err != nil { - eg.Add(ewrap.Wrap(err, "invalid order ID", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError))) - } - - if !eg.HasErrors() && cb.CanExecute() { - if err := saveToDatabase(orderID); err != nil { - cb.RecordFailure() - return ewrap.Wrap(err, "database operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) - } - cb.RecordSuccess() - } - - return eg.Error() -} +logger := ewrapslog.New(stdslog.New(stdslog.NewJSONHandler(os.Stdout, nil))) +err := ewrap.New("boom", ewrap.WithLogger(logger)) ``` -## Error Types and Severity +For zap, zerolog, logrus, glog, etc. — write a 5-line adapter against the +`ewrap.Logger` interface (3 methods: `Error`, `Debug`, `Info`). -The package provides pre-defined error types and severity levels: +### Error groups ```go -// Error Types -ErrorTypeValidation // Input validation failures -ErrorTypeNotFound // Resource not found -ErrorTypePermission // Authorization/authentication failures -ErrorTypeDatabase // Database operation failures -ErrorTypeNetwork // Network-related failures -ErrorTypeConfiguration // Configuration issues -ErrorTypeInternal // Internal system errors -ErrorTypeExternal // External service errors - -// Severity Levels -SeverityInfo // Informational messages -SeverityWarning // Warning conditions -SeverityError // Error conditions -SeverityCritical // Critical failures -``` - -## Logging Integration +pool := ewrap.NewErrorGroupPool(4) +eg := pool.Get() +defer eg.Release() -Implement the Logger interface to integrate with your logging system: +eg.Add(validate(req)) +eg.Add(persist(req)) -```go -type Logger interface { - Error(msg string, keysAndValues ...any) - Debug(msg string, keysAndValues ...any) - Info(msg string, keysAndValues ...any) +if err := eg.Join(); err != nil { // errors.Join semantics + return err } ``` -Built-in adapters are provided for popular logging frameworks including modern slog support: +`(*ErrorGroup).ToJSON()` / `ToYAML()` recursively serialize the whole group, +walking both `*Error` and standard wrapped chains so transport consumers +keep full context. -```go -// Slog logger (Go 1.21+) - Recommended for new projects -slogLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, -})) -err := ewrap.New("error occurred", - ewrap.WithLogger(adapters.NewSlogAdapter(slogLogger))) - -// Zap logger -zapLogger, _ := zap.NewProduction() -err := ewrap.New("error occurred", - ewrap.WithLogger(adapters.NewZapAdapter(zapLogger))) - -// Logrus logger -logrusLogger := logrus.New() -err := ewrap.New("error occurred", - ewrap.WithLogger(adapters.NewLogrusAdapter(logrusLogger))) - -// Zerolog logger -zerologLogger := zerolog.New(os.Stdout) -err := ewrap.New("error occurred", - ewrap.WithLogger(adapters.NewZerologAdapter(zerologLogger))) -``` +### Circuit breaker -### Recovery Suggestions in Logging - -Recovery suggestions are now automatically included in log output: +The breaker is a sibling subpackage so consumers who only want errors don't +pay for it. ```go -err := ewrap.New("database connection failed", - ewrap.WithRecoverySuggestion("Check database connectivity and connection pool settings")) +import "github.com/hyp3rd/ewrap/breaker" -// When logged, includes recovery guidance for operations teams -err.Log() // Outputs recovery suggestion in structured format -``` +cb := breaker.New("payments", 5, 30*time.Second) -## Error Formatting +if !cb.CanExecute() { + return ewrap.New("payments breaker open", + ewrap.WithRetryable(true)) +} -Convert errors to structured formats with proper timestamp formatting: +if err := charge(req); err != nil { + cb.RecordFailure() -```go -// Convert to JSON with proper timestamp formatting -jsonStr, _ := err.ToJSON( - ewrap.WithTimestampFormat(time.RFC3339), - ewrap.WithStackTrace(true), - ewrap.WithRecoverySuggestion(true)) - -// Convert to YAML with custom formatting -yamlStr, _ := err.ToYAML( - ewrap.WithTimestampFormat("2006-01-02T15:04:05Z07:00"), - ewrap.WithStackTrace(true)) - -// Serialize entire error groups -pool := ewrap.NewErrorGroupPool(4) -eg := pool.Get() -eg.Add(err1) -eg.Add(err2) + return ewrap.Wrap(err, "charging customer", + ewrap.WithHTTPStatus(http.StatusBadGateway)) +} -// Export all errors in the group -groupJSON, _ := eg.ToJSON(ewrap.WithTimestampFormat(time.RFC3339)) +cb.RecordSuccess() ``` -### Modern Go Features Integration - -Leverage Go 1.25+ features for efficient operations: +Observers receive transitions synchronously after the lock is released: ```go -// Efficient metadata copying using maps.Clone -originalErr := ewrap.New("base error").WithMetadata("key1", "value1") -clonedErr := originalErr.Clone() // Uses maps.Clone internally - -// Error group integration with errors.Join -eg := pool.Get() -eg.Add(err1, err2, err3) -standardErr := eg.Join() // Returns standard errors.Join result +type metrics struct{ /* ... */ } -// Use with standard library error handling -if errors.Is(standardErr, expectedErr) { - // Handle specific error type +func (m *metrics) RecordTransition(name string, from, to breaker.State) { + m.gauge.WithLabelValues(name, to.String()).Inc() } -``` - -## Performance Considerations -The package is designed with performance in mind and leverages modern Go features: - -### Go 1.25+ Optimizations - -- Uses `maps.Clone` and `slices.Clone` for efficient copying operations -- Zero-allocation paths for error creation and wrapping in hot paths -- Optimized stack trace capture with intelligent filtering - -### Memory Management - -- Error groups use `sync.Pool` for efficient memory reuse -- Stack frame iterators provide lazy evaluation -- Minimal allocations during error metadata operations - -### Concurrency & Safety - -- Thread-safe operations with low lock contention -- Atomic operations for circuit breaker state management -- Lock-free observability hook notifications - -### Structured Operations - -- Pre-allocated buffers for JSON/YAML serialization -- Efficient stack trace capture and filtering -- Optimized metadata storage and retrieval +cb := breaker.NewWithObserver("payments", 5, 30*time.Second, &metrics{}) +``` -## Observability Features +## Error types and severity -### Built-in Monitoring +Pre-defined enums for categorisation. Their `String()` form is what shows up +in `ErrorOutput.Type` / `Severity`, JSON, and `slog` fields. -- Error frequency tracking and reporting -- Circuit breaker state transition monitoring -- Recovery suggestion effectiveness metrics +```go +ErrorTypeUnknown // -> "unknown" +ErrorTypeValidation // -> "validation" +ErrorTypeNotFound // -> "not_found" +ErrorTypePermission // -> "permission" +ErrorTypeDatabase // -> "database" +ErrorTypeNetwork // -> "network" +ErrorTypeConfiguration // -> "configuration" +ErrorTypeInternal // -> "internal" +ErrorTypeExternal // -> "external" + +SeverityInfo // -> "info" +SeverityWarning // -> "warning" +SeverityError // -> "error" +SeverityCritical // -> "critical" +``` -### Integration Points +## Logger interface ```go -// Implement the Observer interface for custom monitoring -type Observer interface { - OnErrorCreated(err *Error, context ErrorContext) - OnCircuitBreakerStateChange(name string, from, to CircuitState) - OnRecoverySuggestionTriggered(suggestion string, context ErrorContext) +type Logger interface { + Error(msg string, keysAndValues ...any) + Debug(msg string, keysAndValues ...any) + Info(msg string, keysAndValues ...any) } - -// Register observers for monitoring -ewrap.RegisterGlobalObserver(myObserver) ``` -## Development Setup - -1. Clone this repository: - - ```bash - git clone https://github.com/hyp3rd/ewrap.git - ``` - -1. Install VS Code Extensions Recommended (optional): - - ```json - { - "recommendations": [ - "github.vscode-github-actions", - "golang.go", - "ms-vscode.makefile-tools", - "esbenp.prettier-vscode", - "pbkit.vscode-pbkit", - "trunk.io", - "streetsidesoftware.code-spell-checker", - "ms-azuretools.vscode-docker", - "eamodio.gitlens" - ] - } - ``` - - 1. Install [**Golang**](https://go.dev/dl). - 1. Install [**GitVersion**](https://github.com/GitTools/GitVersion). - 1. Install [**Make**](https://www.gnu.org/software/make/), follow the procedure for your OS. - 1. **Set up the toolchain:** - - ```bash - make prepare-toolchain - ``` - - 1. Initialize `pre-commit` (strongly recommended to create a virtual env, using for instance [PyEnv](https://github.com/pyenv/pyenv)) and its hooks: - - ```bash - pip install pre-commit - pre-commit install - pre-commit install-hooks - ``` - -## Project Structure - -```txt -├── internal/ # Private code -│ └── logger/ # Application specific code -├── pkg/ # Public libraries) -├── scripts/ # Scripts for development -├── test/ # Additional test files -└── docs/ # Documentation +Three methods, key-value pairs after the message. Implementations stay +goroutine-safe; `(*Error).Log` calls them synchronously. + +## Performance + +Snapshot from `go test -bench=. -benchmem ./test/...` on Apple Silicon (Go 1.25+): + +| Benchmark | ns/op | B/op | allocs/op | +| --- | ---: | ---: | ---: | +| `BenchmarkNew/Simple` | 1622 | 496 | 2 | +| `BenchmarkWrap/NestedWraps` | 11433 | 1512 | 9 | +| `BenchmarkFormatting/ToJSON` | 16947 | 2941 | 14 | +| `BenchmarkStackTrace/CaptureStack` | 858 | 256 | 1 | +| `BenchmarkStackTrace/FormatStack` (cached) | **1.71** | 0 | **0** | +| `BenchmarkCircuitBreaker/RecordFailure` | 33 | 0 | 0 | +| `BenchmarkMetadataOperations/GetMetadata` | 9 | 0 | 0 | + +Notable design choices behind the numbers: + +- **Lazy metadata map** — only allocated on the first `WithMetadata` call. +- **Cached `Error()` / `Stack()`** — `sync.Once` guards a one-shot computation; + subsequent reads are lock-free. +- **goccy/go-json** for the serialization hot path: ~2.5× faster than + stdlib `encoding/json` with ~half the allocations. +- **`runtime.Callers`** captures up to 32 PCs by default, configurable via + `WithStackDepth(n)`. The frame filter is function-prefix based, so the + output starts at user code. +- **Breaker** is allocation-free in steady state; observer/callback dispatch + happens outside the lock to avoid holding it across user code. + +## Project layout + +```text +. +├── attributes.go # WithHTTPStatus, WithRetryable, WithSafeMessage +├── context.go # ErrorContext, WithContext option +├── errors.go # Error type, New/Wrap/Newf/Wrapf, lazy paths +├── error_group.go # ErrorGroup, pool, serialization +├── format.go # ErrorOutput, ToJSON/ToYAML +├── format_verb.go # fmt.Formatter, slog.LogValuer +├── logger.go # Logger interface +├── observability.go # Observer interface (errors only) +├── retry.go # RetryInfo, WithRetry +├── stack.go # StackFrame, StackIterator +├── types.go # ErrorType, Severity, RecoverySuggestion +├── breaker/ # opt-in circuit breaker +└── slog/ # opt-in slog adapter ``` -## Best Practices +## Development -- Follow the [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) -- Run `golangci-lint` before committing code -- Ensure the pre-commit hooks pass -- Write tests for new functionality -- Keep packages small and focused -- Use meaningful package names -- Document exported functions and types - -## Available Make Commands - -- `make test`: Run tests. -- `make benchmark`: Run benchmark tests. -- `make update-deps`: Update all dependencies in the project. -- `make prepare-toolchain`: Install all tools required to build the project. -- `make lint`: Run the staticcheck and golangci-lint static analysis tools on all packages in the project. -- `make run`: Build and run the application in Docker. +```bash +git clone https://github.com/hyp3rd/ewrap.git +cd ewrap +make prepare-toolchain # one-time: golangci-lint, gofumpt, govulncheck, gosec +make test # go test -v -timeout 5m -cover ./... +make test-race # go test -race ./... +make benchmark # go test -bench=. -benchmem ./test/... +make lint # gci + gofumpt + staticcheck + golangci-lint +make sec # govulncheck + gosec +``` ## License @@ -476,13 +285,8 @@ ewrap.RegisterGlobalObserver(myObserver) ## Contributing -1. Fork the repository -1. Create your feature branch -1. Commit your changes -1. Push to the branch -1. Create a Pull Request - -Refer to [CONTRIBUTING](CONTRIBUTING.md) for more information. +See [CONTRIBUTING](CONTRIBUTING.md). PRs welcome — please run `make lint` and +`make test-race` before opening one. ## Author diff --git a/docs/docs/advanced/context.md b/docs/docs/advanced/context.md index f502140..2d9139d 100644 --- a/docs/docs/advanced/context.md +++ b/docs/docs/advanced/context.md @@ -1,310 +1,141 @@ # Context Integration -Understanding how to effectively integrate error handling with Go's context package is crucial for building robust, context-aware applications. Context integration allows us to carry request-scoped data, handle timeouts gracefully, and maintain traceability throughout our application's error handling flow. +`context.Context` flows through every modern Go service. ewrap weaves it +into errors via the `WithContext` option, lifts request-scoped values out +automatically, and exposes them via the typed `ErrorContext` accessor. -## Understanding Context in Error Handling +## What `WithContext` does -Go's context package serves multiple purposes in error handling: +```go +err := ewrap.New("payment failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError)) +``` -- Carrying request-scoped values (like request IDs or user information) -- Managing timeouts and cancellation -- Ensuring proper resource cleanup -- Maintaining traceability across service boundaries +This builds an `ErrorContext` and attaches it as a typed field on the +`*Error`. The option: -Let's explore how ewrap integrates with context to enhance error handling capabilities. +1. Records the supplied `ErrorType` and `Severity`. +2. Captures the file/line of the calling `New`/`Wrap` (via + `runtime.Caller`). +3. Sets `Environment` from `APP_ENV` (or `"development"` by default). +4. Reads four well-known keys out of `ctx`: + - `request_id` → `ErrorContext.RequestID` + - `user` → `ErrorContext.User` + - `operation` → `ErrorContext.Operation` + - `component` → `ErrorContext.Component` -## Basic Context Integration +If you store request-scoped data under those exact string keys, ewrap +picks it up for free. -The most straightforward way to integrate context with error handling is through the WithContext option: +## Reading the context back ```go -func processUserRequest(ctx context.Context, userID string) error { - // Create an error with context - if err := validateUser(userID); err != nil { - return ewrap.Wrap(err, "user validation failed", - ewrap.WithContext(ctx, ErrorTypeValidation, SeverityError)) - } - - return nil +if ec := err.GetErrorContext(); ec != nil { + fmt.Println(ec.RequestID, ec.Component, ec.Operation, ec.Type, ec.Severity) } ``` -When you add context to an error, ewrap automatically extracts and preserves important context values such as: - -- Request IDs for tracing -- User information for auditing -- Operation metadata for monitoring -- Timing information for performance tracking +The accessor returns `*ErrorContext` (or `nil`) — typed, no runtime cast. -## Advanced Context Usage +## Wiring it into your context -Let's look at more sophisticated ways to use context in error handling: +If you use `context.WithValue` for request data, use **string** keys +matching the names above: ```go -// Define context keys for common values -type contextKey string - -const ( - requestIDKey contextKey = "request_id" - userIDKey contextKey = "user_id" - traceIDKey contextKey = "trace_id" -) - -// RequestContext enriches context with standard fields -func RequestContext(ctx context.Context, requestID, userID string) context.Context { - ctx = context.WithValue(ctx, requestIDKey, requestID) - ctx = context.WithValue(ctx, userIDKey, userID) - ctx = context.WithValue(ctx, traceIDKey, generateTraceID()) - return ctx -} - -// ContextualOperation shows how to use context throughout an operation -func ContextualOperation(ctx context.Context) error { - // Extract context values - requestID := ctx.Value(requestIDKey).(string) - userID := ctx.Value(userIDKey).(string) - - // Create an operation-specific context - opCtx := ewrap.WithOperationContext(ctx, "user_update") - - // Start a timed operation - timer := time.Now() - - // Perform the operation with timeout - if err := performTimedOperation(opCtx); err != nil { - return ewrap.Wrap(err, "operation failed", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityError)). - WithMetadata("request_id", requestID). - WithMetadata("user_id", userID). - WithMetadata("duration_ms", time.Since(timer).Milliseconds()) - } - - return nil -} +ctx = context.WithValue(ctx, "request_id", reqID) +ctx = context.WithValue(ctx, "user", userID) +ctx = context.WithValue(ctx, "operation", "POST /v1/charges") +ctx = context.WithValue(ctx, "component", "billing") ``` -## Context-Aware Error Groups - -Error groups can be made context-aware to handle cancellation and timeouts: +Some teams prefer typed keys (e.g. `type ctxKey int`) for safety; in that +case, set both — the typed key for your own code, and the string key for +ewrap to consume: ```go -// ContextualErrorGroup manages errors with context awareness -type ContextualErrorGroup struct { - *ewrap.ErrorGroup - ctx context.Context -} - -// NewContextualErrorGroup creates a context-aware error group -func NewContextualErrorGroup(ctx context.Context, pool *ewrap.ErrorGroupPool) *ContextualErrorGroup { - return &ContextualErrorGroup{ - ErrorGroup: pool.Get(), - ctx: ctx, - } -} +type ctxKey int +const reqIDKey ctxKey = iota -// ProcessWithContext demonstrates context-aware parallel processing -func ProcessWithContext(ctx context.Context, items []Item) error { - pool := ewrap.NewErrorGroupPool(len(items)) - group := NewContextualErrorGroup(ctx, pool) - defer group.Release() - - var wg sync.WaitGroup - for _, item := range items { - wg.Add(1) - go func(item Item) { - defer wg.Done() - - // Check context cancellation - select { - case <-ctx.Done(): - group.Add(ewrap.New("operation cancelled", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityWarning))) - return - default: - if err := processItem(ctx, item); err != nil { - group.Add(err) - } - } - }(item) - } - - wg.Wait() - return group.Error() -} +ctx = context.WithValue(ctx, reqIDKey, reqID) // typed key for your code +ctx = context.WithValue(ctx, "request_id", reqID) // string key for ewrap ``` -## Timeout and Cancellation Handling +## Inheritance through `Wrap` -Proper context integration includes handling timeouts and cancellation gracefully: +`Wrap` carries the inherited `ErrorContext` from a wrapped `*Error`: ```go -// TimeoutAwareOperation shows how to handle context timeouts -func TimeoutAwareOperation(ctx context.Context) error { - // Create a timeout context - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - // Channel for operation result - resultCh := make(chan error, 1) - - // Start the operation - go func() { - resultCh <- performLongOperation(ctx) - }() - - // Wait for result or timeout - select { - case err := <-resultCh: - if err != nil { - return ewrap.Wrap(err, "operation failed", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityError)) - } - return nil - case <-ctx.Done(): - return ewrap.New("operation timed out", - ewrap.WithContext(ctx, ErrorTypeTimeout, SeverityCritical)). - WithMetadata("timeout", 5*time.Second) - } -} -``` - -## Context Propagation in Middleware +inner := ewrap.New("DB unreachable", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) -Context integration is particularly useful in middleware chains: +outer := ewrap.Wrap(inner, "loading user profile") -```go -// ErrorHandlingMiddleware demonstrates context propagation -func ErrorHandlingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Create request context with tracing - ctx := r.Context() - requestID := generateRequestID() - ctx = context.WithValue(ctx, requestIDKey, requestID) - - // Create error group for request - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Wrap handler execution - err := func() error { - // Add request timing - timer := time.Now() - defer func() { - if err := recover(); err != nil { - eg.Add(ewrap.New("panic in handler", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityCritical)). - WithMetadata("panic_value", err). - WithMetadata("stack", debug.Stack())) - } - }() - - // Execute handler - next.ServeHTTP(w, r.WithContext(ctx)) - - // Record timing - duration := time.Since(timer) - if duration > time.Second { - eg.Add(ewrap.New("slow request", - ewrap.WithContext(ctx, ErrorTypePerformance, SeverityWarning)). - WithMetadata("duration_ms", duration.Milliseconds())) - } - - return nil - }() - - if err != nil { - eg.Add(err) - } - - // Handle any collected errors - if eg.HasErrors() { - handleRequestErrors(w, eg.Error()) - } - }) -} +outer.GetErrorContext() // same as inner.GetErrorContext() ``` -## Best Practices +Pass `WithContext` to `Wrap` to override at the new layer. -### 1. Consistent Context Propagation +## Cancellation and deadlines -Maintain consistent context handling throughout your application: +`WithContext` does not record `ctx.Err()` automatically. If a deadline or +cancellation is the cause, wrap that error explicitly so callers can +classify with `errors.Is`: ```go -// ContextualService demonstrates consistent context handling -type ContextualService struct { - db *Database - log Logger -} - -func (s *ContextualService) ProcessRequest(ctx context.Context, req Request) error { - // Enrich context with request information - ctx = enrichContext(ctx, req) - - // Use context in all operations - if err := s.validateRequest(ctx, req); err != nil { - return ewrap.Wrap(err, "validation failed", - ewrap.WithContext(ctx, ErrorTypeValidation, SeverityError)) +if err := upstream.Call(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return ewrap.Wrap(err, "upstream call timed out", + ewrap.WithContext(ctx, ewrap.ErrorTypeNetwork, ewrap.SeverityWarning), + ewrap.WithRetryable(true), + ewrap.WithHTTPStatus(http.StatusGatewayTimeout)) } - - if err := s.processData(ctx, req.Data); err != nil { - return ewrap.Wrap(err, "processing failed", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityError)) + if errors.Is(err, context.Canceled) { + return ewrap.Wrap(err, "upstream call cancelled", + ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityInfo)) } - - return nil + return ewrap.Wrap(err, "upstream call failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError)) } ``` -### 2. Context Value Management +## Tracing integration -Be careful with context values and provide type-safe accessors: +`Observer.RecordError(message)` runs synchronously. Pull the active span +out of the active goroutine's context inside the observer: ```go -// RequestInfo holds request-specific context values -type RequestInfo struct { - RequestID string - UserID string - TraceID string - StartTime time.Time -} +type otelObserver struct{} -// GetRequestInfo safely extracts request information from context -func GetRequestInfo(ctx context.Context) (RequestInfo, bool) { - info, ok := ctx.Value(requestInfoKey).(RequestInfo) - return info, ok -} - -// WithRequestInfo adds request information to context -func WithRequestInfo(ctx context.Context, info RequestInfo) context.Context { - return context.WithValue(ctx, requestInfoKey, info) +func (o *otelObserver) RecordError(message string) { + span := trace.SpanFromContext(activeContext()) // however your code reaches it + if span.IsRecording() { + span.RecordError(errors.New(message)) + } } ``` -### 3. Error Context Enrichment +For richer attribute propagation, store the relevant tracing data on the +error via `WithMetadata` and emit it from your `Logger` adapter — that +gives you the structured key/value pairs ewrap would otherwise hide +behind a single message. -Systematically enrich errors with context information: +## Why string keys for context lookup? -```go -// EnrichError adds standard context information to errors -func EnrichError(ctx context.Context, err error) error { - if err == nil { - return nil - } +`context.WithValue` keys are typed `any`, so unique compile-time keys +require shared package-level types — which would create a dependency +cycle between ewrap and consumer code. - info, ok := GetRequestInfo(ctx) - if !ok { - return err - } +Pinning to string keys keeps ewrap independent of any particular +context-key convention. If you'd rather store everything via typed keys +in your own code, build the `ErrorContext` explicitly and use the method +form: - return ewrap.Wrap(err, "operation failed", - ewrap.WithContext(ctx, getErrorType(err), getSeverity(err))). - WithMetadata("request_id", info.RequestID). - WithMetadata("user_id", info.UserID). - WithMetadata("trace_id", info.TraceID). - WithMetadata("duration_ms", time.Since(info.StartTime).Milliseconds()) -} +```go +err.WithContext(&ewrap.ErrorContext{ + RequestID: reqIDFromTypedKey(ctx), + User: userFromTypedKey(ctx), + Type: ewrap.ErrorTypeExternal, + Severity: ewrap.SeverityError, +}) ``` - -The context integration in ewrap provides a robust foundation for error tracking and debugging. By consistently using these features, you can build applications that are easier to monitor, debug, and maintain. diff --git a/docs/docs/advanced/error-strategies.md b/docs/docs/advanced/error-strategies.md index 15aba44..ee28781 100644 --- a/docs/docs/advanced/error-strategies.md +++ b/docs/docs/advanced/error-strategies.md @@ -1,250 +1,243 @@ # Error Handling Strategies -Understanding how to effectively handle errors is crucial for building robust applications. This guide explores various error handling strategies using ewrap and explains when to use each approach. +Patterns we've seen work well in production code that uses ewrap. None of +them are mandatory; pick the ones that fit your shape. -## Understanding Error Context +## Sentinel errors -Error context is more than just an error message - it's the complete picture of what happened when an error occurred. With ewrap, you can capture rich context that helps with debugging and error resolution. - -### Basic Context - -At its simplest, context includes: +For small, well-known failure modes, package-level sentinels remain the +right tool — even with ewrap. Use `errors.New` for the sentinel and `Wrap` +when you need to add layered context: ```go -err := ewrap.New("user not found", - ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityError)) +var ErrNotFound = errors.New("not found") + +func GetUser(ctx context.Context, id string) (*User, error) { + u, err := store.Lookup(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, ewrap.Wrap(ErrNotFound, "user lookup", + ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusNotFound)). + WithMetadata("user_id", id) + } + return u, err +} ``` -This tells us: - -- What happened ("user not found") -- The type of error (NotFound) -- How severe the error is (Error level) - -### Enhanced Context - -For more complex scenarios, you can add detailed context: +Callers branch on identity: ```go -err := ewrap.New("database query failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithLogger(logger)). - WithMetadata("query", query). - WithMetadata("table", "users"). - WithMetadata("affected_rows", 0). - WithMetadata("latency_ms", queryTime.Milliseconds()) +if errors.Is(err, GetUser.ErrNotFound) { + // 404 path +} ``` -This provides a complete picture: +## Typed error structs -- The operation that failed -- Where it failed -- Related technical details -- Performance metrics +Use a typed struct when callers need to inspect structured fields, not +just identity. Embed `*ewrap.Error` or compose with it: -## Error Group Strategies +```go +type ValidationError struct { + Field string + Rule string + *ewrap.Error +} -Error groups are powerful tools for handling multiple potential errors in a single operation. Here's how to use them effectively: +func (e *ValidationError) Error() string { return e.Error.Error() } -### Validation Scenarios +func NewValidation(field, rule string) *ValidationError { + return &ValidationError{ + Field: field, + Rule: rule, + Error: ewrap.NewSkip(1, fmt.Sprintf("%s: %s", field, rule), + ewrap.WithContext(nil, ewrap.ErrorTypeValidation, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusUnprocessableEntity)), + } +} +``` -When validating multiple fields or conditions: +Callers extract via `errors.As`: ```go -func validateUser(user User) error { - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() +var ve *ValidationError +if errors.As(err, &ve) { + fmt.Println(ve.Field, ve.Rule) +} +``` - // Validate email - if !isValidEmail(user.Email) { - eg.Add(ewrap.New("invalid email format", - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) - } +## Top-of-handler classification + +In an HTTP handler, the most useful place to classify is at the very top +of the error path. Pull `HTTPStatus` and pick a fallback: - // Validate age - if user.Age < 18 { - eg.Add(ewrap.New("user must be 18 or older", - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) +```go +func toHTTPResponse(w http.ResponseWriter, err error) { + status := ewrap.HTTPStatus(err) + if status == 0 { + status = http.StatusInternalServerError } - // Validate username - if len(user.Username) < 3 { - eg.Add(ewrap.New("username too short", - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) + msg := err.Error() + if e, ok := err.(*ewrap.Error); ok { + msg = e.SafeError() // PII-redacted variant for the wire } - return eg.Error() + http.Error(w, msg, status) } ``` -### Parallel Operations - -When handling concurrent operations: +## Retry with classification ```go -func processItems(ctx context.Context, items []Item) error { - pool := ewrap.NewErrorGroupPool(len(items)) - eg := pool.Get() - defer eg.Release() - - var wg sync.WaitGroup - for _, item := range items { - wg.Add(1) - go func(item Item) { - defer wg.Done() - if err := processItem(ctx, item); err != nil { - eg.Add(ewrap.Wrap(err, fmt.Sprintf("failed to process item %d", item.ID))) - } - }(item) +func withRetry(ctx context.Context, op func(context.Context) error) error { + delay := 100 * time.Millisecond + for attempt := 1; attempt <= 5; attempt++ { + err := op(ctx) + if err == nil { + return nil + } + if !ewrap.IsRetryable(err) { + return err + } + select { + case <-time.After(delay): + delay *= 2 + case <-ctx.Done(): + return ewrap.Wrap(ctx.Err(), "retry budget exhausted", + ewrap.WithRetryable(false)) + } } - - wg.Wait() - return eg.Error() + return op(ctx) // last try, return as-is } ``` -## Circuit Breaker Patterns +The retry loop is fully decoupled from the error itself — `IsRetryable` +walks the chain and consults `Temporary()` as a fallback, so it works +whether the error is yours or stdlib. -Circuit breakers help prevent system overload by failing fast when problems are detected. Here are some patterns for using them effectively: +## Validation accumulator -### Basic Circuit Breaker - -For simple protection: +When validating a request, you usually want to surface every problem at +once, not just the first: ```go -cb := ewrap.NewCircuitBreaker("database", 5, time.Minute) +func validateOrder(ctx context.Context, o Order) error { + eg := pool.Get() + defer eg.Release() -func queryDatabase() error { - if !cb.CanExecute() { - return ewrap.New("circuit breaker open", - ewrap.WithErrorType(ewrap.ErrorTypeDatabase), - ewrap.WithMetadata("breaker", "database")) + if o.Customer == "" { + eg.Add(ewrap.New("customer is required", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)). + WithMetadata("field", "customer")) } - - if err := performQuery(); err != nil { - cb.RecordFailure() - return err + if o.Total <= 0 { + eg.Add(ewrap.New("total must be positive", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)). + WithMetadata("field", "total")) } - cb.RecordSuccess() - return nil + return eg.ErrorOrNil() } ``` -### Cascading Circuit Breakers +The handler can then serialize the whole group via `(*ErrorGroup).ToJSON` +for a structured 422 response. -For systems with dependencies: +## Layered messages + +Each layer's message answers "what was *this* layer doing": ```go -type Service struct { - dbBreaker *ewrap.CircuitBreaker - cacheBreaker *ewrap.CircuitBreaker - apiBreaker *ewrap.CircuitBreaker +// db.go +func (s *Store) GetUser(ctx context.Context, id string) (*User, error) { + row, err := s.db.QueryRowContext(ctx, "SELECT ...", id) + if err != nil { + return nil, ewrap.Wrap(err, "loading user row") + } + // ... } -func (s *Service) getData(ctx context.Context, id string) (Data, error) { - // Try cache first - if s.cacheBreaker.CanExecute() { - data, err := queryCache(id) - if err == nil { - s.cacheBreaker.RecordSuccess() - return data, nil - } - s.cacheBreaker.RecordFailure() +// service.go +func (s *Service) Profile(ctx context.Context, id string) (*Profile, error) { + u, err := s.store.GetUser(ctx, id) + if err != nil { + return nil, ewrap.Wrap(err, "building profile", + ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)) } + // ... +} - // Fall back to database - if s.dbBreaker.CanExecute() { - data, err := queryDatabase(id) - if err == nil { - s.dbBreaker.RecordSuccess() - return data, nil - } - s.dbBreaker.RecordFailure() +// handler.go +func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { + p, err := h.service.Profile(r.Context(), chi.URLParam(r, "id")) + if err != nil { + toHTTPResponse(w, ewrap.Wrap(err, "GET /profile")) + return } - - // Last resort: external API - if s.apiBreaker.CanExecute() { - data, err := queryAPI(id) - if err == nil { - s.apiBreaker.RecordSuccess() - return data, nil - } - s.apiBreaker.RecordFailure() - } - - return Data{}, ewrap.New("all data sources failed", - ewrap.WithErrorType(ewrap.ErrorTypeInternal), - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityCritical)) + // ... } ``` -## Best Practices for Error Recovery +The final `Error()` reads top-down: `"GET /profile: building profile: +loading user row: "`. -When handling errors, consider implementing recovery strategies: +## Don't wrap to "log and rethrow" -1. **Graceful Degradation**: +Wrapping just to log is an anti-pattern — you log the same error twice +when the eventual handler logs it. Either log **or** wrap, not both: - ```go - func getProductDetails(ctx context.Context, id string) (Product, error) { - var product Product +```go +// BAD +if err != nil { + log.Printf("failed: %v", err) + return ewrap.Wrap(err, "failed") +} - // Get core product data - data, err := getProductData(id) - if err != nil { - // Log the error but continue with partial data - logger.Error("failed to get full product data", "error", err) - product.Status = "partial" - } else { - product.Status = "complete" - } +// GOOD +if err != nil { + return ewrap.Wrap(err, "failed") +} +``` - // Get non-critical enrichment data - reviews, err := getProductReviews(id) - if err != nil { - // Add metadata about missing data - err = ewrap.Wrap(err, "failed to get reviews", - ewrap.WithMetadata("missing_component", "reviews"), - ewrap.WithErrorType(ewrap.ErrorTypePartial)) - logger.Warn("serving product without reviews", "error", err) - } +The exception is when wrap-and-log adds genuine signal (e.g. logging at a +boundary you control with metadata the caller can't see). - return product, nil - } - ``` +## Observer for metrics -1. **Retry Patterns**: +Wire a metrics observer near the top so all errors flowing through your +service get counted, with labels derived from the error context: ```go -func withRetry(operation func() error, maxAttempts int, delay time.Duration) error { - var lastErr error - - for attempt := 1; attempt <= maxAttempts; attempt++ { - err := operation() - if err == nil { - return nil - } - - lastErr = ewrap.Wrap(err, "operation failed", - ewrap.WithMetadata("attempt", attempt), - ewrap.WithMetadata("max_attempts", maxAttempts)) +type counterObserver struct { + counter *prometheus.CounterVec +} - if attempt < maxAttempts { - time.Sleep(delay * time.Duration(attempt)) - } - } +func (o *counterObserver) RecordError(message string) { + o.counter.WithLabelValues(message).Inc() +} - return lastErr +baseOpts := []ewrap.Option{ + ewrap.WithLogger(logger), + ewrap.WithObserver(observer), } + +err := ewrap.New("checkout failed", baseOpts...) +err.Log() // observer counts + logger writes ``` -The key to effective error handling is choosing the right strategy for each situation. Consider: +For label cardinality control, derive the label from `ErrorContext.Type` +inside a richer observer: -- The criticality of the operation -- Performance requirements -- User experience implications -- System resources -- Dependencies and their reliability +```go +type typedCounterObserver struct { + counter *prometheus.CounterVec + err *ewrap.Error // captured at construction +} -By understanding these factors and using ewrap's features appropriately, you can build robust and maintainable error handling systems. +func (o *typedCounterObserver) RecordError(string) { + if ec := o.err.GetErrorContext(); ec != nil { + o.counter.WithLabelValues(ec.Type.String(), ec.Severity.String()).Inc() + } +} +``` diff --git a/docs/docs/advanced/error-types.md b/docs/docs/advanced/error-types.md index 0c9d243..75b094a 100644 --- a/docs/docs/advanced/error-types.md +++ b/docs/docs/advanced/error-types.md @@ -1,254 +1,118 @@ -# Error Types +# Error Types in Practice -Error types in ewrap provide a structured way to categorize and handle different kinds of errors in your application. Understanding error types helps you make better decisions about error handling, logging, and recovery strategies. +The [`ErrorType`](../api/error-types.md) enum is small on purpose. This +page covers how to use the existing values consistently and how to extend +classification when the built-ins aren't enough. -## Understanding Error Types +## When to use each built-in -Error types serve multiple purposes: +| `ErrorType` | Trigger | Maps cleanly to | +| --- | --- | --- | +| `Validation` | Caller-supplied input failed checks | HTTP 400/422, gRPC `InvalidArgument` | +| `NotFound` | Resource lookup returned no rows | HTTP 404, gRPC `NotFound` | +| `Permission` | AuthN / AuthZ failures | HTTP 401/403, gRPC `PermissionDenied`/`Unauthenticated` | +| `Database` | Storage layer failure | HTTP 500/503, gRPC `Internal` | +| `Network` | Connectivity, DNS, TLS handshake | HTTP 502/504, gRPC `Unavailable` | +| `Configuration` | Misconfiguration at startup or runtime | HTTP 500, gRPC `FailedPrecondition` | +| `Internal` | Bug or invariant violation | HTTP 500, gRPC `Internal` | +| `External` | Third-party service rejected the request | HTTP 502/503, gRPC `Unavailable`/`FailedPrecondition` | +| `Unknown` | Default — avoid in production | HTTP 500 | -1. They help categorize errors meaningfully -1. They enable consistent error handling across your application -1. They facilitate automated error processing and reporting -1. They guide recovery strategies and user feedback +`Validation` is the only type with bespoke library behaviour: the default +retry predicate refuses to retry validation errors. -Let's explore the built-in error types and learn how to use them effectively: +## Pair with `WithHTTPStatus` -```go -type ErrorType int +`ErrorType` is for classification; `WithHTTPStatus` is for transport. +Set both — one for routing/branching, one for the wire: -const ( - ErrorTypeUnknown ErrorType = iota - ErrorTypeValidation - ErrorTypeNotFound - ErrorTypePermission - ErrorTypeDatabase - ErrorTypeNetwork - ErrorTypeConfiguration - ErrorTypeInternal - ErrorTypeExternal -) +```go +ewrap.New("invalid email", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusUnprocessableEntity)) ``` -## Using Error Types +This way, `ec.Type == ErrorTypeValidation` covers internal logic and +`ewrap.HTTPStatus(err) == 422` covers handler responses, with no risk of +the two drifting. -Error types are most powerful when combined with context and metadata. Here's how to use them effectively: +## Severity vs HTTP status -```go -func validateAndProcessUser(ctx context.Context, user User) error { - // Validation errors use ErrorTypeValidation - if err := validateUser(user); err != nil { - return ewrap.Wrap(err, "user validation failed", - ewrap.WithContext(ctx, ErrorTypeValidation, SeverityError)). - WithMetadata("validation_fields", getFailedFields(err)) - } +`Severity` answers *how loud should we be* — pager versus dashboard +versus log line. Don't conflate it with HTTP status: - // Database errors use ErrorTypeDatabase - if err := saveUser(user); err != nil { - return ewrap.Wrap(err, "failed to save user", - ewrap.WithContext(ctx, ErrorTypeDatabase, SeverityCritical)). - WithMetadata("user_id", user.ID) - } +| | Severity | HTTP status | +| --- | --- | --- | +| 401 Unauthorized | `Warning` | `401` | +| 422 Validation | `Warning` | `422` | +| 500 Internal | `Error` | `500` | +| 503 Out of capacity | `Critical` | `503` | +| 503 Breaker open | `Warning` | `503` | - // External service errors use ErrorTypeExternal - if err := notifyUserService(user); err != nil { - return ewrap.Wrap(err, "failed to notify user service", - ewrap.WithContext(ctx, ErrorTypeExternal, SeverityWarning)). - WithMetadata("service", "notification") - } +The severity drives alerting; the status drives the response. - return nil -} -``` +## Extending classification -## Error Type Patterns +If the built-in types don't fit your domain, two approaches: -Different error types often require different handling strategies. Here's a comprehensive example: +### Custom metadata key ```go -func handleError(err error) { - wrappedErr, ok := err.(*ewrap.Error) - if !ok { - // Handle plain errors - return - } - - ctx := getErrorContext(wrappedErr) - errorType := ctx.Type - severity := ctx.Severity - - switch errorType { - case ErrorTypeValidation: - // Validation errors often need user feedback - handleValidationError(wrappedErr) - - case ErrorTypeDatabase: - // Database errors might need retry logic - if severity == SeverityCritical { - notifyDatabaseAdmin(wrappedErr) - } - attemptDatabaseRecovery(wrappedErr) - - case ErrorTypeNetwork: - // Network errors often benefit from circuit breaking - handleNetworkError(wrappedErr) - - case ErrorTypePermission: - // Permission errors need security logging - logSecurityEvent(wrappedErr) - - default: - // Unknown errors need investigation - logUnexpectedError(wrappedErr) - } -} +ewrap.New("rate limit exceeded", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityWarning)). + WithMetadata("ewrap.subtype", "rate_limit"). + WithMetadata("retry_after_s", 30) +``` -func handleValidationError(err *ewrap.Error) { - // Extract validation details for user feedback - fields, _ := err.GetMetadata("validation_fields") - userMessage := buildUserFriendlyMessage(fields) +Read with `ewrap.GetMetadataValue[string](err, "ewrap.subtype")`. +Cheap, keeps you on the existing enum, surfaces in JSON / slog +automatically. - // Log for debugging but don't alert - logger.Debug("validation error occurred", - "fields", fields, - "user_message", userMessage) -} +### Extra error type in your own package -func handleDatabaseError(err *ewrap.Error) { - // Check if error is retryable - if isRetryableError(err) { - retryWithBackoff(func() error { - // Retry the operation - return nil - }) - } - - // Log critical database errors - logger.Error("database error occurred", - "error", err, - "stack", err.Stack()) -} -``` +For a closed domain enum, define your own and use it alongside ewrap's: -## Custom Error Types +```go +package billing -Sometimes you need domain-specific error types. Here's how to extend the system: +type Code int -```go -// Define custom error types const ( - ErrorTypePayment ErrorType = iota + 100 // Start after built-in types - ErrorTypeInventory - ErrorTypeShipping + CodeUnknown Code = iota + CodeCardDeclined + CodeRateLimited + CodeFraudDetected ) -// Create a type registry -type ErrorTypeRegistry struct { - types map[ErrorType]string - mu sync.RWMutex -} - -func NewErrorTypeRegistry() *ErrorTypeRegistry { - return &ErrorTypeRegistry{ - types: make(map[ErrorType]string), - } -} - -func (r *ErrorTypeRegistry) Register(et ErrorType, name string) { - r.mu.Lock() - defer r.mu.Unlock() - r.types[et] = name -} - -func (r *ErrorTypeRegistry) GetName(et ErrorType) string { - r.mu.RLock() - defer r.mu.RUnlock() - if name, ok := r.types[et]; ok { - return name - } - return "unknown" -} - -// Usage example -func initErrorTypes() *ErrorTypeRegistry { - registry := NewErrorTypeRegistry() - - // Register custom error types - registry.Register(ErrorTypePayment, "payment") - registry.Register(ErrorTypeInventory, "inventory") - registry.Register(ErrorTypeShipping, "shipping") - - return registry +func New(code Code, msg string, opts ...ewrap.Option) *ewrap.Error { + return ewrap.NewSkip(1, msg, append(opts, + ewrap.WithContext(context.Background(), ewrap.ErrorTypeExternal, ewrap.SeverityWarning), + )...).WithMetadata("billing_code", code) } ``` -## Error Type Best Practices - -### 1. Consistent Type Assignment - -Be consistent in how you assign error types: +Callers branch on the metadata: ```go -// Good - consistent error typing -func processOrder(order Order) error { - if err := validateOrder(order); err != nil { - return ewrap.Wrap(err, "order validation failed", - ewrap.WithErrorType(ErrorTypeValidation)) - } - - if err := checkInventory(order); err != nil { - return ewrap.Wrap(err, "inventory check failed", - ewrap.WithErrorType(ErrorTypeInventory)) - } - - if err := processPayment(order); err != nil { - return ewrap.Wrap(err, "payment processing failed", - ewrap.WithErrorType(ErrorTypePayment)) +if c, ok := ewrap.GetMetadataValue[billing.Code](err, "billing_code"); ok { + switch c { + case billing.CodeCardDeclined: + // ... } - - return nil -} - -// Avoid - inconsistent or missing error types -func processOrder(order Order) error { - if err := validateOrder(order); err != nil { - return fmt.Errorf("validation failed: %w", err) - } - // ... } ``` -### 2. Error Type Hierarchy +This keeps ewrap's enum closed (so we don't ship breaking-change new +values), while letting your domain pile on as much specificity as you +need. -Consider creating error type hierarchies for complex domains: +## Avoid the "unknown" default -```go -// Define error type hierarchy -type ErrorCategory int +A value of `ErrorTypeUnknown` (the zero value) usually means somebody +forgot `WithContext`. Two ways to keep it out of production: -const ( - CategoryValidation ErrorCategory = iota - CategoryInfrastructure - CategoryBusiness -) - -type DomainErrorType struct { - Type ErrorType - Category ErrorCategory - Retryable bool -} - -var errorTypeRegistry = map[ErrorType]DomainErrorType{ - ErrorTypeValidation: { - Category: CategoryValidation, - Retryable: false, - }, - ErrorTypeDatabase: { - Category: CategoryInfrastructure, - Retryable: true, - }, - ErrorTypePayment: { - Category: CategoryBusiness, - Retryable: true, - }, -} -``` +1. **CI lint:** grep for new `ewrap.New(...)` / `ewrap.Wrap(...)` calls + that don't include `WithContext`. Easy to pair with a CODEOWNERS rule. +2. **Default in handlers:** when serializing to a response, treat + `ErrorTypeUnknown` as a 500 with a generic message — never echo back + the raw text — and emit a metric so the gap is visible. diff --git a/docs/docs/advanced/formatting.md b/docs/docs/advanced/formatting.md index 765f38a..220f2b5 100644 --- a/docs/docs/advanced/formatting.md +++ b/docs/docs/advanced/formatting.md @@ -1,335 +1,138 @@ -# Error Formatting +# Formatting -Error formatting in ewrap provides flexible ways to present errors in different formats and contexts with proper timestamp formatting and enhanced serialization capabilities. This capability is crucial for logging, debugging, API responses, and system integration. Let's explore how to effectively format errors to meet various needs in your application. +Three ways to render an `*Error` for output. Pick the one that matches +the consumer. -## Understanding Error Formatting +| Consumer | Use | +| --- | --- | +| Human reader (terminal, dev logs) | `fmt.Printf("%+v", err)` | +| Structured log sink (slog/zap/zerolog) | `LogValuer` (automatic) or `(*Error).Log` | +| Machine pipeline (transport, dashboard) | `(*Error).ToJSON()` / `ToYAML()` | -When an error occurs in your system, you might need to present it in different ways depending on the context: +## `fmt.Formatter` -- As JSON for API responses with proper timestamp formatting -- As YAML for configuration-related errors -- As structured text for logging with recovery suggestions -- As user-friendly messages for end users -- As serialized error groups for monitoring systems - -ewrap provides comprehensive formatting options to handle all these cases while maintaining the rich context and metadata associated with your errors. - -## Enhanced JSON Formatting - -JSON formatting now includes proper timestamp formatting and recovery suggestions: +`*Error` implements `fmt.Formatter` and supports four verbs: ```go -func handleAPIError(w http.ResponseWriter, err error) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Convert error to JSON with enhanced formatting - jsonOutput, err := wrappedErr.ToJSON( - ewrap.WithTimestampFormat(time.RFC3339), // Proper timestamp formatting - ewrap.WithStackTrace(true), - ewrap.WithRecoverySuggestion(true), // Include recovery guidance - ) - - if err != nil { - // Handle formatting error - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(jsonOutput)) - } -} -``` +err := ewrap.New("boom").WithMetadata("k", "v") -### Enhanced JSON Structure - -The resulting JSON now includes proper timestamp formatting and recovery suggestions: - -```json -{ - "message": "failed to process user order", - "timestamp": "2024-03-15T14:30:00Z", - "type": "database", - "severity": "critical", - "stack_trace": [ - { - "function": "main.processOrder", - "file": "/app/main.go", - "line": 42, - "pc": "0x4567890" - } - ], - "metadata": { - "user_id": "12345", - "order_id": "ord_789", - "retry_count": 3 - }, - "recovery_suggestion": "Check database connectivity and retry with exponential backoff" -} +fmt.Printf("%s\n", err) // boom +fmt.Printf("%v\n", err) // boom +fmt.Printf("%q\n", err) // "boom" +fmt.Printf("%+v\n", err) // boom\n\n ``` -## Timestamp Formatting Options - -### Standard Formats +| Verb | Output | +| --- | --- | +| `%s`, `%v` | `Error()` | +| `%q` | quoted `Error()` | +| `%+v` | `Error()` + newline + `Stack()` | -ewrap supports various timestamp formats: +Both `Error()` and `Stack()` are cached, so formatting is essentially free +after the first call. -```go -// RFC3339 format (recommended for APIs and JSON) -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat(time.RFC3339)) -// Result: "2024-03-15T14:30:00Z" - -// RFC3339Nano for high precision -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat(time.RFC3339Nano)) -// Result: "2024-03-15T14:30:00.123456789Z" +## `slog.LogValuer` -// Kitchen format for human-readable logs -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat(time.Kitchen)) -// Result: "2:30PM" +`*Error` implements `slog.LogValuer`, so passing it as a value in any +`log/slog` call emits structured fields: -// Custom format for specific requirements -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat("2006-01-02 15:04:05")) -// Result: "2024-03-15 14:30:00" +```go +slog.Error("payment failed", "err", err) ``` -### Unix Timestamp Support +The handler receives an attribute group with: -For systems integration requiring Unix timestamps: - -```go -// Unix timestamp (seconds since epoch) -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat("unix")) -// Result: "1710507000" +- `message` (always) +- `type`, `severity` (if `WithContext` was set) +- `component`, `operation`, `request_id` (if non-empty in context) +- `recovery` (if `WithRecoverySuggestion` was set) +- `cause` (if non-nil) +- one attribute per metadata key -// Unix timestamp with milliseconds -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat("unix_milli")) -// Result: "1710507000123" +If you'd rather log via `(*Error).Log` (using an attached `ewrap.Logger`), +the same fields appear: -// Unix timestamp with microseconds -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat("unix_micro")) -// Result: "1710507000123456" +```go +err := ewrap.New("boom", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), + ewrap.WithLogger(logger)) +err.Log() ``` -## YAML Formatting with Recovery Suggestions +`Log` writes a single record at error level. -YAML formatting now supports recovery suggestions and enhanced metadata: +## JSON and YAML ```go -func logConfigurationError(err error, logger Logger) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Convert error to YAML with recovery suggestions - yamlOutput, err := wrappedErr.ToYAML( - ewrap.WithTimestampFormat(time.RFC3339), - ewrap.WithStackTrace(true), - ewrap.WithRecoverySuggestion(true), - ) - - if err != nil { - logger.Error("failed to format error", "error", err) - return - } - - logger.Error("configuration error occurred", "details", yamlOutput) - } -} -``` +jsonStr, _ := err.ToJSON( + ewrap.WithTimestampFormat(time.RFC3339), + ewrap.WithStackTrace(true), +) -### Enhanced YAML Structure - -The formatted YAML now includes recovery suggestions: - -```yaml -message: failed to load configuration -timestamp: "2024-03-15T14:30:00Z" -type: configuration -severity: critical -stack_trace: - - function: main.loadConfig - file: /app/config.go - line: 25 - pc: "0x4567890" - - function: main.initialize - file: /app/main.go - line: 15 - pc: "0x4567891" -metadata: - config_file: /etc/myapp/config.yaml - invalid_fields: - - database.host - - database.port -recovery_suggestion: "Check configuration file format and validate required fields" +yamlStr, _ := err.ToYAML(ewrap.WithStackTrace(false)) ``` -## Custom Formatting +The schema is documented in [Serialization](../features/serialization.md). +Two format options: -Sometimes you need to create custom formats for specific use cases. Here's how to build custom formatters: +| Option | Effect | +| --- | --- | +| `WithTimestampFormat(layout)` | Reformat the `timestamp` field. Empty = leave unchanged. | +| `WithStackTrace(false)` | Strip the `stack` field. | -```go -type ErrorFormatter struct { - TimestampFormat string - IncludeStack bool - IncludeMetadata bool - MaxStackDepth int -} - -func NewErrorFormatter() *ErrorFormatter { - return &ErrorFormatter{ - TimestampFormat: time.RFC3339, - IncludeStack: true, - IncludeMetadata: true, - MaxStackDepth: 10, - } -} - -func (f *ErrorFormatter) Format(err *ewrap.Error) map[string]any { - // Create base error information - formatted := map[string]any{ - "message": err.Error(), - "timestamp": time.Now().Format(f.TimestampFormat), - } - - // Add stack trace if enabled - if f.IncludeStack { - formatted["stack"] = f.formatStack(err.Stack()) - } - - // Add metadata if enabled - if f.IncludeMetadata { - metadata := make(map[string]any) - // Extract and format metadata... - formatted["metadata"] = metadata - } - - return formatted -} - -func (f *ErrorFormatter) formatStack(stack string) []string { - lines := strings.Split(stack, "\n") - if len(lines) > f.MaxStackDepth { - lines = lines[:f.MaxStackDepth] - } - return lines -} -``` +For an `ErrorGroup`, the same options apply via `(*ErrorGroup).ToJSON()` +/ `ToYAML()`. The group also implements `json.Marshaler` and +`yaml.Marshaler`, so generic encoders that consume them via +`json.Marshal` / `yaml.Marshal` work without ceremony. -## User-Friendly Error Messages +## Picking the timestamp format -When presenting errors to end users, you often need to transform technical errors into user-friendly messages while preserving the technical details for logging: +Default: RFC3339 (e.g. `2026-05-02T10:11:12Z`). Most log pipelines parse +that natively. Use the friendlier alternatives only when emitting +direct-to-human output: ```go -type UserErrorFormatter struct { - translations map[ErrorType]string - logger Logger -} - -func NewUserErrorFormatter(logger Logger) *UserErrorFormatter { - return &UserErrorFormatter{ - translations: map[ErrorType]string{ - ErrorTypeValidation: "The provided information is invalid", - ErrorTypeNotFound: "The requested resource could not be found", - ErrorTypePermission: "You don't have permission to perform this action", - ErrorTypeDatabase: "A system error occurred", - ErrorTypeNetwork: "Connection issues detected", - ErrorTypeConfiguration: "System configuration error", - }, - logger: logger, - } -} - -func (f *UserErrorFormatter) FormatForUser(err error) string { - // Always log the full technical error - if wrappedErr, ok := err.(*ewrap.Error); ok { - f.logger.Error("error occurred", - "technical_details", wrappedErr.ToJSON()) - - // Get error context - ctx := getErrorContext(wrappedErr) - - // Return translated message - if msg, ok := f.translations[ctx.Type]; ok { - return msg - } - } - - // Default message for unknown errors - return "An unexpected error occurred" -} +err.ToJSON(ewrap.WithTimestampFormat(time.DateTime)) // 2026-05-02 10:11:12 +err.ToJSON(ewrap.WithTimestampFormat("2006-01-02")) // 2026-05-02 ``` -## Best Practices for Error Formatting - -### 1. Security-Conscious Formatting +## Stripping stacks for hot paths -Be careful about what information you expose in different contexts: +A formatted stack trace adds a few hundred bytes to each serialized +record. If you serialize on a high-volume path (e.g. error metrics +shipping), strip the stack and capture it only in dev/debug paths: ```go -func formatErrorResponse(err error, internal bool) any { - wrappedErr, ok := err.(*ewrap.Error) - if !ok { - return map[string]string{"message": "Internal Server Error"} - } - - if internal { - // Full details for internal logging - return map[string]any{ - "message": wrappedErr.Error(), - "stack": wrappedErr.Stack(), - "metadata": wrappedErr.GetAllMetadata(), - "type": getErrorContext(wrappedErr).Type, - "severity": getErrorContext(wrappedErr).Severity, - } - } - - // Limited information for external responses - return map[string]string{ - "message": sanitizeErrorMessage(wrappedErr.Error()), - "code": getPublicErrorCode(wrappedErr), - } -} +const includeStack = false // toggle in your config + +opts := []ewrap.FormatOption{ewrap.WithStackTrace(includeStack)} +jsonStr, _ := err.ToJSON(opts...) ``` -### 2. Consistent Format Structure +## SafeError for external sinks -Maintain consistent error format structures across your application: +When the rendered output may leave your trust boundary (third-party log +ingestion, customer-facing responses), use `SafeError()`: ```go -type StandardErrorResponse struct { - Message string `json:"message"` - Code string `json:"code"` - Details map[string]any `json:"details,omitempty"` - RequestID string `json:"request_id,omitempty"` - Timestamp string `json:"timestamp"` -} - -func NewStandardErrorResponse(err error, requestID string) StandardErrorResponse { - return StandardErrorResponse{ - Message: getErrorMessage(err), - Code: getErrorCode(err), - Details: getErrorDetails(err), - RequestID: requestID, - Timestamp: time.Now().UTC().Format(time.RFC3339), - } -} +external.Log(err.SafeError()) // redacted variant +internal.Log(err.Error()) // full detail ``` -### 3. Context-Aware Formatting +See [Operational Features](../features/operational.md) for how to attach +safe messages. -Adjust formatting based on the execution context: +## Performance summary -```go -func formatErrorByEnvironment(err error, env string) any { - switch env { - case "development": - // Include everything in development - return formatWithFullDetails(err) - case "testing": - // Include stack traces but sanitize sensitive data - return formatForTesting(err) - case "production": - // Minimal public information - return formatForProduction(err) - default: - return formatWithDefaultSettings(err) - } -} -``` +| Operation | ns/op | allocs | +| --- | ---: | ---: | +| `fmt.Sprintf("%v", err)` (cached) | ~30 | 1 (output buffer) | +| `fmt.Sprintf("%+v", err)` (cached) | ~70 | 1 | +| `(*Error).Log` (with metadata) | ~500 | a few (logger-dependent) | +| `(*Error).ToJSON` | ~17,000 | ~14 | +| `(*Error).ToYAML` | ~250,000 | ~115 | + +The first call to `Error()` and `Stack()` does the formatting; subsequent +calls hit the cache. JSON output is dominated by `goccy/go-json` (already +~2.5× faster than stdlib for this payload shape). YAML is significantly +slower — prefer JSON wherever the consumer accepts it. diff --git a/docs/docs/advanced/performance.md b/docs/docs/advanced/performance.md index 4688d56..888447f 100644 --- a/docs/docs/advanced/performance.md +++ b/docs/docs/advanced/performance.md @@ -1,324 +1,154 @@ # Performance Optimization -Understanding how to optimize error handling is crucial for maintaining high-performance applications. While error handling is essential, it shouldn't become a bottleneck in your application. Let's explore how ewrap helps you achieve efficient error handling and learn about optimization strategies. +ewrap is designed for the hot path. This page collates the design choices +and the knobs you control. -## Understanding Error Handling Performance +## Numbers at a glance -When we talk about performance in error handling, we need to consider several aspects: +`go test -bench=. -benchmem ./test/...` (Apple Silicon, Go 1.25+): -1. Memory allocation and garbage collection impact -1. CPU overhead from stack trace capture -1. Concurrency and contention in high-throughput scenarios -1. The cost of error formatting and logging -1. The impact of error wrapping chains +| Benchmark | ns/op | B/op | allocs | +| --- | ---: | ---: | ---: | +| `BenchmarkNew/Simple` | 1622 | 496 | 2 | +| `BenchmarkNew/WithContext` | 5273 | 968 | 6 | +| `BenchmarkWrap/Simple` | 3828 | 504 | 3 | +| `BenchmarkWrap/NestedWraps` | 11433 | 1512 | 9 | +| `BenchmarkErrorGroup/AddErrors` | ~22000 | 752 | 24 | +| `BenchmarkFormatting/ToJSON` | 16947 | 2941 | 14 | +| `BenchmarkFormatting/ToYAML` | 247276 | 40472 | 115 | +| `BenchmarkCircuitBreaker/RecordFailure` | 33 | 0 | 0 | +| `BenchmarkMetadataOperations/AddMetadata` | 4895 | 852 | 9 | +| `BenchmarkMetadataOperations/GetMetadata` | 9 | 0 | 0 | +| `BenchmarkStackTrace/CaptureStack` | 858 | 256 | 1 | +| `BenchmarkStackTrace/FormatStack` (cached) | **1.71** | 0 | **0** | -Let's explore how ewrap addresses each of these concerns and how you can optimize your error handling. +## Where the allocations come from -## Memory Management +A bare `ewrap.New("...")`: -One of the most significant performance impacts in error handling comes from memory allocations. ewrap uses several strategies to minimize this impact: +1. `*Error` struct — one allocation. +2. `runtime.Callers` PC slice (32 entries) — one allocation. -### Object Pooling +That's it — two allocations, ~500 bytes. The metadata map is **lazy**: +allocated on the first `WithMetadata`, never if you don't call it. -The Error Group pool is a prime example of how we can reduce memory pressure: +`Wrap` adds a third allocation when the inner error is a `*Error` +(cloning its metadata map via `maps.Clone`). -```go -// Create a pool with an appropriate size for your use case -pool := ewrap.NewErrorGroupPool(4) - -func processItems(items []Item) error { - // Get an error group from the pool - eg := pool.Get() - defer eg.Release() // Return to pool when done - - for _, item := range items { - if err := processItem(item); err != nil { - eg.Add(err) - } - } - - return eg.Error() -} -``` - -This approach is particularly effective because: +## Caching `Error()` and `Stack()` -1. It reduces garbage collection pressure -1. It minimizes memory fragmentation -1. It provides predictable memory usage patterns - -### Pre-allocation Strategies - -When dealing with metadata or formatting, pre-allocation can significantly improve performance: +Both methods are guarded by `sync.Once`: ```go -// Pre-allocate slices with expected capacity -func buildErrorContext(err error, expectedFields int) map[string]any { - // Allocate map with expected size to avoid resizing - context := make(map[string]any, expectedFields) - - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Pre-allocate string builder with reasonable capacity - var builder strings.Builder - builder.Grow(256) // Reserve space for typical error message - - // Build context efficiently - builder.WriteString("Error occurred in ") - builder.WriteString(wrappedErr.Operation()) - context["message"] = builder.String() - - // Add other fields... - } - - return context -} +e.errOnce.Do(func() { + e.errStr = ... // single computation +}) +return e.errStr ``` -## Stack Trace Optimization +After the first call, every subsequent `Error()` / `Stack()` (and any +verb that uses them — `%v`, `%+v`, `LogValue`) returns the cached string +with zero allocations. -Stack traces are expensive to capture, so ewrap implements several optimizations: +If you log the same error multiple times — common in retry / fan-out +flows — this is a substantial win. -### Lazy Stack Capture +## Tuning stack capture -```go -type lazyStack struct { - pcs []uintptr - frames runtime.Frames - once sync.Once -} - -func (ls *lazyStack) Frames() runtime.Frames { - ls.once.Do(func() { - if ls.frames == nil { - ls.frames = runtime.CallersFrames(ls.pcs) - } - }) - return ls.frames -} -``` - -### Stack Filtering +| What | Default | How to change | +| --- | --- | --- | +| Capture depth | 32 frames | `WithStackDepth(n)` — pass 0 to disable | +| Caller skip | starts at user code | `NewSkip(skip, ...)` / `WrapSkip(skip, ...)` for helpers | +| Frame filter | hides `runtime.*` and ewrap internals | not configurable; fork if needed | -We filter out unnecessary frames to reduce memory usage and improve readability: +Disabling capture entirely on a hot path: ```go -func filterStack(stack []runtime.Frame) []runtime.Frame { - filtered := make([]runtime.Frame, 0, len(stack)) - for _, frame := range stack { - if shouldIncludeFrame(frame) { - filtered = append(filtered, frame) - } - } - return filtered -} - -func shouldIncludeFrame(frame runtime.Frame) bool { - // Skip runtime frames - if strings.Contains(frame.File, "runtime/") { - return false - } - // Skip ewrap internal frames - if strings.Contains(frame.File, "ewrap/errors.go") { - return false - } - return true -} +ewrap.New("rate limited", ewrap.WithStackDepth(0)) ``` -## Concurrency Optimization - -In high-concurrency scenarios, efficient error handling becomes even more critical: +This trades the second allocation (PCs slice) for zero stack output — the +right call when you know the error will be classified-and-returned, not +debugged. -### Lock-Free Operations +## `ErrorGroup` pooling -Where possible, ewrap uses atomic operations instead of locks: +For high-throughput aggregation (validation passes, batch operations, +fan-out), reuse `ErrorGroup` via a pool: ```go -type AtomicCounter struct { - value int64 -} +pool := ewrap.NewErrorGroupPool(8) // initial slice capacity per group -func (c *AtomicCounter) Increment() { - atomic.AddInt64(&c.value, 1) -} - -func (c *AtomicCounter) Get() int64 { - return atomic.LoadInt64(&c.value) -} +eg := pool.Get() +defer eg.Release() ``` -### Minimizing Lock Contention - -When locks are necessary, we minimize their scope: - -```go -func (eg *ErrorGroup) Add(err error) { - if err == nil { - return - } - - // Prepare the error outside the lock - wrappedErr := prepareError(err) - - // Minimize critical section - eg.mu.Lock() - eg.errors = append(eg.errors, wrappedErr) - eg.mu.Unlock() -} -``` - -## Formatting Performance - -Error formatting can be expensive, especially for JSON/YAML conversion. Here's how to optimize it: - -### Cached Formatting - -For frequently accessed formats: - -```go -type CachedError struct { - err *ewrap.Error - jsonCache atomic.Value - yamlCache atomic.Value - cacheTimeout time.Duration -} - -func (ce *CachedError) ToJSON() (string, error) { - if cached := ce.jsonCache.Load(); cached != nil { - cacheEntry := cached.(*formatCacheEntry) - if !cacheEntry.isExpired() { - return cacheEntry.data, nil - } - } - - // Format and cache the result - json, err := ce.err.ToJSON() - if err != nil { - return "", err - } - - ce.jsonCache.Store(&formatCacheEntry{ - data: json, - expires: time.Now().Add(ce.cacheTimeout), - }) - - return json, nil -} -``` +`Release()` clears the slice (preserving capacity) and returns the group +to the pool. The pool is goroutine-safe (`sync.Pool`). -### Efficient Buffer Usage +A warm pool eliminates the slice header allocation for new groups; the +benchmark above (24 allocs for 10 errors with `AddErrors`) drops to 14 +when running with a pool. -When formatting errors: +## Lazy metadata ```go -// Pool of buffers for formatting -var bufferPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - -func formatError(err *ewrap.Error) string { - buf := bufferPool.Get().(*bytes.Buffer) - defer func() { - buf.Reset() - bufferPool.Put(buf) - }() - - // Use buffer for formatting - buf.WriteString("Error: ") - buf.WriteString(err.Error()) - buf.WriteString("\nStack:\n") - buf.WriteString(err.Stack()) - - return buf.String() -} +err := ewrap.New("boom") // err.metadata == nil +err.WithMetadata("k", "v") // map is allocated here ``` -## Performance Monitoring - -To ensure your error handling remains efficient, implement monitoring: - -```go -type ErrorMetrics struct { - creationTime metrics.Histogram - wrappingTime metrics.Histogram - stackDepth metrics.Histogram - allocationSize metrics.Histogram -} - -func TrackErrorMetrics(err error, metrics *ErrorMetrics) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - metrics.stackDepth.Observe(float64(len(wrappedErr.Stack()))) - // Track other metrics... - } -} -``` - -## Best Practices for Performance - -### 1. Pool Appropriately - -Choose pool sizes based on your application's characteristics: - -```go -func initializePools(config Config) { - // Size pools based on expected concurrent operations - errorGroupPool := ewrap.NewErrorGroupPool(config.MaxConcurrentOperations) - - // Size buffer pools based on expected error volume - bufferPool := sync.Pool{ - New: func() any { - return bytes.NewBuffer(make([]byte, 0, config.AverageErrorSize)) - }, - } -} -``` - -### 2. Minimize Allocations - -Be mindful of unnecessary allocations: - -```go -// Good - reuse error types for common cases -var ( - ErrNotFound = ewrap.New("resource not found", - ewrap.WithErrorType(ErrorTypeNotFound)) - ErrUnauthorized = ewrap.New("unauthorized access", - ewrap.WithErrorType(ErrorTypePermission)) -) - -// Avoid - creating new errors for common cases -if !exists { - return ewrap.New("resource not found") // Creates new error each time -} -``` - -### 3. Profile and Monitor - -Regularly profile your error handling: - -```go -func TestErrorPerformance(t *testing.T) { - if testing.Short() { - t.Skip("skipping performance test in short mode") - } - - // Profile creation - b.Run("ErrorCreation", func(b *testing.B) { - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = ewrap.New("test error") - } - }) - - // Profile wrapping - b.Run("ErrorWrapping", func(b *testing.B) { - baseErr := errors.New("base error") - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = ewrap.Wrap(baseErr, "wrapped error") - } - }) -} -``` +Errors that never carry metadata pay zero for the map. The metadata map +is also cloned (not shared) when `Wrap` inherits from an inner `*Error`, +so wrapper writes never mutate the inner. + +## JSON vs YAML + +JSON via `goccy/go-json` is ~14× faster than YAML for the same payload: + +| | ns/op | allocs | +| --- | ---: | ---: | +| `Error.ToJSON` | 16947 | 14 | +| `Error.ToYAML` | 247276 | 115 | + +If you control the format, prefer JSON. Strip stacks for high-volume +sinks (`WithStackTrace(false)`). + +## Concurrency + +| Type | Locking | +| --- | --- | +| `*Error` (read paths after construction) | lock-free (cached, immutable) | +| `*Error.WithMetadata`, `GetMetadata` | `sync.RWMutex` | +| `*Error.IncrementRetry` | `sync.RWMutex` (write) | +| `ErrorGroup.Add`, `Errors`, etc. | `sync.RWMutex` | +| `breaker.Breaker` ops | single `sync.Mutex` | + +Hot-path reads (`Error()`, `Stack()`, `LogValue`, `Format`) don't take the +mutex — they read fields set at construction or cached results. + +## Hot-path checklist + +- ☑ Pool `ErrorGroup` instances if you allocate many per request. +- ☑ Set `WithStackDepth(0)` on classified-and-returned errors that + won't be debugged. +- ☑ Reuse a single `Logger` and `Observer` across the request — both + are inherited by `Wrap`. +- ☑ Prefer `slog.LogValuer` (no adapter) over `(*Error).Log` when + you're already inside an `slog` handler. +- ☑ Use `ewrap.GetMetadataValue[T]` instead of `GetMetadata` followed + by a type assertion. +- ☐ Don't reallocate `RecoverySuggestion` per call — define them as + `var`s and reuse. +- ☐ Don't log + wrap. Wrap and let the eventual handler log. + +## When ewrap is the wrong tool + +- **Inner loops processing millions of items:** errors should be + exceptional. If you're allocating one per iteration, restructure the + algorithm so failure is rare or signalled differently (sentinel + variable, skip count, channel signal). +- **CGo error wrappers:** stack capture across the cgo boundary is + pointless. Use `ewrap.New(msg, ewrap.WithStackDepth(0))` and pass the + C errno via metadata. +- **Single-binary CLI tools:** the structured fields are overkill — + `fmt.Errorf` with `%w` is plenty. diff --git a/docs/docs/advanced/testing.md b/docs/docs/advanced/testing.md index 066fbb4..32f82fc 100644 --- a/docs/docs/advanced/testing.md +++ b/docs/docs/advanced/testing.md @@ -1,317 +1,248 @@ # Testing Error Handling -Testing error handling is crucial for building reliable applications. Good error handling tests not only verify that errors are caught and handled correctly but also ensure that error contexts, metadata, and performance characteristics meet your requirements. Let's explore how to effectively test error handling using ewrap. +Patterns for testing code that produces `*ewrap.Error` values. -## Understanding Error Testing +## Asserting identity with `errors.Is` -Testing error handling requires a different mindset from testing normal application flow. We need to verify not just that errors are caught, but that they carry the right information, perform efficiently, and integrate properly with the rest of our system. Let's break this down into manageable pieces. +Always test by **identity**, not by string match. ewrap respects the +stdlib chain via `Unwrap()`, so `errors.Is` works through `Wrap`: -## Unit Testing Error Handling +```go +sentinel := errors.New("not found") + +err := layered() // returns ewrap.Wrap(sentinel, "...") + +if !errors.Is(err, sentinel) { + t.Fatalf("expected sentinel in chain, got %v", err) +} +``` + +Strings are fragile and break the moment somebody adds a layer. -Let's start with basic unit tests that verify error creation and handling: +## Asserting type with `errors.As` + +For typed errors: ```go -func TestErrorCreation(t *testing.T) { - // We'll create a structured test to verify different aspects of error creation - testCases := []struct { - name string - message string - errorType ErrorType - severity Severity - metadata map[string]any - expectedStack bool - }{ - { - name: "Basic Error", - message: "something went wrong", - errorType: ErrorTypeUnknown, - severity: SeverityError, - metadata: nil, - expectedStack: true, - }, - { - name: "Database Error with Metadata", - message: "connection failed", - errorType: ErrorTypeDatabase, - severity: SeverityCritical, - metadata: map[string]any{ - "host": "localhost", - "port": 5432, - }, - expectedStack: true, - }, - } +var ec *ewrap.Error +if !errors.As(err, &ec) { + t.Fatal("expected an ewrap.Error in the chain") +} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Create the error with test case parameters - err := ewrap.New(tc.message, - ewrap.WithContext(context.Background(), tc.errorType, tc.severity)) - - // Add metadata if provided - if tc.metadata != nil { - for k, v := range tc.metadata { - err = err.WithMetadata(k, v) - } - } - - // Verify error properties - if err.Error() != tc.message { - t.Errorf("Expected message %q, got %q", tc.message, err.Error()) - } - - // Verify stack trace presence - if tc.expectedStack && err.Stack() == "" { - t.Error("Expected stack trace, but none was captured") - } - - // Verify metadata - if tc.metadata != nil { - for k, v := range tc.metadata { - if mv, ok := err.GetMetadata(k); !ok || mv != v { - t.Errorf("Metadata %q: expected %v, got %v", k, v, mv) - } - } - } - }) - } +if ec.GetErrorContext().Type != ewrap.ErrorTypeValidation { + t.Errorf("expected validation type, got %v", ec.GetErrorContext().Type) } ``` -## Testing Error Wrapping +## Asserting attached state -Error wrapping requires special attention to ensure context is preserved: +`HTTPStatus`, `IsRetryable`, and the typed accessors are the right tools +in tests: ```go -func TestErrorWrapping(t *testing.T) { - // Create a mock logger to verify logging behavior - mockLogger := NewMockLogger(t) - - // Create a base error - baseErr := errors.New("base error") - - // Create our wrapped error - wrappedErr := ewrap.Wrap(baseErr, "operation failed", - ewrap.WithLogger(mockLogger), - ewrap.WithContext(context.Background(), ErrorTypeDatabase, SeverityCritical)) - - // Test error chain - t.Run("Error Chain", func(t *testing.T) { - // Verify the complete error message - expectedMsg := "operation failed: base error" - if wrappedErr.Error() != expectedMsg { - t.Errorf("Expected message %q, got %q", expectedMsg, wrappedErr.Error()) - } +if got := ewrap.HTTPStatus(err); got != http.StatusBadGateway { + t.Errorf("HTTP status: got %d, want %d", got, http.StatusBadGateway) +} - // Verify we can unwrap to the original error - if !errors.Is(wrappedErr, baseErr) { - t.Error("Wrapped error should match the original error") - } +if !ewrap.IsRetryable(err) { + t.Error("expected retryable error") +} - // Verify the error chain is preserved - cause := wrappedErr.Unwrap() - if cause != baseErr { - t.Error("Unwrapped error should be the base error") - } - }) +if rs := ec.Recovery(); rs == nil || rs.Message == "" { + t.Error("expected non-empty recovery suggestion") +} +``` - // Test context preservation - t.Run("Context Preservation", func(t *testing.T) { - ctx := getErrorContext(wrappedErr) +## Checking metadata - if ctx.Type != ErrorTypeDatabase { - t.Errorf("Expected error type %v, got %v", ErrorTypeDatabase, ctx.Type) - } +```go +val, ok := ec.GetMetadata("user_id") +if !ok || val != "u-1" { + t.Errorf("user_id metadata: got %v (ok=%v)", val, ok) +} - if ctx.Severity != SeverityCritical { - t.Errorf("Expected severity %v, got %v", SeverityCritical, ctx.Severity) - } - }) +// Or with type checking +if id, ok := ewrap.GetMetadataValue[string](ec, "user_id"); !ok || id != "u-1" { + t.Errorf("user_id: got %q (ok=%v)", id, ok) } ``` -## Testing Error Groups +## Test loggers -Error groups require testing both individual operations and concurrent behavior: +Don't reach for a mocking framework — implement the three-method +interface inline. The test suite in this repo uses this pattern: ```go -func TestErrorGroup(t *testing.T) { - // Test pool creation and basic operations - t.Run("Basic Operations", func(t *testing.T) { - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Add some errors - eg.Add(ewrap.New("error 1")) - eg.Add(ewrap.New("error 2")) - - // Verify error count - if !eg.HasErrors() { - t.Error("Expected errors in group") - } +type recordingLogger struct { + mu sync.Mutex + logs []entry + calls map[string]int +} - // Verify error message format - errMsg := eg.Error() - if !strings.Contains(errMsg, "error 1") || !strings.Contains(errMsg, "error 2") { - t.Error("Error message doesn't contain all errors") - } - }) +type entry struct { + Level string + Msg string + Args []any +} - // Test concurrent operations - t.Run("Concurrent Operations", func(t *testing.T) { - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - var wg sync.WaitGroup - for i := 0; i < 100; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - eg.Add(ewrap.New(fmt.Sprintf("concurrent error %d", i))) - }(i) - } +func (l *recordingLogger) Error(msg string, kv ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.logs = append(l.logs, entry{"error", msg, kv}) + l.calls["error"]++ +} +// Debug, Info similarly - wg.Wait() +func TestSomethingLogsErrors(t *testing.T) { + l := &recordingLogger{calls: map[string]int{}} + err := callee(ewrap.WithLogger(l)) + err.Log() - // Verify all errors were captured - errs := eg.Errors() - if len(errs) != 100 { - t.Errorf("Expected 100 errors, got %d", len(errs)) - } - }) + if l.calls["error"] != 1 { + t.Errorf("expected 1 error log, got %d", l.calls["error"]) + } } ``` -## Testing Circuit Breakers +## Test observers -Circuit breakers require testing state transitions and timing behavior: +The breaker subpackage uses an analogous pattern: ```go -func TestCircuitBreaker(t *testing.T) { - t.Run("State Transitions", func(t *testing.T) { - cb := ewrap.NewCircuitBreaker("test", 3, time.Second) +type recordingObserver struct { + mu sync.Mutex + transitions []transition +} - // Should start closed - if !cb.CanExecute() { - t.Error("Circuit breaker should start in closed state") - } +func (o *recordingObserver) RecordTransition(name string, from, to breaker.State) { + o.mu.Lock() + defer o.mu.Unlock() + o.transitions = append(o.transitions, transition{name, from, to}) +} +``` - // Record failures until open - for i := 0; i < 3; i++ { - cb.RecordFailure() - } +Same shape works for `ewrap.Observer.RecordError`. - // Should now be open - if cb.CanExecute() { - t.Error("Circuit breaker should be open after failures") - } +## Concurrency tests - // Wait for timeout - time.Sleep(time.Second + 100*time.Millisecond) +Run race-detected concurrent stress tests on anything that holds shared +state. ewrap's own suite includes: - // Should be half-open - if !cb.CanExecute() { - t.Error("Circuit breaker should be half-open after timeout") - } +```go +func TestConcurrentMetadata(t *testing.T) { + t.Parallel() - // Record success to close - cb.RecordSuccess() + err := ewrap.New("test") - // Should be closed - if !cb.CanExecute() { - t.Error("Circuit breaker should be closed after success") - } - }) + var wg sync.WaitGroup + for i := range 100 { + wg.Go(func() { _ = err.WithMetadata(fmt.Sprintf("k%d", i), i) }) + wg.Go(func() { _, _ = err.GetMetadata(fmt.Sprintf("k%d", i)) }) + } + wg.Wait() } ``` -## Performance Testing +Run with `go test -race ./...` to catch real races. -Performance testing is crucial for error handling code: +## Deep-chain tests -```go -func BenchmarkErrorOperations(b *testing.B) { - // Benchmark error creation - b.Run("Creation", func(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = ewrap.New("test error") - } - }) +Verify your code survives long wrap chains — easy to construct: - // Benchmark error wrapping - b.Run("Wrapping", func(b *testing.B) { - baseErr := errors.New("base error") - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = ewrap.Wrap(baseErr, "wrapped error") - } - }) +```go +func TestDeepChain(t *testing.T) { + var err error = errors.New("root") + for i := range 200 { + err = ewrap.Wrap(err, fmt.Sprintf("layer-%d", i)) + } - // Benchmark error group operations - b.Run("ErrorGroup", func(b *testing.B) { - pool := ewrap.NewErrorGroupPool(4) - b.ReportAllocs() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - eg := pool.Get() - eg.Add(errors.New("test error")) - _ = eg.Error() - eg.Release() - } - }) - }) + if !errors.Is(err, errors.New("root")) { + // would fail because errors.New gives unique identity each call + } } ``` -## Integration Testing +(Use a sentinel `var` instead of inline `errors.New` for the assertion.) + +## Fuzz tests -Testing error handling in integration scenarios: +`(*Error).ToJSON` and `Newf` are good fuzz targets: ```go -func TestErrorIntegration(t *testing.T) { - // Create a test server - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Simulate error handling in an HTTP server - err := processRequest(r) - if err != nil { - // Convert error to API response - resp := formatErrorResponse(err) - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(resp) - return +func FuzzJSONRoundTrip(f *testing.F) { + for _, seed := range []string{"", "boom", strings.Repeat("a", 1024)} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, msg string) { + err := ewrap.New(msg) + s, jerr := err.ToJSON() + if jerr != nil { + t.Fatalf("ToJSON: %v", jerr) } - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - // Test error handling through the entire stack - t.Run("Integration", func(t *testing.T) { - resp, err := http.Get(srv.URL) - if err != nil { - t.Fatal(err) + var out ewrap.ErrorOutput + if err := json.Unmarshal([]byte(s), &out); err != nil { + t.Fatalf("invalid JSON: %v", err) } - defer resp.Body.Close() - - // Verify error response format - if resp.StatusCode != http.StatusInternalServerError { - t.Errorf("Expected status 500, got %d", resp.StatusCode) + if out.Message != msg { + t.Errorf("round-trip lost data: got %q, want %q", out.Message, msg) } + }) +} +``` - var errorResp map[string]any - if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil { - t.Fatal(err) - } +## `t.Parallel()` is safe - // Verify error response structure - requiredFields := []string{"message", "code", "timestamp"} - for _, field := range requiredFields { - if _, ok := errorResp[field]; !ok { - t.Errorf("Missing required field: %s", field) - } - } - }) +ewrap is goroutine-safe by design — every test in this repo runs with +`t.Parallel()`. Add it to your tests too unless they mutate global state +(e.g. `runtime.MemProfileRate`, env vars). + +## Testing the breaker + +Pin the timeout to something tiny so you don't wait around: + +```go +func TestBreakerOpens(t *testing.T) { + t.Parallel() + + cb := breaker.New("test", 1, 10*time.Millisecond) + + cb.RecordFailure() + if cb.State() != breaker.Open { + t.Errorf("State: got %v, want Open", cb.State()) + } + + time.Sleep(15 * time.Millisecond) + + if !cb.CanExecute() { + t.Error("expected breaker to allow execution after timeout") + } } ``` + +The transition observer fires synchronously, so you don't need to sleep +to wait for callbacks. + +## Test fixtures + +For shared sentinels and constants across a test suite, define them in a +`*_test.go` helper file: + +```go +// test_helpers_test.go +package mypkg + +import "errors" + +const ( + msgValidation = "invalid input" + msgNotFound = "user not found" +) + +var ( + errSentinel = errors.New("sentinel") + errOther = errors.New("other") +) +``` + +This pattern silences `goconst` and `err113` linters while keeping tests +readable. diff --git a/docs/docs/api/error-types.md b/docs/docs/api/error-types.md index 915e735..14f7663 100644 --- a/docs/docs/api/error-types.md +++ b/docs/docs/api/error-types.md @@ -1,244 +1,226 @@ # Error Types and Severity -Understanding error types and severity levels is fundamental to using ewrap effectively. This guide explains the built-in error categorization system and how to leverage it for better error handling. +ewrap ships small enums for classifying errors. Both have a `String()` +method whose output is what shows up in `ErrorOutput.Type`/`Severity`, +JSON, YAML, and `slog` records — pin yourself to these values rather than +re-stringifying. -## Error Types Explained - -Error types in ewrap are more than just labels - they represent distinct categories of failures that can occur in your application. Each type suggests different handling strategies and helps maintain consistency in how errors are processed throughout your system. - -### Built-in Error Types +## `ErrorType` ```go +type ErrorType int + const ( - ErrorTypeUnknown ErrorType = iota - ErrorTypeValidation - ErrorTypeNotFound - ErrorTypePermission - ErrorTypeDatabase - ErrorTypeNetwork - ErrorTypeConfiguration - ErrorTypeInternal - ErrorTypeExternal + ErrorTypeUnknown // -> "unknown" + ErrorTypeValidation // -> "validation" + ErrorTypeNotFound // -> "not_found" + ErrorTypePermission // -> "permission" + ErrorTypeDatabase // -> "database" + ErrorTypeNetwork // -> "network" + ErrorTypeConfiguration // -> "configuration" + ErrorTypeInternal // -> "internal" + ErrorTypeExternal // -> "external" ) ``` -Let's explore each error type and its intended use: +| Type | When to use | +| --- | --- | +| `Unknown` | Unclassified — default if you don't supply `WithContext`. | +| `Validation` | Caller-supplied input failed checks. Not retryable by default. | +| `NotFound` | Resource doesn't exist. Maps cleanly to HTTP 404. | +| `Permission` | Authentication / authorisation failures. Maps to 401/403. | +| `Database` | Storage layer failure (driver, connection pool, query). | +| `Network` | Connectivity, DNS, TLS handshake failures. | +| `Configuration` | Misconfiguration detected at startup or runtime. | +| `Internal` | Bug or invariant violation in your own code. | +| `External` | Third-party service failure beyond `Network` (e.g. rate-limit, 5xx). | -### ErrorTypeUnknown - -Used when an error doesn't clearly fit into other categories. While it's available as a fallback, you should try to use more specific error types when possible. +### Setting ```go -err := ewrap.New("unexpected error occurred", - ewrap.WithContext(ctx, ErrorTypeUnknown, SeverityError)) +ewrap.New("invalid email", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityWarning)) ``` -### ErrorTypeValidation - -For errors related to input validation, data formatting, or business rule violations. These errors typically indicate that the request or data being processed doesn't meet required criteria. +### Reading ```go -func validateUser(user User) error { - if user.Age < 18 { - return ewrap.New("user must be 18 or older", - ewrap.WithContext(ctx, ErrorTypeValidation, SeverityError)). - WithMetadata("provided_age", user.Age). - WithMetadata("minimum_age", 18) +if ec := err.GetErrorContext(); ec != nil { + switch ec.Type { + case ewrap.ErrorTypeValidation: + // 422 / 400 + case ewrap.ErrorTypeNotFound: + // 404 } - return nil } ``` -### ErrorTypeNotFound +You can also branch on the canonical string form (`ec.Type.String()`), +useful in dashboards or log filters. + +### Default retry semantics -Indicates that a requested resource doesn't exist. This is particularly useful in API endpoints and database operations. +The default `WithRetry` predicate (`defaultShouldRetry`) treats +`ErrorTypeValidation` as **non-retryable** and everything else as +retryable. Override with `WithRetryShould` for finer control. + +## `Severity` ```go -func getUser(ctx context.Context, userID string) (*User, error) { - user, err := db.FindUser(userID) - if err == sql.ErrNoRows { - return nil, ewrap.New("user not found", - ewrap.WithContext(ctx, ErrorTypeNotFound, SeverityWarning)). - WithMetadata("user_id", userID) - } - return user, err -} +type Severity int + +const ( + SeverityInfo // -> "info" + SeverityWarning // -> "warning" + SeverityError // -> "error" + SeverityCritical // -> "critical" +) ``` -### ErrorTypePermission +| Severity | Typical use | +| --- | --- | +| `Info` | Notable but not failed (e.g. degraded mode). | +| `Warning` | Recovered automatically; likely worth investigating. | +| `Error` | Operation failed; user-facing or actionable. | +| `Critical` | System-impacting; page someone. | -For authorization and authentication failures. These errors indicate that the operation failed due to insufficient permissions or invalid credentials. +### Setting severity ```go -func validateAccess(ctx context.Context, userID string, resource string) error { - if !hasPermission(userID, resource) { - return ewrap.New("access denied", - ewrap.WithContext(ctx, ErrorTypePermission, SeverityWarning)). - WithMetadata("user_id", userID). - WithMetadata("resource", resource). - WithMetadata("required_role", "admin") - } - return nil -} +ewrap.New("DB unreachable", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) ``` -### ErrorTypeDatabase - -Used for database-related errors, including connection issues, query failures, and transaction problems. +### Reading severity ```go -func saveUserData(ctx context.Context, user User) error { - if err := db.Insert(user); err != nil { - return ewrap.Wrap(err, "failed to save user data", - ewrap.WithContext(ctx, ErrorTypeDatabase, SeverityCritical)). - WithMetadata("table", "users"). - WithMetadata("operation", "insert") - } - return nil +if ec := err.GetErrorContext(); ec != nil && ec.Severity >= ewrap.SeverityError { + pageOncall(err) } ``` -### ErrorTypeNetwork - -For network-related failures, including API calls, service communication, and connectivity issues. +## `RecoverySuggestion` ```go -func callExternalAPI(ctx context.Context, endpoint string) error { - resp, err := http.Get(endpoint) - if err != nil { - return ewrap.Wrap(err, "API call failed", - ewrap.WithContext(ctx, ErrorTypeNetwork, SeverityError)). - WithMetadata("endpoint", endpoint). - WithMetadata("timeout_seconds", 30) - } - return nil +type RecoverySuggestion struct { + Message string `json:"message" yaml:"message"` + Actions []string `json:"actions" yaml:"actions"` + Documentation string `json:"documentation" yaml:"documentation"` } ``` -### ErrorTypeConfiguration - -Used when errors occur due to misconfiguration or invalid settings. +A typed payload describing what an operator should do about the error. +Attached via `WithRecoverySuggestion`, read via `(*Error).Recovery()`, +and emitted by `(*Error).Log` as `recovery_message`, `recovery_actions`, +`recovery_documentation` fields. ```go -func loadConfig(ctx context.Context, path string) (*Config, error) { - cfg, err := parseConfig(path) - if err != nil { - return nil, ewrap.Wrap(err, "invalid configuration", - ewrap.WithContext(ctx, ErrorTypeConfiguration, SeverityCritical)). - WithMetadata("config_path", path). - WithMetadata("invalid_fields", getInvalidFields(err)) - } - return cfg, nil -} +ewrap.New("payment provider timeout", + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Inspect provider's queue and retry after backoff.", + Actions: []string{"check status page", "retry with backoff"}, + Documentation: "https://runbooks.example.com/payments/timeout", + })) ``` -### ErrorTypeInternal - -For internal system errors that aren't caused by external factors or user input. +## `ErrorContext` ```go -func processData(ctx context.Context, data []byte) error { - if err := internalProcess(data); err != nil { - return ewrap.Wrap(err, "internal processing failed", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityCritical)). - WithMetadata("process_id", getCurrentProcessID()). - WithMetadata("memory_usage", getMemoryUsage()) - } - return nil +type ErrorContext struct { + Timestamp time.Time + Type ErrorType + Severity Severity + Operation string + Component string + RequestID string + User string + Environment string + Version string + File string + Line int + Data map[string]any } ``` -### ErrorTypeExternal +Built by the `WithContext(ctx, type, severity)` option, which reads +`request_id`, `user`, `operation`, and `component` out of the +`context.Context` if present. `File`/`Line` are captured automatically +via `runtime.Caller`. `Environment` falls back to `APP_ENV` or +`"development"`. -For errors originating from external services or systems. +You can also build one explicitly and pass it via the method form: ```go -func callPaymentProvider(ctx context.Context, payment Payment) error { - result, err := paymentProvider.Process(payment) - if err != nil { - return ewrap.Wrap(err, "payment processing failed", - ewrap.WithContext(ctx, ErrorTypeExternal, SeverityCritical)). - WithMetadata("provider", "stripe"). - WithMetadata("payment_id", payment.ID). - WithMetadata("status_code", result.StatusCode) - } - return nil -} +err.WithContext(&ewrap.ErrorContext{ + Type: ewrap.ErrorTypeNetwork, + Severity: ewrap.SeverityError, + RequestID: "req-123", + Component: "billing", +}) ``` -## Severity Levels - -Severity levels help indicate the impact and urgency of an error. ewrap provides four severity levels: +## `RetryInfo` ```go -const ( - SeverityInfo Severity = iota - SeverityWarning - SeverityError - SeverityCritical -) +type RetryInfo struct { + MaxAttempts int + CurrentAttempt int + Delay time.Duration + LastAttempt time.Time + ShouldRetry func(error) bool +} ``` -### Using Severity Levels Effectively - -The choice of severity level should reflect the impact of the error on your system: +Attached via `WithRetry`, read via `(*Error).Retry()`, driven via +`(*Error).CanRetry()` and `(*Error).IncrementRetry()`. -### SeverityInfo +The `ShouldRetry` predicate is consulted by `CanRetry` along with +`CurrentAttempt < MaxAttempts`. The default is +`defaultShouldRetry` (refuses validation errors); override with +`WithRetryShould`. -For informational messages that don't indicate a problem but might be useful for debugging or monitoring. +## `StackFrame` ```go -func auditAction(ctx context.Context, action string) error { - return ewrap.New("action audited", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityInfo)). - WithMetadata("action", action). - WithMetadata("timestamp", time.Now()) +type StackFrame struct { + Function string `json:"function" yaml:"function"` + File string `json:"file" yaml:"file"` + Line int `json:"line" yaml:"line"` + PC uintptr `json:"pc" yaml:"pc"` } ``` -### SeverityWarning +A single decoded stack frame. Returned in slices by +`(*Error).GetStackFrames()` and via `*StackIterator` from +`GetStackIterator()`. -For issues that don't prevent the system from functioning but require attention. +## `StackTrace` ```go -func checkDiskSpace(ctx context.Context) error { - if usage := getDiskUsage(); usage > 80 { - return ewrap.New("high disk usage detected", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityWarning)). - WithMetadata("usage_percentage", usage). - WithMetadata("threshold", 80) - } - return nil -} +type StackTrace []StackFrame ``` -### SeverityError +A slice alias, mostly used for documentation purposes. -For significant issues that prevent a specific operation from completing successfully. +## `ErrorOutput` -```go -func processOrder(ctx context.Context, order Order) error { - if err := validateOrder(order); err != nil { - return ewrap.Wrap(err, "order validation failed", - ewrap.WithContext(ctx, ErrorTypeValidation, SeverityError)). - WithMetadata("order_id", order.ID) - } - return nil -} -``` +The schema for `(*Error).ToJSON` / `ToYAML`. See +[Serialization](../features/serialization.md) for the full layout. -### SeverityCritical +## `SerializableError` and `ErrorGroupSerialization` -For severe issues that might affect system stability or require immediate attention. +The schemas for `(*ErrorGroup).ToJSON` / `ToYAML`. Same source. + +## `breaker.State` ```go -func initializeDatabase(ctx context.Context) error { - if err := db.Connect(); err != nil { - return ewrap.Wrap(err, "database initialization failed", - ewrap.WithContext(ctx, ErrorTypeDatabase, SeverityCritical)). - WithMetadata("retry_count", 3). - WithMetadata("last_error", err.Error()) - } - return nil -} +type State int + +const ( + Closed // "closed" + Open // "open" + HalfOpen // "half-open" +) ``` + +See [Circuit Breaker](../features/circuit-breaker.md) for behaviour. diff --git a/docs/docs/api/interfaces.md b/docs/docs/api/interfaces.md index 2d30b9e..1fa7bf7 100644 --- a/docs/docs/api/interfaces.md +++ b/docs/docs/api/interfaces.md @@ -1,12 +1,9 @@ # Interfaces -The ewrap package is built around several key interfaces that provide flexibility and extensibility. Understanding these interfaces is crucial for effectively integrating ewrap into your application and extending its functionality to meet your specific needs. +ewrap exposes only two interfaces in the root package, both deliberately +small. The breaker subpackage adds one more. -## Core Interfaces - -### Logger Interface - -The Logger interface is fundamental to ewrap's logging capabilities. It provides a standardized way to log error information at different severity levels: +## `Logger` ```go type Logger interface { @@ -16,254 +13,89 @@ type Logger interface { } ``` -This interface is intentionally simple yet powerful. Let's explore how to implement and use it effectively: - -```go -// Example implementation using standard log package -type StandardLogger struct { - logger *log.Logger -} - -func (l *StandardLogger) Error(msg string, keysAndValues ...any) { - l.logger.Printf("ERROR: %s %v", msg, formatKeyValues(keysAndValues...)) -} - -func (l *StandardLogger) Debug(msg string, keysAndValues ...any) { - l.logger.Printf("DEBUG: %s %v", msg, formatKeyValues(keysAndValues...)) -} - -func (l *StandardLogger) Info(msg string, keysAndValues ...any) { - l.logger.Printf("INFO: %s %v", msg, formatKeyValues(keysAndValues...)) -} - -// Helper function to format key-value pairs -func formatKeyValues(keysAndValues ...any) string { - var pairs []string - for i := 0; i < len(keysAndValues); i += 2 { - if i+1 < len(keysAndValues) { - pairs = append(pairs, fmt.Sprintf("%v=%v", - keysAndValues[i], keysAndValues[i+1])) - } - } - return strings.Join(pairs, " ") -} -``` - -The Logger interface is designed to: +The contract: -- Support structured logging through key-value pairs -- Provide different log levels for appropriate error handling -- Be easily implemented by existing logging frameworks -- Allow for context-aware logging +- `keysAndValues` is the standard alternating key/value convention. +- Implementations must be **goroutine-safe**. `(*Error).Log` calls them + synchronously from the calling goroutine, but multiple goroutines can + hold a reference to the same logger via `WithLogger`. +- Returning errors is intentionally not part of the interface; logger + failures are typically fatal-or-ignore decisions. -### Error Interface +Used via `WithLogger(logger)`. See [Logging](../features/logging.md) for +adapter patterns. -While ewrap's Error type implements Go's standard error interface, it extends it with additional capabilities: +## `Observer` ```go -type error interface { - Error() string -} - -// ewrap.Error implements these additional methods: -type Error struct { - // Cause returns the underlying cause of the error - Cause() error - - // Stack returns the error's stack trace - Stack() string - - // GetMetadata retrieves metadata associated with the error - GetMetadata(key string) (any, bool) - - // WithMetadata adds metadata to the error - WithMetadata(key string, value any) *Error - - // Is reports whether target matches err in the error chain - Is(target error) bool - - // Unwrap provides compatibility with Go 1.13 error chains - Unwrap() error +type Observer interface { + RecordError(message string) } ``` -Understanding these interfaces helps when working with errors: +Single method. `(*Error).Log` calls it synchronously before writing the +structured log record. Implementations must be goroutine-safe. -```go -func processError(err error) { - // Type assert to access ewrap.Error functionality - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Access stack trace - fmt.Printf("Stack Trace:\n%s\n", wrappedErr.Stack()) +The interface is deliberately minimal — anything richer would mean ewrap +dictating a particular metric / tracing API. If you want the full +structured payload, attach a `Logger` instead. - // Access metadata - if requestID, ok := wrappedErr.GetMetadata("request_id"); ok { - fmt.Printf("Request ID: %v\n", requestID) - } +Used via `WithObserver(obs)`. See [Observability](../features/observability.md). - // Access cause chain - for cause := wrappedErr; cause != nil; cause = cause.Unwrap() { - fmt.Printf("Error: %s\n", cause.Error()) - } - } -} -``` - -## Interface Integration - -### Implementing Custom Loggers - -Creating custom loggers for different environments or logging systems: +## `breaker.Observer` ```go -// Production logger with structured output -type ProductionLogger struct { - output io.Writer -} - -func (l *ProductionLogger) Error(msg string, keysAndValues ...any) { - entry := LogEntry{ - Level: "ERROR", - Message: msg, - Timestamp: time.Now().UTC(), - Data: makeMap(keysAndValues...), - } - json.NewEncoder(l.output).Encode(entry) -} +package breaker -// Test logger for capturing logs in tests -type TestLogger struct { - Logs []LogEntry - mu sync.Mutex -} - -func (l *TestLogger) Error(msg string, keysAndValues ...any) { - l.mu.Lock() - defer l.mu.Unlock() - - l.Logs = append(l.Logs, LogEntry{ - Level: "ERROR", - Message: msg, - Timestamp: time.Now(), - Data: makeMap(keysAndValues...), - }) +type Observer interface { + RecordTransition(name string, from, to State) } ``` -### Interface Composition - -Combining interfaces for enhanced functionality: - -```go -// MetricsLogger combines logging with metrics collection -type MetricsLogger struct { - logger Logger - metrics MetricsCollector -} - -func (l *MetricsLogger) Error(msg string, keysAndValues ...any) { - // Log the error - l.logger.Error(msg, keysAndValues...) - - // Collect metrics - l.metrics.IncrementCounter("errors_total", 1) - l.metrics.Record("error_occurred", keysAndValues...) -} -``` - -## Best Practices - -### Logger Implementation - -When implementing the Logger interface, consider these guidelines: - -```go -// Structured logger with configurable output -type StructuredLogger struct { - output io.Writer - minLevel LogLevel - formatter LogFormatter - mu sync.Mutex -} +Receives circuit-breaker transitions. Fired synchronously after the +breaker lock is released, so observer code runs without holding the +breaker mutex. Implementations must not invoke the breaker recursively. -func (l *StructuredLogger) Error(msg string, keysAndValues ...any) { - if l.minLevel <= ErrorLevel { - l.log(ErrorLevel, msg, keysAndValues...) - } -} +Used via `breaker.NewWithObserver(...)` or `(*Breaker).SetObserver(obs)`. +See [Circuit Breaker](../features/circuit-breaker.md). -func (l *StructuredLogger) log(level LogLevel, msg string, keysAndValues ...any) { - l.mu.Lock() - defer l.mu.Unlock() +## Optional interfaces ewrap consumes - entry := l.formatter.Format(level, msg, keysAndValues...) - l.output.Write(entry) -} -``` +ewrap also recognises a few well-known interfaces from the stdlib and +ecosystem. -### Interface Testing +### `interface{ Unwrap() error }` -Writing tests for interface implementations: +Standard error chain walker (Go 1.13+). `(*Error)` implements it; ewrap +helpers that walk chains (`HTTPStatus`, `IsRetryable`, serializer) call +`errors.Unwrap` so they cross both `*Error` and `fmt.Errorf("...:%w", ...)` +boundaries. -```go -func TestLogger(t *testing.T) { - // Create a buffer to capture log output - var buf bytes.Buffer - logger := NewStructuredLogger(&buf) +### `interface{ Unwrap() []error }` - // Test error logging - logger.Error("test error", - "key1", "value1", - "key2", 42) +Multi-cause variant introduced in Go 1.20 for `errors.Join`. ewrap's +`Newf` recognises it when extracting the cause from a `%w` format +containing multiple wrapped values; it stores the first cause as the +single `cause` field. `(*Error)` itself does not implement this — for +genuine multi-cause aggregation use `ErrorGroup`. - // Verify log output - output := buf.String() - if !strings.Contains(output, "test error") { - t.Error("Log message not found in output") - } +### `interface{ Temporary() bool }` - // Verify structured data - var logEntry LogEntry - if err := json.NewDecoder(&buf).Decode(&logEntry); err != nil { - t.Fatal(err) - } +Stdlib transient-error marker (`net.Error`, `*net.OpError`, etc.). +`ewrap.IsRetryable(err)` falls through to this when no ewrap layer set +`WithRetryable`. - if logEntry.Level != "ERROR" { - t.Errorf("Expected level ERROR, got %s", logEntry.Level) - } -} -``` +### `interface{ SafeError() string }` -### Interface Extensions +Implemented by `(*Error)`. `(*Error).SafeError()` walks the chain and +defers to a cause's `SafeError` method when present, so PII redaction +composes cleanly. -Creating specialized interfaces for specific use cases: +### `fmt.Formatter` -```go -// ContextualLogger adds context awareness to the basic Logger interface -type ContextualLogger interface { - Logger - WithContext(ctx context.Context) Logger - WithFields(fields map[string]any) Logger -} +Implemented by `(*Error)`. Supports `%s`, `%v`, `%q`, and `%+v`. -// Implementation example -type contextualLogger struct { - Logger - ctx context.Context - fields map[string]any -} +### `slog.LogValuer` -func (l *contextualLogger) WithContext(ctx context.Context) Logger { - return &contextualLogger{ - Logger: l.Logger, - ctx: ctx, - fields: l.fields, - } -} - -func (l *contextualLogger) Error(msg string, keysAndValues ...any) { - // Combine context values, fields, and provided key-values - allKeyValues := l.mergeContextAndFields(keysAndValues...) - l.Logger.Error(msg, allKeyValues...) -} -``` +Implemented by `(*Error)`. Returns an `slog.GroupValue` containing +message, type, severity, request_id, cause, recovery, and metadata. diff --git a/docs/docs/api/options.md b/docs/docs/api/options.md index b7ef55f..8b209df 100644 --- a/docs/docs/api/options.md +++ b/docs/docs/api/options.md @@ -1,219 +1,183 @@ # Options -Options in ewrap provide a flexible way to configure error behavior. Using the functional options pattern, you can customize how errors are created, logged, and handled while maintaining clean and extensible code. +`type Option func(*Error)` — variadic configuration for `New` and +`Wrap`. Options run during construction; after `New`/`Wrap` returns the +`*Error` is effectively immutable except for chained `WithMetadata` / +`WithContext` calls and `IncrementRetry`. -## Understanding Options +This page is the canonical reference for every option exported from the +root package. -Options are functions that modify error behavior. They follow Go's functional options pattern, which allows for flexible and readable configuration. Each option function takes an error pointer and modifies its properties: +## `WithLogger(log Logger) Option` + +Attach a `Logger` consulted by `(*Error).Log`. Inherited by `Wrap` when +the inner error is a `*Error`. ```go -type Option func(*Error) +err := ewrap.New("boom", ewrap.WithLogger(logger)) +err.Log() // calls logger.Error("error occurred", ...kv) ``` -## Built-in Options +Setting `WithLogger(nil)` is allowed and silently no-ops the logger +(typical pattern: pass nil in unit tests). -### WithContext +## `WithObserver(obs Observer) Option` -The `WithContext` option enriches errors with contextual information, including error type, severity, and relevant request data: +Attach an `Observer` whose `RecordError(msg string)` is called from +`(*Error).Log`. Inherited by `Wrap` when the inner error is a `*Error`. ```go -func processUser(ctx context.Context, userID string) error { - err := ewrap.New("user processing failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)) - - // The error now includes: - // - Error type and severity - // - Stack trace location - // - Request ID (if present in context) - // - User information (if present in context) - // - Operation name (if present in context) - // - Component name (if present in context) - // - Environment information - return err -} +err := ewrap.New("boom", ewrap.WithObserver(metrics)) +err.Log() // metrics.RecordError("boom") ``` -The context option automatically extracts common values from the provided context: +## `WithStackDepth(depth int) Option` + +Override the default stack capture depth (32). Pass `0` to disable +capture entirely. -- `request_id` for request tracing -- `user` for user identification -- `operation` for operation naming -- `component` for system component identification +```go +ewrap.New("boom", ewrap.WithStackDepth(8)) // shallower +ewrap.New("boom", ewrap.WithStackDepth(0)) // no stack +ewrap.New("boom", ewrap.WithStackDepth(128)) // deeper +``` -### WithLogger +## `WithContext(ctx context.Context, type ErrorType, sev Severity) Option` -The `WithLogger` option attaches a logger to the error, enabling automatic logging of error events: +Build an `ErrorContext` from the supplied `context.Context` and attach +it. The option reads `request_id`, `user`, `operation`, and `component` +keys out of `ctx` if present. The resulting `ErrorContext` includes the +file/line of the calling `New`/`Wrap` (via `runtime.Caller`). ```go -// Create a logger (implementing the Logger interface) -logger := NewZapLogger() - -// Attach logger to error -err := ewrap.New("database connection failed", - ewrap.WithLogger(logger), - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) - -// The error will automatically log: -// - Error creation -// - Context addition -// - Metadata changes -// - Stack trace information +err := ewrap.New("boom", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) ``` -### WithRetry - -The `WithRetry` option configures retry behavior for recoverable errors: +For attaching a pre-built `ErrorContext`, use the method form: ```go -err := ewrap.New("temporary network failure", - ewrap.WithContext(ctx, ewrap.ErrorTypeNetwork, ewrap.SeverityError), - ewrap.WithRetry(3, time.Second*5)) - -// The error now includes retry information: -// - Maximum retry attempts (3) -// - Delay between attempts (5 seconds) -// - Retry strategy configuration +err.WithContext(&ewrap.ErrorContext{Type: ewrap.ErrorTypeNetwork}) ``` -## Combining Options +## `WithRecoverySuggestion(rs *RecoverySuggestion) Option` -Options can be combined to create rich error configurations: +Attach actionable recovery guidance. Read back via `(*Error).Recovery()`, +emitted as `recovery_message`, `recovery_actions`, and +`recovery_documentation` fields by `Log`. ```go -func processOrder(ctx context.Context, orderID string) error { - // Create an error with multiple options - return ewrap.New("order processing failed", - // Add context information - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError), - // Attach logger - ewrap.WithLogger(logger), - // Configure retry behavior - ewrap.WithRetry(3, time.Second*5)) -} +ewrap.New("DB unreachable", + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Verify pool sizing and credentials.", + Actions: []string{"reset pool", "rotate creds"}, + Documentation: "https://runbooks.example.com/db", + })) ``` -## Creating Custom Options +## `WithRetry(maxAttempts int, delay time.Duration, opts ...RetryOption) Option` -You can create custom options to extend error functionality: +Attach a retry **policy** (max attempts, delay, predicate). Use with +`(*Error).CanRetry()` and `(*Error).IncrementRetry()` to drive a retry +loop, or just inspect `(*Error).Retry()` for the raw `*RetryInfo`. ```go -// WithCorrelationID adds a correlation ID to the error -func WithCorrelationID(correlationID string) ewrap.Option { - return func(err *ewrap.Error) { - err.WithMetadata("correlation_id", correlationID) - } -} +err := ewrap.New("upstream timeout", ewrap.WithRetry(3, 5*time.Second)) -// WithResource adds resource information to the error -func WithResource(resourceType, resourceID string) ewrap.Option { - return func(err *ewrap.Error) { - err.WithMetadata("resource_type", resourceType) - err.WithMetadata("resource_id", resourceID) +for err.CanRetry() { + if doErr := upstream(); doErr == nil { + break } + err.IncrementRetry() + time.Sleep(err.Retry().Delay) } +``` + +### `WithRetryShould(fn func(error) bool) RetryOption` -// Usage example -err := ewrap.New("resource access failed", - ewrap.WithContext(ctx, ewrap.ErrorTypePermission, ewrap.SeverityError), - WithCorrelationID("corr-123"), - WithResource("document", "doc-456")) +Customise the predicate consulted by `CanRetry`: + +```go +ewrap.WithRetry(5, 2*time.Second, + ewrap.WithRetryShould(func(e error) bool { return ewrap.IsRetryable(e) })) ``` -## Best Practices +The default predicate returns `true` unless `ErrorContext.Type` is +`ErrorTypeValidation`. -### Option Organization +## `WithHTTPStatus(status int) Option` -Group related options together for better readability: +Tag the error with an HTTP status code. Use `net/http` constants for +clarity. `ewrap.HTTPStatus(err)` walks the chain and returns the first +non-zero status. ```go -// Configuration options -configOpts := []ewrap.Option{ - ewrap.WithContext(ctx, errorType, severity), - ewrap.WithLogger(logger), -} +ewrap.New("upstream 502", + ewrap.WithHTTPStatus(http.StatusBadGateway)) +``` -// Retry options -retryOpts := []ewrap.Option{ - ewrap.WithRetry(maxAttempts, delay), -} +## `WithRetryable(retryable bool) Option` -// Combine all options -allOpts := append(configOpts, retryOpts...) +Three-state retry classification (unset / true / false). Read with +`(*Error).Retryable() (value, set bool)` or `ewrap.IsRetryable(err)`. -// Create error with combined options -err := ewrap.New("operation failed", allOpts...) +```go +ewrap.New("rate limited", ewrap.WithRetryable(true)) +ewrap.New("invalid credentials", ewrap.WithRetryable(false)) ``` -### Option Factories +`IsRetryable` falls through to the stdlib `interface{ Temporary() bool }` +when no ewrap layer set the flag, so `net.OpError` and similar work +out of the box. -Create factory functions for commonly used option combinations: +## `WithSafeMessage(safe string) Option` + +Attach a redacted variant returned by `(*Error).SafeError()`. Each layer +contributes either its safe message (if set) or its raw `msg`; standard +wrapped errors without a `SafeError` method are included verbatim. ```go -// CreateHTTPErrorOptions creates standard options for HTTP handlers -func CreateHTTPErrorOptions(ctx context.Context, logger Logger) []ewrap.Option { - return []ewrap.Option{ - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError), - ewrap.WithLogger(logger), - WithCorrelationID(GetRequestID(ctx)), - } -} +ewrap.New("user 'alice@example.com' rejected", + ewrap.WithSafeMessage("user [redacted] rejected")) +``` -// Usage in HTTP handlers -func handleRequest(w http.ResponseWriter, r *http.Request) { - opts := CreateHTTPErrorOptions(r.Context(), logger) +## Inheritance through `Wrap` - if err := processRequest(r); err != nil { - err = ewrap.Wrap(err, "request processing failed", opts...) - handleError(w, err) - return - } -} +When the inner error is a `*Error`, `Wrap` inherits **all** option-set +state on the inner: logger, observer, stack-depth-derived stack, error +context, recovery suggestion, retry info, HTTP status, retryable flag, +and a clone of the metadata map. + +Any option passed to `Wrap` overrides the inherited value: + +```go +inner := ewrap.New("boom", ewrap.WithHTTPStatus(http.StatusBadGateway)) +outer := ewrap.Wrap(inner, "in handler", + ewrap.WithHTTPStatus(http.StatusInternalServerError)) + +ewrap.HTTPStatus(outer) // 500 — outer wins +ewrap.HTTPStatus(inner) // 502 — inner unchanged ``` -### Option Validation +## RetryOption -When creating custom options, include validation logic: +A separate option type for `WithRetry`'s sub-options: ```go -// WithTimeout adds a timeout duration to the error -func WithTimeout(duration time.Duration) ewrap.Option { - return func(err *ewrap.Error) { - // Validate input - if duration <= 0 { - duration = time.Second * 30 // Default timeout - } - - err.WithMetadata("timeout", duration.String()) - err.WithMetadata("deadline", time.Now().Add(duration)) - } -} +type RetryOption func(*RetryInfo) + +func WithRetryShould(fn func(error) bool) RetryOption ``` -### Dynamic Options +## FormatOption -Create options that adapt based on conditions: +`(*Error).ToJSON` and `(*Error).ToYAML` accept their own option type: ```go -// WithEnvironmentAwareLogging adjusts logging based on environment -func WithEnvironmentAwareLogging(logger Logger) ewrap.Option { - return func(err *ewrap.Error) { - env := os.Getenv("APP_ENV") - - switch env { - case "production": - // Use production logger settings - err.WithMetadata("log_level", "error") - err.WithMetadata("include_stack", true) - case "development": - // Use development logger settings - err.WithMetadata("log_level", "debug") - err.WithMetadata("include_stack", true) - default: - // Use default settings - err.WithMetadata("log_level", "info") - } - - err.SetLogger(logger) - } -} +type FormatOption func(*ErrorOutput) + +func WithTimestampFormat(format string) FormatOption +func WithStackTrace(include bool) FormatOption ``` + +See [Serialization](../features/serialization.md). diff --git a/docs/docs/api/overview.md b/docs/docs/api/overview.md index e069172..06e0b92 100644 --- a/docs/docs/api/overview.md +++ b/docs/docs/api/overview.md @@ -1,287 +1,174 @@ # Package Overview -The ewrap package provides a sophisticated error handling solution for Go applications, combining modern error handling patterns with performance optimizations and developer-friendly features. This overview explains how the package's components work together to create a comprehensive error handling system. +A condensed reference of the public surface of `github.com/hyp3rd/ewrap` +and its subpackages. For longer prose see [Features](../features/error-creation.md). -## Core Philosophy +## Module layout -ewrap is built on several key principles: - -1. **Rich Context**: Errors should carry enough information to understand what went wrong, where, and why -1. **Performance**: Error handling shouldn't become a bottleneck in your application -1. **Developer Experience**: Clear, consistent patterns that make error handling both powerful and approachable -1. **Flexibility**: Easy integration with existing systems and adaptable to different needs -1. **Production Readiness**: Built-in support for logging, monitoring, and debugging +```text +github.com/hyp3rd/ewrap // root package — error type, options, formatting +github.com/hyp3rd/ewrap/breaker // circuit breaker (independent) +github.com/hyp3rd/ewrap/slog // slog adapter +``` -## Component Architecture +## Constructors -### Error Types and Context +```go +func New(msg string, opts ...Option) *Error +func NewSkip(skip int, msg string, opts ...Option) *Error +func Newf(format string, args ...any) *Error // honours %w +func Wrap(err error, msg string, opts ...Option) *Error // nil-safe +func WrapSkip(skip int, err error, msg string, opts ...Option) *Error +func Wrapf(err error, format string, args ...any) *Error // nil-safe +``` -At the heart of ewrap is the Error type, which provides the foundation for all error handling: +## `*Error` methods ```go -// Core error structure -type Error struct { - msg string - cause error - stack []uintptr - metadata map[string]any - logger Logger - mu sync.RWMutex -} - -// Error context structure -type ErrorContext struct { - Timestamp time.Time - Type ErrorType - Severity Severity - Operation string - Component string - RequestID string - User string - Environment string - Version string - File string - Line int - Data map[string]any -} +// stdlib interfaces +func (e *Error) Error() string // cached +func (e *Error) Unwrap() error +func (e *Error) Format(state fmt.State, verb rune) // %s %v %q %+v +func (e *Error) LogValue() slog.Value // structured slog + +// inspection +func (e *Error) Cause() error +func (e *Error) Stack() string // cached +func (e *Error) GetStackIterator() *StackIterator +func (e *Error) GetStackFrames() []StackFrame +func (e *Error) GetErrorContext() *ErrorContext +func (e *Error) Recovery() *RecoverySuggestion +func (e *Error) Retry() *RetryInfo +func (e *Error) Retryable() (value, set bool) +func (e *Error) SafeError() string + +// metadata +func (e *Error) WithMetadata(key string, value any) *Error +func (e *Error) WithContext(ctx *ErrorContext) *Error +func (e *Error) GetMetadata(key string) (any, bool) +func GetMetadataValue[T any](e *Error, key string) (T, bool) + +// retry control +func (e *Error) CanRetry() bool +func (e *Error) IncrementRetry() + +// logging +func (e *Error) Log() ``` -This structure allows errors to carry: +## Options (`type Option func(*Error)`) -- Stack traces for debugging -- Metadata for context -- Logging configuration -- Error categorization -- Severity levels -- Operation tracking +| Option | Purpose | +| --- | --- | +| `WithLogger(Logger)` | Attach a structured logger consulted by `(*Error).Log` | +| `WithObserver(Observer)` | Attach an observer that's called from `(*Error).Log` | +| `WithStackDepth(n int)` | Override stack capture depth (0 disables) | +| `WithContext(ctx, type, severity)` | Build an `ErrorContext` from `context.Context` | +| `WithRecoverySuggestion(*RecoverySuggestion)` | Attach recovery guidance | +| `WithRetry(maxAttempts, delay, opts...)` | Attach a retry policy | +| `WithRetryShould(func(error) bool)` | Customise the retry predicate (passed to `WithRetry`) | +| `WithHTTPStatus(int)` | Tag with an HTTP status code | +| `WithRetryable(bool)` | Mark as retryable / permanent (tri-state via pointer) | +| `WithSafeMessage(string)` | Attach a redacted variant returned by `SafeError` | -### Memory Management - -The package includes sophisticated memory management through pooling: +## Top-level helpers ```go -// Error group pooling -pool := ewrap.NewErrorGroupPool(4) -eg := pool.Get() -defer eg.Release() - -// Usage in high-throughput scenarios -for item := range items { - if err := processItem(item); err != nil { - eg.Add(err) - } -} +func HTTPStatus(err error) int // walks chain; 0 if unset +func IsRetryable(err error) bool // chain + stdlib Temporary() fallback +func CaptureStack() []uintptr // raw PC slice at the call site +func GetMetadataValue[T any](e *Error, key string) (T, bool) ``` -This system helps reduce garbage collection pressure in applications that handle many errors. +## Types -### Logging Integration +```go +type Error struct{ /* unexported */ } // implements error, fmt.Formatter, slog.LogValuer +type ErrorContext struct{ Type ErrorType; Severity Severity; ... } +type RecoverySuggestion struct{ Message string; Actions []string; Documentation string } +type RetryInfo struct{ MaxAttempts, CurrentAttempt int; Delay time.Duration; ... } +type StackFrame struct{ Function, File string; Line int; PC uintptr } +type StackTrace []StackFrame +type StackIterator struct{ /* unexported */ } +type ErrorOutput struct{ /* JSON/YAML output schema */ } +type ErrorGroup struct{ /* aggregator */ } +type ErrorGroupPool struct{ /* pool */ } +type SerializableError struct{ /* group serialization */ } +type ErrorGroupSerialization struct{ /* group envelope */ } +``` -The logging system is designed for flexibility and integration with existing logging frameworks: +## Interfaces ```go -// Logger interface type Logger interface { Error(msg string, keysAndValues ...any) Debug(msg string, keysAndValues ...any) Info(msg string, keysAndValues ...any) } -``` - -Built-in adapters support popular logging frameworks while allowing custom implementations. -### Circuit Breaker Pattern - -The circuit breaker implementation provides protection against cascading failures: - -```go -type CircuitBreaker struct { - name string - maxFailures int - timeout time.Duration - failureCount int - lastFailure time.Time - state CircuitState - mu sync.RWMutex - onStateChange func(name string, from, to CircuitState) +type Observer interface { + RecordError(message string) } ``` -This helps build resilient systems that can gracefully handle service degradation. - -## Feature Integration - -### Error Creation and Wrapping - -The package provides a fluent API for error creation and wrapping: +## Enums ```go -// Error creation -err := ewrap.New("operation failed", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityError), - ewrap.WithLogger(logger)) - -// Error wrapping -if err != nil { - return ewrap.Wrap(err, "processing failed", - ewrap.WithContext(ctx, ErrorTypeInternal, SeverityError)) -} +type ErrorType int +const ( + ErrorTypeUnknown ErrorType = iota + ErrorTypeValidation + ErrorTypeNotFound + ErrorTypePermission + ErrorTypeDatabase + ErrorTypeNetwork + ErrorTypeConfiguration + ErrorTypeInternal + ErrorTypeExternal +) + +type Severity int +const ( + SeverityInfo Severity = iota + SeverityWarning + SeverityError + SeverityCritical +) ``` -### Error Groups and Concurrency - -Error groups handle concurrent error collection with built-in synchronization: +## Subpackage: `ewrap/breaker` ```go -func processItems(items []Item) error { - pool := ewrap.NewErrorGroupPool(len(items)) - eg := pool.Get() - defer eg.Release() - - var wg sync.WaitGroup - for _, item := range items { - wg.Add(1) - go func(item Item) { - defer wg.Done() - if err := processItem(item); err != nil { - eg.Add(err) - } - }(item) - } - wg.Wait() - - return eg.Error() +type State int // Closed, Open, HalfOpen; String() supported +type Breaker struct{ /* ... */ } +type Observer interface { + RecordTransition(name string, from, to State) } -``` - -## Performance Considerations - -The package includes several optimizations: - -1. **Memory Pooling**: Reduces allocation overhead -1. **Lock-Free Operations**: Where possible, for better concurrency -1. **Efficient Stack Traces**: Captures only necessary frames -1. **Lazy Formatting**: Defers expensive string operations -## Best Practices +func New(name string, maxFailures int, timeout time.Duration) *Breaker +func NewWithObserver(name string, maxFailures int, timeout time.Duration, obs Observer) *Breaker -To get the most out of ewrap: - -1. **Use Error Types Consistently**: Choose appropriate error types for better error handling: - - ```go - ErrorTypeValidation // For input validation - ErrorTypeDatabase // For database operations - ErrorTypeNetwork // For network operations - ``` - -1. **Leverage Context**: Add relevant context to errors: - - ```go - ewrap.WithContext(ctx, errorType, severity) - ``` - -1. **Implement Proper Logging**: Use structured logging for better debugging: - - ```go - ewrap.WithLogger(logger) - ``` - -1. **Use Error Groups Efficiently**: Pool error groups for better performance: - - ```go - pool := ewrap.NewErrorGroupPool(size) - ``` - -1. **Handle Circuit Breaking**: Protect your system from cascading failures: - - ```go - breaker := ewrap.NewCircuitBreaker(name, maxFailures, timeout) - ``` +func (cb *Breaker) Name() string +func (cb *Breaker) State() State +func (cb *Breaker) CanExecute() bool +func (cb *Breaker) RecordFailure() +func (cb *Breaker) RecordSuccess() +func (cb *Breaker) OnStateChange(callback func(name string, from, to State)) +func (cb *Breaker) SetObserver(obs Observer) +``` -## Common Patterns +See [Circuit Breaker](../features/circuit-breaker.md). -### Request Handling +## Subpackage: `ewrap/slog` ```go -func handleRequest(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Get error group from pool - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Process request - if err := processRequest(ctx, r); err != nil { - eg.Add(ewrap.Wrap(err, "request processing failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError))) - } - - // Handle any errors - if err := eg.Error(); err != nil { - handleError(w, err) - return - } - - // Send success response - respondSuccess(w) -} -``` +type Adapter struct{ /* unexported */ } -### Service Integration +func New(logger *slog.Logger) *Adapter -```go -type Service struct { - breaker *ewrap.CircuitBreaker - logger Logger -} - -func (s *Service) CallExternalService(ctx context.Context) error { - if !s.breaker.CanExecute() { - return ewrap.New("service unavailable", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityCritical), - ewrap.WithLogger(s.logger)) - } - - if err := makeExternalCall(); err != nil { - s.breaker.RecordFailure() - return ewrap.Wrap(err, "external call failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError)) - } - - s.breaker.RecordSuccess() - return nil -} +func (a *Adapter) Error(msg string, keysAndValues ...any) +func (a *Adapter) Debug(msg string, keysAndValues ...any) +func (a *Adapter) Info(msg string, keysAndValues ...any) ``` -## Integration Points - -ewrap is designed to integrate with: - -- Logging frameworks (zap, logrus, zerolog) -- Monitoring systems -- Tracing solutions -- HTTP middleware -- Database layers -- External services - -## Version Compatibility - -The package is compatible with: - -- Go 1.13+ error wrapping -- Standard library contexts -- Common logging frameworks -- Standard HTTP packages -- Database/SQL interfaces - -## Further Reading - -For more detailed information about specific features, refer to: - -- [Error Types Documentation](error-types.md) -- [Circuit Breaker Documentation](../features/circuit-breaker.md) -- [Error Groups Documentation](../features/error-groups.md) -- [Logging Integration](../features/logging.md) -- [Context Integration](../advanced/context.md) +See [`ewrap/slog`](../features/slog-adapter.md). diff --git a/docs/docs/examples/advanced.md b/docs/docs/examples/advanced.md index 611920e..1c3dbc9 100644 --- a/docs/docs/examples/advanced.md +++ b/docs/docs/examples/advanced.md @@ -1,234 +1,335 @@ # Advanced Examples -These examples demonstrate sophisticated error handling patterns using ewrap's advanced features. We'll explore complex scenarios that combine multiple features to create robust error handling solutions. +Realistic scenarios combining multiple ewrap features. Each example is +self-contained and uses the current API. -## Microservice Error Handling - -This example shows a complete microservice error handling setup, combining circuit breakers, error groups, and contextual logging: +## HTTP service with classification + retry ```go -// ServiceManager handles communication with external services -type ServiceManager struct { - // Circuit breakers for different services - authBreaker *ewrap.CircuitBreaker - paymentBreaker *ewrap.CircuitBreaker - storageBreaker *ewrap.CircuitBreaker - - // Error group pool for batch operations - errorPool *ewrap.ErrorGroupPool - - // Contextual logger - logger Logger -} +package billing -func NewServiceManager(logger Logger) *ServiceManager { - return &ServiceManager{ - authBreaker: ewrap.NewCircuitBreaker("auth", 5, time.Second*30), - paymentBreaker: ewrap.NewCircuitBreaker("payment", 3, time.Minute), - storageBreaker: ewrap.NewCircuitBreaker("storage", 5, time.Second*45), - errorPool: ewrap.NewErrorGroupPool(10), - logger: logger, - } -} +import ( + "context" + "net/http" + "time" -func (sm *ServiceManager) ProcessOrder(ctx context.Context, order Order) error { - // Create error group for the operation - eg := sm.errorPool.Get() - defer eg.Release() + "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/ewrap/breaker" +) - // Enrich context with operation details - ctx = context.WithValue(ctx, "operation", "process_order") - ctx = context.WithValue(ctx, "order_id", order.ID) +type Service struct { + upstream Upstream + breaker *breaker.Breaker + logger ewrap.Logger +} - // Step 1: Authenticate user - if !sm.authBreaker.CanExecute() { - return ewrap.New("authentication service unavailable", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityCritical), - ewrap.WithLogger(sm.logger)) +func (s *Service) Charge(ctx context.Context, req ChargeRequest) (*Receipt, error) { + if err := req.Validate(); err != nil { + return nil, ewrap.Wrap(err, "invalid charge request", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusUnprocessableEntity), + ewrap.WithSafeMessage("invalid charge request"), + ewrap.WithLogger(s.logger)) } - if err := sm.authenticateUser(ctx, order.UserID); err != nil { - sm.authBreaker.RecordFailure() - return ewrap.Wrap(err, "authentication failed", - ewrap.WithContext(ctx, ewrap.ErrorTypePermission, ewrap.SeverityError), - ewrap.WithLogger(sm.logger)) + if !s.breaker.CanExecute() { + return nil, ewrap.New("payments breaker open", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusServiceUnavailable), + ewrap.WithRetryable(true), + ewrap.WithRetry(3, 2*time.Second), + ewrap.WithLogger(s.logger)) } - sm.authBreaker.RecordSuccess() - // Step 2: Process payment with retry mechanism - if !sm.paymentBreaker.CanExecute() { - return ewrap.New("payment service unavailable", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityCritical), - ewrap.WithLogger(sm.logger)) + receipt, err := s.upstream.Charge(ctx, req) + if err != nil { + s.breaker.RecordFailure() + + return nil, ewrap.Wrap(err, "upstream charge", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true), + ewrap.WithSafeMessage("payment provider error"), + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Retry after backoff; check provider status page.", + Documentation: "https://runbooks.example.com/payments/upstream", + }), + ewrap.WithLogger(s.logger)). + WithMetadata("provider", "stripe"). + WithMetadata("amount_cents", req.AmountCents) } - err := retry.Do( - func() error { - return sm.processPayment(ctx, order) - }, - retry.Attempts(3), - retry.Delay(time.Second), - retry.OnRetry(func(n uint, err error) { - sm.logger.Debug("retrying payment", - "attempt", n+1, - "error", err.Error()) - }), - ) + s.breaker.RecordSuccess() + return receipt, nil +} +``` + +The handler then translates uniformly: +```go +func (h *Handler) Charge(w http.ResponseWriter, r *http.Request) { + receipt, err := h.svc.Charge(r.Context(), parseRequest(r)) if err != nil { - sm.paymentBreaker.RecordFailure() - return ewrap.Wrap(err, "payment processing failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), - ewrap.WithLogger(sm.logger)) + status := ewrap.HTTPStatus(err) + if status == 0 { + status = http.StatusInternalServerError + } + msg := err.Error() + if e, ok := err.(*ewrap.Error); ok { + msg = e.SafeError() + } + http.Error(w, msg, status) + return } - sm.paymentBreaker.RecordSuccess() - // Step 3: Update inventory in parallel - var wg sync.WaitGroup - for _, item := range order.Items { - wg.Add(1) - go func(item OrderItem) { - defer wg.Done() + writeJSON(w, receipt) +} +``` + +## Retry middleware + +```go +func WithRetry(max int, base time.Duration, op func(context.Context) error) func(context.Context) error { + return func(ctx context.Context) error { + delay := base + + for attempt := 1; attempt <= max; attempt++ { + err := op(ctx) + if err == nil { + return nil + } - if err := sm.updateInventory(ctx, item); err != nil { - eg.Add(ewrap.Wrap(err, "inventory update failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError), - ewrap.WithLogger(sm.logger)). - WithMetadata("item_id", item.ID)) + if !ewrap.IsRetryable(err) { + return err } - }(item) + + select { + case <-time.After(delay): + delay *= 2 + case <-ctx.Done(): + return ewrap.Wrap(ctx.Err(), "retry budget exhausted", + ewrap.WithRetryable(false)) + } + } + + // last attempt — return whatever the op returns + return op(ctx) } - wg.Wait() +} +``` + +`IsRetryable` walks the chain and consults `Temporary()` as a fallback, +so this middleware works with any error source — ewrap or stdlib. + +## Validation middleware emitting structured 422 - // Check if any inventory updates failed - if eg.HasErrors() { - return eg.Error() +```go +func writeValidation(w http.ResponseWriter, err error) { + eg, ok := err.(*ewrap.ErrorGroup) + if !ok { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - return nil + body, _ := eg.ToJSON() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(body)) } +``` -// Error handling middleware for HTTP servers -func ErrorHandlingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Create request-specific error group - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Enrich context with request information - ctx := r.Context() - ctx = context.WithValue(ctx, "request_id", uuid.New().String()) - ctx = context.WithValue(ctx, "user_agent", r.UserAgent()) - ctx = context.WithValue(ctx, "remote_addr", r.RemoteAddr) - - // Wrap handler execution with panic recovery - func() { - defer func() { - if r := recover(); r != nil { - err := ewrap.New("panic recovered", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityCritical), - ewrap.WithLogger(logger)). - WithMetadata("panic_value", r). - WithMetadata("stack", string(debug.Stack())) - eg.Add(err) - } - }() +The `ErrorGroup` JSON envelope already carries every member's `field` +metadata, so the API consumer gets a clean per-field error list with no +extra translation. + +## Background worker with the breaker - next.ServeHTTP(w, r.WithContext(ctx)) - }() +```go +type Worker struct { + queue <-chan Job + cb *breaker.Breaker + logger ewrap.Logger + obs ewrap.Observer +} - // Handle any collected errors - if eg.HasErrors() { - handleErrors(w, r, eg.Error()) +func (w *Worker) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): return + case job := <-w.queue: + if !w.cb.CanExecute() { + w.requeue(job, time.Second) + continue + } + + if err := w.process(ctx, job); err != nil { + w.cb.RecordFailure() + + wrapped := ewrap.Wrap(err, "processing job", + ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError), + ewrap.WithLogger(w.logger), + ewrap.WithObserver(w.obs)). + WithMetadata("job_id", job.ID) + wrapped.Log() + + if ewrap.IsRetryable(wrapped) { + w.requeue(job, backoff(job.Attempts)) + } + continue + } + + w.cb.RecordSuccess() } - }) + } } +``` -// Sophisticated error response handling -func handleErrors(w http.ResponseWriter, r *http.Request, err error) { - var response ErrorResponse - - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Convert error to API response - response = ErrorResponse{ - Code: getErrorCode(wrappedErr), - Message: sanitizeErrorMessage(wrappedErr.Error()), - RequestID: r.Context().Value("request_id").(string), - Timestamp: time.Now().UTC(), - } +A single `WithLogger`/`WithObserver` near the construction site +propagates to wraps via inheritance. - // Add details based on error type - if details := getErrorDetails(wrappedErr); details != nil { - response.Details = details - } +## Fan-out fan-in with `ErrorGroup` - // Log error with full context - logger.Error("request failed", - "error", wrappedErr.Error(), - "stack", wrappedErr.Stack(), - "metadata", wrappedErr.GetMetadata("error_context"), - "request_id", response.RequestID) - - // Set appropriate HTTP status - w.WriteHeader(getHTTPStatus(wrappedErr)) - } else { - // Handle unwrapped errors - response = ErrorResponse{ - Code: "INTERNAL_ERROR", - Message: "An unexpected error occurred", - } - w.WriteHeader(http.StatusInternalServerError) +```go +var pool = ewrap.NewErrorGroupPool(16) + +func sweep(ctx context.Context, ids []string) error { + eg := pool.Get() + defer eg.Release() + + var wg sync.WaitGroup + for _, id := range ids { + wg.Add(1) + go func(id string) { + defer wg.Done() + + if err := refresh(ctx, id); err != nil { + eg.Add(ewrap.Wrap(err, "refresh failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeNetwork, ewrap.SeverityWarning), + ewrap.WithRetryable(true)). + WithMetadata("id", id)) + } + }(id) } + wg.Wait() - // Write JSON response - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) + return eg.Join() // single error containing every failure } +``` -// Helper functions for error handling -func getErrorCode(err *ewrap.Error) string { - if ctx, ok := err.GetMetadata("error_context").(*ewrap.ErrorContext); ok { - switch ctx.Type { - case ewrap.ErrorTypeValidation: - return "VALIDATION_ERROR" - case ewrap.ErrorTypePermission: - return "PERMISSION_DENIED" - case ewrap.ErrorTypeNotFound: - return "NOT_FOUND" - case ewrap.ErrorTypeDatabase: - return "DATABASE_ERROR" - default: - return "INTERNAL_ERROR" - } +Each member error preserves its own stack trace, metadata, and HTTP +status; the aggregator returned by `Join()` is `errors.Is`-walkable. + +## Custom domain factory + +```go +package billing + +import ( + "context" + "net/http" + "time" + + "github.com/hyp3rd/ewrap" +) + +type Code int + +const ( + CodeUnknown Code = iota + CodeCardDeclined + CodeRateLimited +) + +// New constructs a billing-specific error. NewSkip(1, ...) advances the +// captured stack past this helper so the trace begins at the caller. +func New(ctx context.Context, code Code, msg string) *ewrap.Error { + base := ewrap.NewSkip(1, msg, + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityWarning)) + + switch code { + case CodeCardDeclined: + return base. + WithMetadata("billing_code", code). + WithContext(&ewrap.ErrorContext{ + Type: ewrap.ErrorTypeExternal, + Severity: ewrap.SeverityWarning, + }) + case CodeRateLimited: + return base. + WithMetadata("billing_code", code). + WithMetadata("retry_after_s", 30) } - return "UNKNOWN_ERROR" + + return base.WithMetadata("billing_code", code) } +``` -func getHTTPStatus(err *ewrap.Error) int { - if ctx, ok := err.GetMetadata("error_context").(*ewrap.ErrorContext); ok { - switch ctx.Type { - case ewrap.ErrorTypeValidation: - return http.StatusBadRequest - case ewrap.ErrorTypePermission: - return http.StatusForbidden - case ewrap.ErrorTypeNotFound: - return http.StatusNotFound - default: - return http.StatusInternalServerError - } +Usage: + +```go +return billing.New(ctx, billing.CodeRateLimited, "rate limited at provider") +``` + +## OpenTelemetry observer + +```go +import "go.opentelemetry.io/otel/trace" + +type otelObserver struct { + tracer trace.Tracer + ctx context.Context // captured on construction +} + +func (o *otelObserver) RecordError(message string) { + span := trace.SpanFromContext(o.ctx) + if !span.IsRecording() { + return } - return http.StatusInternalServerError + span.RecordError(errors.New(message)) } + +err := ewrap.New("payment failed", + ewrap.WithObserver(&otelObserver{tracer: tracer, ctx: ctx})) +err.Log() // span event recorded ``` -This example demonstrates several advanced concepts: +For a richer integration, walk the metadata in the observer and attach +each as a span attribute. + +## Test fixtures + +```go +package billing + +import ( + "errors" + "testing" + + "github.com/hyp3rd/ewrap" +) + +var errFakeUpstream = errors.New("fake upstream failure") + +func TestChargeWrapsUpstreamError(t *testing.T) { + t.Parallel() + + svc := &Service{upstream: stubUpstream{err: errFakeUpstream}} + _, err := svc.Charge(t.Context(), validRequest()) + + if !errors.Is(err, errFakeUpstream) { + t.Fatalf("expected upstream error in chain, got %v", err) + } + + if got := ewrap.HTTPStatus(err); got != http.StatusBadGateway { + t.Errorf("HTTP status: got %d, want %d", got, http.StatusBadGateway) + } + + if !ewrap.IsRetryable(err) { + t.Error("expected upstream error to be retryable") + } +} +``` -- Circuit breaker integration for external service calls -- Error group pooling for efficient error collection -- Context propagation through the request lifecycle -- Sophisticated error response handling -- Panic recovery and logging -- Error type mapping to HTTP status codes -- Request-scoped error tracking +`errors.Is`, `ewrap.HTTPStatus`, and `ewrap.IsRetryable` are the right +assertions here — none of them touch string-formatted messages. diff --git a/docs/docs/examples/basic.md b/docs/docs/examples/basic.md index 6a2d797..f93945d 100644 --- a/docs/docs/examples/basic.md +++ b/docs/docs/examples/basic.md @@ -1,236 +1,233 @@ # Basic Examples -This guide provides practical examples of common error handling scenarios using ewrap. Each example demonstrates a specific feature or pattern, helping you understand how to use the package effectively. +Drop-in patterns showing the core surface. Each example compiles against +the current API. -## Simple Error Creation and Handling - -Let's start with basic error creation and handling: +## Simple error ```go package main import ( - "context" "fmt" "github.com/hyp3rd/ewrap" ) func main() { - if err := processUserRegistration("john.doe@example.com"); err != nil { - fmt.Printf("Registration failed: %v\n", err) - return - } - fmt.Println("Registration successful") + err := ewrap.New("file not found") + fmt.Println(err.Error()) // file not found + fmt.Printf("%+v\n", err) // file not found + stack } +``` -func processUserRegistration(email string) error { - // Create a context for the operation - ctx := context.Background() +## Wrapping with context - // Validate email - if err := validateEmail(email); err != nil { - return ewrap.Wrap(err, "email validation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)) - } +```go +import ( + "context" + "net/http" - // Create user in database - if err := createUser(email); err != nil { - return ewrap.Wrap(err, "user creation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) + "github.com/hyp3rd/ewrap" +) + +func loadUser(ctx context.Context, id string) (*User, error) { + u, err := db.Get(ctx, id) + if err != nil { + return nil, ewrap.Wrap(err, "loading user", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusInternalServerError)). + WithMetadata("user_id", id) } + return u, nil +} +``` + +## `%w` in `Newf` + +```go +import ( + "errors" + "io" + + "github.com/hyp3rd/ewrap" +) + +func read() error { + err := ewrap.Newf("reading config: %w", io.EOF) - return nil + fmt.Println(err.Error()) // reading config: EOF + fmt.Println(errors.Is(err, io.EOF)) // true + return err } +``` + +## Validation accumulator -func validateEmail(email string) error { - if !strings.Contains(email, "@") { - return ewrap.New("invalid email format") +```go +func validate(o Order) error { + eg := ewrap.NewErrorGroup() + + if o.Customer == "" { + eg.Add(ewrap.New("customer is required", + ewrap.WithContext(nil, ewrap.ErrorTypeValidation, ewrap.SeverityError)). + WithMetadata("field", "customer")) + } + if o.Total <= 0 { + eg.Add(ewrap.New("total must be positive", + ewrap.WithContext(nil, ewrap.ErrorTypeValidation, ewrap.SeverityError)). + WithMetadata("field", "total")) } - return nil -} -func createUser(email string) error { - // Simulate database operation - return nil + return eg.ErrorOrNil() } ``` -## Error Groups for Multiple Validations - -Here's how to collect multiple validation errors: +## Pooled error group ```go -type User struct { - Email string - Password string - Age int - Username string -} +var pool = ewrap.NewErrorGroupPool(8) -func validateUser(ctx context.Context, user User) error { - // Get an error group from the pool - pool := ewrap.NewErrorGroupPool(4) +func process(items []Item) error { eg := pool.Get() defer eg.Release() - // Validate email - if err := validateEmail(user.Email); err != nil { - eg.Add(ewrap.Wrap(err, "invalid email", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError))) + for _, it := range items { + eg.Add(handle(it)) } + return eg.Join() +} +``` - // Validate password - if len(user.Password) < 8 { - eg.Add(ewrap.New("password too short", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)). - WithMetadata("min_length", 8)) - } +## Inspecting metadata - // Validate age - if user.Age < 18 { - eg.Add(ewrap.New("user must be 18 or older", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)). - WithMetadata("provided_age", user.Age)) - } +```go +err := ewrap.New("payment failed"). + WithMetadata("provider", "stripe"). + WithMetadata("attempt", 2) - return eg.Error() +if v, ok := err.GetMetadata("provider"); ok { + fmt.Println(v) // stripe } -``` -## Logging Integration +if attempt, ok := ewrap.GetMetadataValue[int](err, "attempt"); ok { + fmt.Println(attempt) // 2 +} +``` -Example showing basic logging integration: +## Walking the cause chain ```go -type AppLogger struct { - logger *zap.Logger -} +import "errors" -func (l *AppLogger) Error(msg string, keysAndValues ...any) { - l.logger.Error(msg, convertToZapFields(keysAndValues...)...) -} +err := ewrap.Wrap(io.EOF, "reading body") -func (l *AppLogger) Debug(msg string, keysAndValues ...any) { - l.logger.Debug(msg, convertToZapFields(keysAndValues...)...) -} +errors.Is(err, io.EOF) // true +errors.Unwrap(err) // io.EOF +err.Cause() // io.EOF +``` -func (l *AppLogger) Info(msg string, keysAndValues ...any) { - l.logger.Info(msg, convertToZapFields(keysAndValues...)...) -} +## Logging with `slog` directly -func processWithLogging(ctx context.Context, data []byte) error { - logger := &AppLogger{logger: zapLogger} +`*Error` implements `slog.LogValuer`, so you can pass it as a structured +field with no adapter: - err := processData(data) - if err != nil { - return ewrap.Wrap(err, "data processing failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError), - ewrap.WithLogger(logger)) - } +```go +import ( + "log/slog" + "os" +) - return nil -} +logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) +err := ewrap.New("payment failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError)) + +logger.Error("payment failed", "err", err) +// {"level":"ERROR","msg":"payment failed","err":{"message":"payment failed", +// "type":"external","severity":"error",...}} ``` -## HTTP Handler Example +## Logging via `(*Error).Log` -Using ewrap in an HTTP handler: +For loggers attached as `ewrap.Logger`: ```go -func handleUserRegistration(w http.ResponseWriter, r *http.Request) { - // Create request context with ID - ctx := r.Context() - requestID := generateRequestID() - ctx = context.WithValue(ctx, "request_id", requestID) - - var user User - if err := json.NewDecoder(r.Body).Decode(&user); err != nil { - respondWithError(w, ewrap.Wrap(err, "invalid request body", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError))) - return - } +import ewrapslog "github.com/hyp3rd/ewrap/slog" - if err := validateUser(ctx, user); err != nil { - respondWithError(w, err) - return - } +logger := ewrapslog.New(slog.New(slog.NewJSONHandler(os.Stdout, nil))) - if err := createUser(ctx, user); err != nil { - respondWithError(w, err) - return - } +err := ewrap.New("payment failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithLogger(logger)) +err.Log() +``` - respondWithJSON(w, http.StatusCreated, map[string]string{ - "message": "user created successfully", - }) -} +## Pretty-printing for humans -func respondWithError(w http.ResponseWriter, err error) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Convert error to API response - response := ErrorResponse{ - Message: wrappedErr.Error(), - Code: getErrorCode(wrappedErr), - Details: getErrorDetails(wrappedErr), - } +```go +fmt.Printf("%+v\n", err) +// payment failed +// /path/to/repo/billing.go:42 - example.com/repo/billing.charge +// /path/to/repo/handlers.go:71 - example.com/repo/handlers.Pay +// ... +``` - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(getHTTPStatus(wrappedErr)) - json.NewEncoder(w).Encode(response) - } else { - // Handle unwrapped errors - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]string{ - "message": "internal server error", - }) - } +## Circuit breaker basics + +```go +import "github.com/hyp3rd/ewrap/breaker" + +cb := breaker.New("payments", 3, time.Minute) + +if !cb.CanExecute() { + return ewrap.New("payments breaker open", + ewrap.WithRetryable(true)) } -``` -## Database Operations +if err := charge(); err != nil { + cb.RecordFailure() + return ewrap.Wrap(err, "charging customer") +} + +cb.RecordSuccess() +``` -Example showing database error handling: +## Classifying for the wire ```go -func getUserByID(ctx context.Context, userID string) (*User, error) { - var user User - err := db.QueryRow("SELECT * FROM users WHERE id = $1", userID).Scan(&user) - - switch { - case err == sql.ErrNoRows: - return nil, ewrap.New("user not found", - ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityWarning)). - WithMetadata("user_id", userID) - case err != nil: - return nil, ewrap.Wrap(err, "database query failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)). - WithMetadata("user_id", userID) +func toResponse(w http.ResponseWriter, err error) { + status := ewrap.HTTPStatus(err) + if status == 0 { + status = http.StatusInternalServerError } - return &user, nil + msg := err.Error() + if e, ok := err.(*ewrap.Error); ok { + msg = e.SafeError() // PII-redacted variant + } + + http.Error(w, msg, status) } ``` -## Cleanup and Deferred Operations - -Example showing error handling with cleanup: +## Retry with classification ```go -func processFile(ctx context.Context, path string) error { - file, err := os.Open(path) - if err != nil { - return ewrap.Wrap(err, "failed to open file", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)) - } - defer func() { - if closeErr := file.Close(); closeErr != nil { - err = ewrap.Wrap(closeErr, "failed to close file", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityWarning)) +func withRetry(ctx context.Context, op func() error) error { + for attempt := 1; attempt <= 5; attempt++ { + err := op() + if err == nil { + return nil } - }() - - // Process file... - return nil + if !ewrap.IsRetryable(err) { + return err + } + select { + case <-time.After(backoff(attempt)): + case <-ctx.Done(): + return ewrap.Wrap(ctx.Err(), "retry budget exhausted") + } + } + return op() } ``` diff --git a/docs/docs/features/circuit-breaker.md b/docs/docs/features/circuit-breaker.md index 309e04e..27cb261 100644 --- a/docs/docs/features/circuit-breaker.md +++ b/docs/docs/features/circuit-breaker.md @@ -1,288 +1,185 @@ -# Circuit Breaker Pattern - -The Circuit Breaker pattern is like a safety switch in an electrical system - it prevents cascade failures by "breaking the circuit" when too many errors occur. This pattern is crucial for building resilient systems that can gracefully handle failures in distributed environments. - -## Understanding Circuit Breakers - -Imagine you're calling a database service. Without a circuit breaker, if the database becomes slow or unresponsive, your application might: +# Circuit Breaker (`ewrap/breaker` subpackage) + +The classic circuit-breaker pattern, implemented in a small, self-contained +subpackage. It does **not** depend on the parent `ewrap` module — you can +use it on its own, and consumers who only want error wrapping don't pay +for it. + +## States + +```text +┌────────┐ failures ≥ max ┌──────┐ timeout elapsed ┌───────────┐ +│ Closed │ ───────────────────►│ Open │ ─────────────────► │ Half-Open │ +└────────┘ └──────┘ └─────┬─────┘ + ▲ │ + │ success │ + └───────────────────────────────────────────────────────────┘ + failure + ↓ + Open +``` -1. Keep trying and failing -1. Accumulate resource-consuming connections -1. Eventually crash or become unresponsive itself +| State | Behaviour | +| --- | --- | +| `Closed` | Calls pass through. Failures increment a counter. | +| `Open` | Calls are rejected fast. After `timeout` elapses, the next `CanExecute` flips state to `HalfOpen`. | +| `HalfOpen` | A single probe call is allowed. Success closes the breaker; failure re-opens it. | -A circuit breaker prevents this by monitoring failures and automatically stopping attempts when a threshold is reached, giving the system time to recover. +## Quick start -## Basic Circuit Breaker Usage +```go +import "github.com/hyp3rd/ewrap/breaker" -Let's start with a simple example of how to use circuit breakers in ewrap: +cb := breaker.New("payments", 5, 30*time.Second) -```go -// Create a circuit breaker that will: -// - Open after 5 failures -// - Stay open for 1 minute before attempting recovery -cb := ewrap.NewCircuitBreaker("database-operations", 5, time.Minute) - -func queryDatabase() error { - // Check if we can execute the operation - if !cb.CanExecute() { - return ewrap.New("circuit breaker is open", - ewrap.WithErrorType(ewrap.ErrorTypeDatabase), - ewrap.WithMetadata("breaker_name", "database-operations")) - } - - err := performDatabaseQuery() - if err != nil { - // Record the failure - cb.RecordFailure() - return ewrap.Wrap(err, "database query failed") - } - - // Record the success - cb.RecordSuccess() - return nil +if !cb.CanExecute() { + return ewrap.New("payments breaker open", + ewrap.WithRetryable(true)) } -``` -## Circuit Breaker States +if err := charge(req); err != nil { + cb.RecordFailure() + return ewrap.Wrap(err, "charging customer") +} -A circuit breaker can be in one of three states: +cb.RecordSuccess() +``` -### Closed State (Normal Operation) +## API ```go -if cb.CanExecute() { // Returns true when circuit is closed - // Normal operation - requests are allowed through - err := performOperation() - if err != nil { - cb.RecordFailure() - } else { - cb.RecordSuccess() - } -} +func New(name string, maxFailures int, timeout time.Duration) *Breaker +func NewWithObserver(name string, maxFailures int, timeout time.Duration, obs Observer) *Breaker + +func (cb *Breaker) Name() string +func (cb *Breaker) State() State +func (cb *Breaker) CanExecute() bool +func (cb *Breaker) RecordFailure() +func (cb *Breaker) RecordSuccess() +func (cb *Breaker) OnStateChange(callback func(name string, from, to State)) +func (cb *Breaker) SetObserver(obs Observer) ``` -### Open State (Failure Prevention) +States and the observer interface: ```go -if !cb.CanExecute() { // Returns false when circuit is open - // Circuit is open - fail fast without attempting operation - return ewrap.New("service unavailable", - ewrap.WithErrorType(ewrap.ErrorTypeInternal), - ewrap.WithMetadata("circuit_state", "open")) -} -``` +type State int +const ( + Closed State = iota + Open + HalfOpen +) -### Half-Open State (Recovery Attempt) +func (s State) String() string // "closed", "open", "half-open", "unknown" -```go -// After timeout period, circuit moves to half-open -// Allowing a single request through to test the service -if cb.CanExecute() { - err := performOperation() - if err != nil { - cb.RecordFailure() // Returns to open state - return err - } - cb.RecordSuccess() // Returns to closed state - return nil +type Observer interface { + RecordTransition(name string, from, to State) } ``` -## Advanced Circuit Breaker Patterns - -### Monitoring Multiple Services +## Observability -When your application depends on multiple services, you can use separate circuit breakers for each: +Pass an `Observer` at construction time or with `SetObserver`: ```go -type ServiceManager struct { - dbBreaker *ewrap.CircuitBreaker - cacheBreaker *ewrap.CircuitBreaker - apiBreaker *ewrap.CircuitBreaker +type metrics struct { + gauge *prometheus.GaugeVec } -func NewServiceManager() *ServiceManager { - return &ServiceManager{ - dbBreaker: ewrap.NewCircuitBreaker("database", 5, time.Minute), - cacheBreaker: ewrap.NewCircuitBreaker("cache", 3, time.Second*30), - apiBreaker: ewrap.NewCircuitBreaker("external-api", 10, time.Minute*2), - } +func (m *metrics) RecordTransition(name string, from, to breaker.State) { + m.gauge.WithLabelValues(name, to.String()).Set(1) } -func (sm *ServiceManager) GetUserData(userID string) (*UserData, error) { - // Try cache first - if sm.cacheBreaker.CanExecute() { - data, err := tryCache(userID) - if err == nil { - sm.cacheBreaker.RecordSuccess() - return data, nil - } - sm.cacheBreaker.RecordFailure() - } - - // Fall back to database - if sm.dbBreaker.CanExecute() { - data, err := queryDatabase(userID) - if err == nil { - sm.dbBreaker.RecordSuccess() - return data, nil - } - sm.dbBreaker.RecordFailure() - } - - return nil, ewrap.New("all data sources unavailable", - ewrap.WithErrorType(ewrap.ErrorTypeInternal), - ewrap.WithSeverity(ewrap.SeverityCritical)) -} +cb := breaker.NewWithObserver("payments", 5, 30*time.Second, &metrics{gauge: stateGauge}) ``` -### Circuit Breaker with Fallback Strategies - -Implement graceful degradation when services fail: +`OnStateChange` registers a callback that fires for the same events as the +observer: ```go -type CacheService struct { - primaryBreaker *ewrap.CircuitBreaker - secondaryBreaker *ewrap.CircuitBreaker - localCache *cache.Cache -} +cb.OnStateChange(func(name string, from, to breaker.State) { + log.Printf("breaker %s: %s -> %s", name, from, to) +}) +``` -func (cs *CacheService) GetValue(key string) (any, error) { - // Try primary cache - if cs.primaryBreaker.CanExecute() { - value, err := cs.getPrimaryCache(key) - if err == nil { - cs.primaryBreaker.RecordSuccess() - return value, nil - } - cs.primaryBreaker.RecordFailure() - } - - // Try secondary cache - if cs.secondaryBreaker.CanExecute() { - value, err := cs.getSecondaryCache(key) - if err == nil { - cs.secondaryBreaker.RecordSuccess() - return value, nil - } - cs.secondaryBreaker.RecordFailure() - } +### Synchronous, lock-released dispatch - // Fall back to local cache - if value, found := cs.localCache.Get(key); found { - return value, nil - } +Transition events (observer + callback) fire **synchronously** after the +breaker lock is released. The relevant guarantees: - return nil, ewrap.New("all cache layers unavailable", - ewrap.WithErrorType(ewrap.ErrorTypeInternal)) -} -``` +1. The breaker is never holding its own mutex when your code runs. +2. Two transitions cannot interleave — observer/callback for transition A + completes before transition B begins. +3. Your callbacks must **not** invoke the breaker recursively (would + deadlock). -## Combining with Error Groups +There is no fire-and-forget goroutine — earlier versions spawned one per +transition, which would have allowed unbounded goroutine growth under load. -Circuit Breakers work particularly well with Error Groups for batch operations: +## Concurrency -```go -func processBatch(items []Item) error { - pool := ewrap.NewErrorGroupPool(len(items)) - eg := pool.Get() - defer eg.Release() +`CanExecute`, `RecordFailure`, `RecordSuccess`, `State`, `OnStateChange`, +and `SetObserver` are all goroutine-safe. The breaker uses a single +`sync.Mutex` and the `Open → HalfOpen` transition is atomic. - cb := ewrap.NewCircuitBreaker("batch-processor", 5, time.Minute) +A typical hot-path use: - for _, item := range items { - if !cb.CanExecute() { - eg.Add(ewrap.New("circuit breaker open: too many failures")) - break - } +```go +for range workers { + go func() { + for req := range jobs { + if !cb.CanExecute() { + jobs <- req // requeue / drop + continue + } + + if err := process(req); err != nil { + cb.RecordFailure() + continue + } - if err := processItem(item); err != nil { - cb.RecordFailure() - eg.Add(ewrap.Wrap(err, fmt.Sprintf("failed to process item %d", item.ID))) - } else { cb.RecordSuccess() } - } - - return eg.Error() + }() } ``` -## Best Practices - -### 1. Choose Appropriate Thresholds - -Consider your service's characteristics when configuring circuit breakers: - -```go -// For critical, fast operations -cb := ewrap.NewCircuitBreaker("critical-service", 3, time.Second*30) - -// For less critical, slower operations -cb := ewrap.NewCircuitBreaker("background-service", 10, time.Minute*5) -``` - -### 2. Monitor Circuit Breaker States +## Pairing with `ewrap` -Implement monitoring to track circuit breaker behavior: +The breaker has no compile-time dependency on `ewrap`, but the two compose +naturally — a tripped breaker is the canonical place to return a +retryable, well-classified error: ```go -type MonitoredCircuitBreaker struct { - *ewrap.CircuitBreaker - metrics *metrics.Recorder -} - -func (mcb *MonitoredCircuitBreaker) RecordFailure() { - mcb.CircuitBreaker.RecordFailure() - mcb.metrics.Increment("circuit_breaker.failures") -} - -func (mcb *MonitoredCircuitBreaker) RecordSuccess() { - mcb.CircuitBreaker.RecordSuccess() - mcb.metrics.Increment("circuit_breaker.successes") +if !cb.CanExecute() { + return ewrap.New("payments breaker open", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusServiceUnavailable), + ewrap.WithRetryable(true), + ewrap.WithRetry(3, 5*time.Second)) } ``` -### 3. Implement Graceful Degradation +Downstream callers can then use `ewrap.IsRetryable(err)` and +`ewrap.HTTPStatus(err)` to decide what to do. -Plan for circuit breaker activation: +## Performance -```go -func getUserProfile(userID string) (*Profile, error) { - if !profileBreaker.CanExecute() { - // Return cached or minimal profile when circuit is open - return getMinimalProfile(userID) - } - - profile, err := getFullProfile(userID) - if err != nil { - profileBreaker.RecordFailure() - // Fall back to minimal profile - return getMinimalProfile(userID) - } - - profileBreaker.RecordSuccess() - return profile, nil -} -``` +| Benchmark | ns/op | B/op | allocs | +| --- | ---: | ---: | ---: | +| `RecordFailure` | ~33 | 0 | 0 | +| `ConcurrentOperations` (parallel CanExecute / Record) | ~200 | 0 | 0 | -### 4. Use Context-Aware Circuit Breakers +Steady-state operations are allocation-free. Observer / callback dispatch +allocates only the closure passed to `OnStateChange`. -Consider request context when making circuit breaker decisions: +## When NOT to use a circuit breaker -```go -func processWithContext(ctx context.Context, data []byte) error { - if deadline, ok := ctx.Deadline(); ok { - // Adjust circuit breaker timeout based on context deadline - timeout := time.Until(deadline) - cb := ewrap.NewCircuitBreaker("context-aware", 5, timeout/2) - - if !cb.CanExecute() { - return ewrap.New("circuit breaker open", - ewrap.WithContext(ctx, ewrap.ErrorTypeTimeout, ewrap.SeverityWarning)) - } - // Process with context-aware circuit breaker - } - // ... rest of processing -} -``` +- **Per-call retries** — use exponential backoff (e.g. + `cenkalti/backoff`) instead. The breaker protects shared infrastructure + from being overwhelmed; per-call backoff smooths a single client's + request. +- **Validation errors** — those are not transient; failing fast is + already the right answer. +- **Tests** — pin the timeout to something tiny (10 ms) and you won't + need to mock the clock. diff --git a/docs/docs/features/error-creation.md b/docs/docs/features/error-creation.md index d74bac7..c6e2016 100644 --- a/docs/docs/features/error-creation.md +++ b/docs/docs/features/error-creation.md @@ -1,185 +1,133 @@ # Error Creation -Understanding how to create errors effectively is fundamental to using the ewrap package. This guide explains the different ways to create errors and when to use each approach. +ewrap exposes four constructors. They all capture a stack trace at the call +site (configurable; see [Stack Traces](stack-traces.md)) and return a +`*Error` that satisfies the `error` interface. -## Basic Error Creation +| Constructor | Use when | +| --- | --- | +| `New(msg, opts...)` | Plain error with a static message | +| `Newf(format, args...)` | Formatted message; `%w` is honoured | +| `Wrap(err, msg, opts...)` | Add a layer to an existing error | +| `Wrapf(err, format, args...)` | Same, with a formatted message | -The most straightforward way to create an error is using the `New` function. However, there's more to consider than just the error message. +`Wrap` / `Wrapf` are nil-safe: `Wrap(nil, "...")` returns `nil`, so you can +call them unconditionally. + +## `New` — static message ```go -// Simple error creation err := ewrap.New("user not found") - -// With additional context -err := ewrap.New("user not found", - ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityError), - ewrap.WithLogger(logger)) ``` -The `New` function captures a stack trace automatically, allowing you to trace the error's origin later. This is particularly valuable when debugging complex applications where errors might surface far from their source. - -## Creating Errors with Options - -The `New` function accepts variadic options that configure the error's behavior and context: - -```go -err := ewrap.New("failed to process payment", - // Add request context - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityCritical), - // Configure logging - ewrap.WithLogger(logger), - // Add retry information - ewrap.WithRetry(3, time.Second*5)) -``` +`New` returns a `*Error` with the message stored verbatim. The metadata map +is **not** allocated until you call `WithMetadata` — most errors never need +one and pay no cost for it. -The `Newf` function is similar to `New`, but it allows you to provide a formatted error message: +## `Newf` — formatted, `%w`-aware ```go -err := ewrap.Newf("failed to process payment: %w", err) +err := ewrap.Newf("user %d not found", id) // simple format +err := ewrap.Newf("query %q failed: %w", q, ioErr) // wraps ioErr ``` -Each option serves a specific purpose: - -- `WithContext`: Adds request context and error classification -- `WithLogger`: Configures error logging behavior -- `WithRetry`: Specifies retry behavior for recoverable errors +When `format` contains `%w`, `Newf` extracts the wrapped argument as the +cause so `errors.Is(err, ioErr)` returns true. The full formatted text +becomes the error's `.Error()` output (matching `fmt.Errorf` semantics). -## Creating Domain-Specific Errors +If `format` contains multiple `%w` verbs, the first wrapped error becomes +the cause; the others appear in the rendered message. -For domain-specific error cases, you can combine error creation with metadata: +## `Wrap` — layer on an existing error ```go -func validateUserAge(age int) error { - if age < 18 { - return ewrap.New("user is underage", - ewrap.WithContext(context.Background(), ewrap.ErrorTypeValidation, ewrap.SeverityError)). - WithMetadata("minimum_age", 18). - WithMetadata("provided_age", age) - } - return nil +if err := db.Ping(); err != nil { + return ewrap.Wrap(err, "syncing replicas") } ``` -## Best Practices for Error Creation - -When creating errors, follow these guidelines for maximum effectiveness: - -1. **Be Specific**: Error messages should clearly indicate what went wrong: - - ```go - // Good - err := ewrap.New("database connection timeout after 5 seconds") +Every `Wrap` captures **its own** stack frames, so deep chains carry the +full call history rather than just the innermost site. When the inner error +is itself a `*Error`, the wrapper inherits its metadata, error context, +recovery suggestion, retry info, observer, and logger. - // Not as helpful - err := ewrap.New("database error") - ``` +## `Wrapf` — formatted wrap -1. **Include Relevant Context**: Add context that helps with debugging: - - ```go - err := ewrap.New("failed to update user profile", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)). - WithMetadata("user_id", userID). - WithMetadata("fields_updated", fields) - ``` - -1. **Use Appropriate Error Types**: Choose error types that match the situation: +```go +return ewrap.Wrapf(err, "loading row %d for tenant %s", rowID, tenantID) +``` - ```go - // For validation errors - err := ewrap.New("invalid email format", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityWarning)) +## Options at construction time - // For system errors - err := ewrap.New("failed to connect to database", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) - ``` +`New`, `Wrap`, and `WrapSkip` accept variadic `Option`s: -1. **Consider Recovery Options**: Include information that helps with recovery: +```go +err := ewrap.New("payment authorization rejected", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true), + ewrap.WithSafeMessage("payment authorization rejected"), + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Inspect provider's queue and retry after backoff.", + Documentation: "https://runbooks.example.com/payments/timeout", + }), + ewrap.WithRetry(3, 5*time.Second), + ewrap.WithLogger(logger), + ewrap.WithObserver(observer), + ewrap.WithStackDepth(16), // override default capture depth +) +``` - ```go - err := ewrap.New("rate limit exceeded", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityWarning)). - WithMetadata("retry_after", time.Now().Add(time.Minute)). - WithMetadata("current_rate", currentRate). - WithMetadata("limit", rateLimit) - ``` +A full list lives in [API Reference → Options](../api/options.md). -## Working with Stack Traces +## Chained metadata -Every error created with `New` automatically captures a stack trace: +`(*Error).WithMetadata` returns the same instance so calls can chain after +construction. The map is allocated lazily on the first call. ```go -func processOrder(orderID string) error { - err := ewrap.New("order processing failed") - fmt.Println(err.Stack()) // Prints the stack trace - return err -} +err := ewrap.New("operation failed"). + WithMetadata("query", "SELECT * FROM users"). + WithMetadata("retry_count", 3). + WithMetadata("connection_pool_size", 10) ``` -The stack trace includes function names, file names, and line numbers, making it easier to trace the error's origin. - -## Error Creation in Tests - -When writing tests, you might want to create errors for specific scenarios: +For typed reads, use the generic accessor: ```go -func TestOrderProcessing(t *testing.T) { - // Create a test error - testErr := ewrap.New("simulated database error", - ewrap.WithContext(context.Background(), ewrap.ErrorTypeDatabase, ewrap.SeverityError)) - - // Mock database returns our test error - mockDB := &MockDatabase{ - QueryFunc: func() error { - return testErr - }, - } - - err := processOrder(mockDB, "order123") - - // Verify error handling - if !errors.Is(err, testErr) { - t.Errorf("expected error %v, got %v", testErr, err) - } -} +count, ok := ewrap.GetMetadataValue[int](err, "retry_count") ``` -## Thread Safety +## Domain-specific factories -All error creation operations in ewrap are thread-safe. You can safely create errors from multiple goroutines: +Wrap construction in your own factories to enforce conventions: ```go -func processItems(items []string) []error { - var wg sync.WaitGroup - errors := make([]error, 0) - var mu sync.Mutex - - for _, item := range items { - wg.Add(1) - go func(item string) { - defer wg.Done() - if err := process(item); err != nil { - mu.Lock() - errors = append(errors, ewrap.New("processing failed", - ewrap.WithMetadata("item", item))) - mu.Unlock() - } - }(item) - } - - wg.Wait() - return errors +func ErrUnderage(ctx context.Context, age int) *ewrap.Error { + return ewrap.NewSkip(1, "user is underage", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusUnprocessableEntity), + ). + WithMetadata("minimum_age", 18). + WithMetadata("provided_age", age) } ``` -## Performance Considerations +`NewSkip(skip, ...)` advances the captured stack by `skip` frames, so the +trace starts at the caller of your factory rather than inside it. The +companion `WrapSkip(skip, err, ...)` does the same for wraps. + +## Thread safety -Error creation in ewrap is optimized for both CPU and memory usage. However, consider these performance tips: +All constructors and `*Error` accessors are safe for concurrent use. The +metadata map is guarded by an `RWMutex`; everything else is set once at +construction and never mutated, so reads (including cached `Error()` and +`Stack()`) are lock-free after the first call. -1. Reuse error types for common errors instead of creating new ones -1. Only capture stack traces when necessary -1. Be mindful of metadata quantity in high-throughput scenarios -1. Use error pools for frequent error creation scenarios +## Performance notes -Remember: error creation should be reserved for exceptional cases. Don't use errors for normal control flow in your application. +- The metadata map is allocated on first write; errors with no metadata pay + for one allocation (the `*Error` struct) plus the stack PCs slice. +- `Error()` and `Stack()` cache their formatted output via `sync.Once`. +- `(*Error).Format` and `LogValue` reuse those caches — `fmt.Printf("%v", err)` + and `slog.Error(..., "err", err)` are both cheap after the first format. diff --git a/docs/docs/features/error-groups.md b/docs/docs/features/error-groups.md index fc61c1d..577ef34 100644 --- a/docs/docs/features/error-groups.md +++ b/docs/docs/features/error-groups.md @@ -1,296 +1,188 @@ # Error Groups -Error Groups in ewrap provide a powerful way to collect, manage, and handle multiple errors together. They are particularly useful in concurrent operations, validation scenarios, or any situation where multiple errors might occur and need to be handled cohesively. +`ErrorGroup` aggregates multiple errors into a single `error`-implementing +value. Use it for validation passes, batch operations, fan-out fan-in +goroutine work, or anywhere you'd otherwise return the first error and +silently drop the rest. -## Understanding Error Groups - -An Error Group acts as a thread-safe container for multiple errors. Think of it as a collector that can gather errors from various operations while ensuring that all errors are properly tracked and can be processed together. What makes our implementation special is its efficient memory usage through a pooling mechanism. - -## Basic Usage - -Let's start with the fundamental ways to use Error Groups: +## Quick start ```go -// Create a pool for error groups -pool := ewrap.NewErrorGroupPool(4) // Initial capacity of 4 errors - -// Get an error group from the pool -eg := pool.Get() -defer eg.Release() // Don't forget to release it back to the pool +eg := ewrap.NewErrorGroup() +eg.Add(validate(req)) +eg.Add(persist(req)) +eg.Add(notify(req)) -// Add errors to the group -eg.Add(ewrap.New("validation failed for email")) -eg.Add(ewrap.New("validation failed for password")) - -// Aggregate errors using errors.Join -if err := eg.Join(); err != nil { - fmt.Printf("Encountered errors: %v\n", err) +if err := eg.ErrorOrNil(); err != nil { + return err } ``` -## Error Group Pooling +`Add(nil)` is a no-op, so you can call it unconditionally. -Our Error Group implementation uses a pool to reuse instances efficiently. This is particularly valuable in high-throughput scenarios where creating and destroying error groups frequently could impact performance. +## Pooled allocation -### How Pooling Works - -The pooling mechanism works behind the scenes to manage memory efficiently: +For high-throughput paths, reuse `ErrorGroup` instances via `ErrorGroupPool`: ```go -// Create a pool with specific capacity -pool := ewrap.NewErrorGroupPool(4) +pool := ewrap.NewErrorGroupPool(4) // initial slice capacity -func processUserRegistration(user User) error { - // Get an error group from the pool - eg := pool.Get() - defer eg.Release() // Returns the group to the pool when done +eg := pool.Get() +defer eg.Release() // returns it to the pool, cleared - // Validate different aspects of the user - if err := validateEmail(user.Email); err != nil { - eg.Add(err) - } +eg.Add(err1) +eg.Add(err2) +``` - if err := validatePassword(user.Password); err != nil { - eg.Add(err) - } +`Release()` clears the underlying slice (preserving capacity) and puts the +group back in the pool. Calling `Release()` on a non-pooled group is a no-op. - if err := validateAge(user.Age); err != nil { - eg.Add(err) - } +## Reading the group - return eg.Error() -} +```go +eg.HasErrors() // bool +len(eg.Errors()) // count (clones the slice) +eg.Error() // formatted "N errors occurred:\n..." text +eg.ErrorOrNil() // returns eg if non-empty, else nil +eg.Join() // errors.Join semantics — single, multi-cause error ``` -## Concurrent Operations +`Errors()` returns a defensive copy via `slices.Clone` so callers can't +mutate the group's internal state. + +## `errors.Is` / `errors.As` over a group -Error Groups are particularly useful in concurrent operations. They're designed to be thread-safe and can safely collect errors from multiple goroutines: +`Join()` returns a value compatible with `errors.Join`, so the stdlib walks +through every member: ```go -func processItems(items []Item) error { - pool := ewrap.NewErrorGroupPool(len(items)) - eg := pool.Get() - defer eg.Release() +joined := eg.Join() - var wg sync.WaitGroup +errors.Is(joined, sql.ErrNoRows) // true if any member matches +errors.Is(joined, io.EOF) // ditto +errors.As(joined, &myCustomError) // first matching member fills target +``` - for _, item := range items { - wg.Add(1) - go func(item Item) { - defer wg.Done() +## Concurrent use - if err := processItem(item); err != nil { - eg.Add(ewrap.Wrap(err, fmt.Sprintf("failed to process item %d", item.ID))) - } - }(item) - } +`Add`, `HasErrors`, `Error`, `Errors`, `Join`, and `Clear` are all +goroutine-safe via an internal `sync.RWMutex`. Typical fan-out fan-in: + +```go +eg := pool.Get() +defer eg.Release() + +var wg sync.WaitGroup +for _, item := range items { + wg.Add(1) + go func(it Item) { + defer wg.Done() + eg.Add(process(it)) + }(item) +} +wg.Wait() - wg.Wait() - return eg.Error() +if err := eg.Join(); err != nil { + return err } ``` -## Validation Scenarios +## Serialization -Error Groups excel at collecting validation errors, allowing you to report all validation failures at once rather than stopping at the first error: +`ErrorGroup` implements `json.Marshaler` and `yaml.Marshaler`, plus explicit +`ToJSON()` / `ToYAML()` methods for callers that want the result as a string. ```go -func validateUser(user User) error { - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Validate email format - if !isValidEmail(user.Email) { - eg.Add(ewrap.New("invalid email format", - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) - } +jsonStr, _ := eg.ToJSON() +yamlStr, _ := eg.ToYAML() - // Validate password strength - if !isStrongPassword(user.Password) { - eg.Add(ewrap.New("password too weak", - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) - } +bytes, _ := json.Marshal(eg) // also works +``` - // Validate age - if user.Age < 18 { - eg.Add(ewrap.New("user must be 18 or older", - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) +The serialized payload includes: + +```json +{ + "error_count": 2, + "timestamp": "2026-05-02T10:11:12Z", + "errors": [ + { + "message": "validation failed: missing field 'email'", + "type": "ewrap", + "stack_trace": [ + {"function": "...", "file": "...", "line": 42, "pc": 12345} + ], + "metadata": {"field": "email"}, + "cause": null + }, + { + "message": "EOF", + "type": "standard" } - - return eg.Error() + ] } ``` -## Advanced Usage Patterns +The cause chain is preserved for both `*Error` members and standard wrapped +errors (the serializer walks them via `errors.Unwrap`), so transport +consumers see the full picture. -### Hierarchical Error Collection +## Patterns -You can create hierarchical error structures by nesting error groups: +### Validation pass ```go -func validateOrder(order Order) error { - mainPool := ewrap.NewErrorGroupPool(2) - mainGroup := mainPool.Get() - defer mainGroup.Release() - - // Validate customer details - if err := func() error { - customerPool := ewrap.NewErrorGroupPool(3) - customerGroup := customerPool.Get() - defer customerGroup.Release() - - if err := validateCustomerEmail(order.Customer.Email); err != nil { - customerGroup.Add(err) - } - if err := validateCustomerAddress(order.Customer.Address); err != nil { - customerGroup.Add(err) - } - - return customerGroup.Error() - }(); err != nil { - mainGroup.Add(ewrap.Wrap(err, "customer validation failed")) - } +func validateOrder(o Order) error { + eg := pool.Get() + defer eg.Release() - // Validate order items - if err := func() error { - itemsPool := ewrap.NewErrorGroupPool(len(order.Items)) - itemsGroup := itemsPool.Get() - defer itemsGroup.Release() - - for _, item := range order.Items { - if err := validateOrderItem(item); err != nil { - itemsGroup.Add(err) - } - } - - return itemsGroup.Error() - }(); err != nil { - mainGroup.Add(ewrap.Wrap(err, "order items validation failed")) + if o.Customer == "" { + eg.Add(ewrap.New("missing customer", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError))) + } + if o.Total <= 0 { + eg.Add(ewrap.New("invalid total", + ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError))) } - return mainGroup.Error() + return eg.ErrorOrNil() } ``` -### Error Group with Circuit Breaker - -Combine Error Groups with Circuit Breakers for robust error handling: +### Best-effort cleanup ```go -func processOrderBatch(orders []Order) error { - pool := ewrap.NewErrorGroupPool(len(orders)) +func close(resources []io.Closer) error { eg := pool.Get() defer eg.Release() - cb := ewrap.NewCircuitBreaker("order-processing", 5, time.Minute) - - for _, order := range orders { - if !cb.CanExecute() { - eg.Add(ewrap.New("circuit breaker open: too many failures", - ewrap.WithErrorType(ewrap.ErrorTypeInternal))) - break - } - - if err := processOrder(order); err != nil { - cb.RecordFailure() - eg.Add(ewrap.Wrap(err, fmt.Sprintf("failed to process order %s", order.ID))) - } else { - cb.RecordSuccess() - } + for _, r := range resources { + eg.Add(r.Close()) } - - return eg.Error() + return eg.Join() // single error containing every Close failure } ``` -## Performance Considerations - -The pooled Error Group implementation is designed for high performance, but there are some best practices to follow: - -1. **Choose Appropriate Pool Capacity**: - - ```go - // For known size operations - pool := ewrap.NewErrorGroupPool(len(items)) - - // For variable size operations, estimate typical case - pool := ewrap.NewErrorGroupPool(4) // If you typically expect 1-4 errors - ``` - -1. **Release Groups Properly**: - - ```go - func processWithErrors() error { - eg := pool.Get() - // Always release with defer to prevent leaks - defer eg.Release() - - // Use the error group... - return eg.Error() - } - ``` - -1. **Reuse Pools**: - - ```go - // Good: Create pool once and reuse - var validationPool = ewrap.NewErrorGroupPool(4) +### Mixed wrapping - func validateData(data Data) error { - eg := validationPool.Get() - defer eg.Release() - // Use the error group... - } - - // Less efficient: Creating new pools frequently - func validateData(data Data) error { - pool := ewrap.NewErrorGroupPool(4) // Don't do this - eg := pool.Get() - // ... - } - - ``` - -## Best Practices - -1. **Always Release Error Groups**: - Use defer to ensure Error Groups are always released back to their pool: - - ```go - eg := pool.Get() - defer eg.Release() - ``` - -1. **Size Pools Appropriately**: - Choose pool sizes based on your expected error cases: - - ```go - // For validation where you know the maximum possible errors - pool := ewrap.NewErrorGroupPool(len(validationRules)) - ``` - -1. **Handle Nested Operations**: - When dealing with nested operations, manage Error Groups carefully: - - ```go - func processComplex() error { - outerPool := ewrap.NewErrorGroupPool(2) - outerGroup := outerPool.Get() - defer outerGroup.Release() +```go +eg.Add(ewrap.Wrap(httpErr, "fetching user", + ewrap.WithHTTPStatus(http.StatusBadGateway))) +eg.Add(ewrap.Wrap(dbErr, "loading order")) +eg.Add(io.EOF) // raw stdlib error mixes fine +``` - for _, item := range items { - innerPool := ewrap.NewErrorGroupPool(4) - innerGroup := innerPool.Get() +The serializer normalises all members into the same shape, so consumers +don't need to special-case ewrap vs standard errors. - // Process with inner group... +## Performance - if err := innerGroup.Error(); err != nil { - outerGroup.Add(err) - } - innerGroup.Release() - } +| Operation | ns/op | allocs | +| --- | ---: | ---: | +| `Add` (non-nil) | ~30 | 0 (steady state) | +| `Get` from pool | ~50 | 0 (warm pool) | +| `Error()` (formatted) | varies | 1 (builder) | +| `ToJSON` (10 entries) | ~10 µs | ~30 | - return outerGroup.Error() - } - ``` +The pool eliminates the per-error allocation of the slice header in +hot paths. diff --git a/docs/docs/features/error-wrapping.md b/docs/docs/features/error-wrapping.md index 642fb42..47666de 100644 --- a/docs/docs/features/error-wrapping.md +++ b/docs/docs/features/error-wrapping.md @@ -1,53 +1,24 @@ # Error Wrapping -Error wrapping is a powerful feature that allows you to add context to errors as they propagate through your application. Understanding how to effectively wrap errors can significantly improve your application's debuggability and error handling capabilities. +`Wrap` and `Wrapf` add a layer of context to an existing error while +preserving the cause chain. Each wrap captures its own stack frames, and +inherited metadata stays attached so log records remain useful all the way +out. -## Understanding Error Wrapping - -When an error occurs deep in your application's call stack, it often needs to pass through several layers before being handled. Each layer might need to add its own context to the error, helping to tell the complete story of what went wrong. - -Consider this scenario: +## Signature ```go -func getUserProfile(userID string) (*Profile, error) { - // Low level database error occurs - data, err := db.Query("SELECT * FROM users WHERE id = ?", userID) - if err != nil { - // We wrap the database error with our context - return nil, ewrap.Wrap(err, "failed to fetch user data", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) - } - - // Error occurs during data processing - profile, err := parseUserData(data) - if err != nil { - // We wrap the parsing error with additional context - return nil, ewrap.Wrap(err, "failed to parse user profile", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)). - WithMetadata("user_id", userID) - } - - return profile, nil -} +func Wrap(err error, msg string, opts ...Option) *Error +func Wrapf(err error, format string, args ...any) *Error ``` -## The Wrap Function - -The `Wrap` function is the primary tool for error wrapping. It preserves the original error while adding new context: +Both return `nil` if `err` is `nil`, so you can call them unconditionally: ```go -func Wrap(err error, msg string, opts ...Option) *Error +return ewrap.Wrap(maybeErr, "syncing replicas") // nil-safe ``` -The function takes: - -- The original error -- A message describing what went wrong at this level -- Optional configuration options - -### Basic Usage - -Here's a simple example of error wrapping: +## Basic usage ```go if err := validateInput(data); err != nil { @@ -55,201 +26,148 @@ if err := validateInput(data); err != nil { } ``` -### Adding Context While Wrapping +The returned `*Error`: -You can add rich context while wrapping errors: +- Has its own `Error()` text: `"input validation failed: "`. +- Holds the inner error as its `Cause()`, so `errors.Unwrap`, `errors.Is`, + and `errors.As` walk through it. +- Captures a fresh stack at the wrap site. -```go -if err := processPayment(amount); err != nil { - return ewrap.Wrap(err, "payment processing failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityCritical), - ewrap.WithLogger(logger)). - WithMetadata("amount", amount). - WithMetadata("currency", "USD"). - WithMetadata("processor", "stripe") -} -``` +## Stack semantics -## Error Chain Preservation +```go +root := db.Ping() // io error from net/http +inner := ewrap.Wrap(root, "ping db") // captures wrap site A +outer := ewrap.Wrap(inner, "boot") // captures wrap site B -When you wrap an error, ewrap maintains the entire error chain. This means you can: +inner.Stack() // shows where Wrap was called for `inner` +outer.Stack() // shows where Wrap was called for `outer` +``` -- Access the original error -- See all intermediate wrapping contexts -- Understand the complete error path +`outer.Stack()` and `inner.Stack()` are independent. To see the full +chain in one shot, use the verbose verb: ```go -func main() { - err := processUserRequest() - if err != nil { - // Print the full error chain - fmt.Println(err) - - // Access the root cause - cause := errors.Unwrap(err) - - // Check if a specific error type exists in the chain - if errors.Is(err, sql.ErrNoRows) { - // Handle database not found case - } - } -} +fmt.Printf("%+v\n", outer) +// outer message +// stack of outer ``` -## Formatted Wrapping with Wrapf - -For cases where you need to include formatted messages, use `Wrapf`: +If you need the inner's frames too, walk the chain: ```go -func updateUser(userID string, fields map[string]any) error { - if err := db.Update(userID, fields); err != nil { - return ewrap.Wrapf(err, "failed to update user %s", userID) +for cur := error(outer); cur != nil; cur = errors.Unwrap(cur) { + var ec *ewrap.Error + if errors.As(cur, &ec) { + fmt.Println(ec.Stack()) } - return nil } ``` -## Best Practices for Error Wrapping +## Wrapping inherits typed fields -### 1. Add Meaningful Context +When the inner error is a `*Error`, the wrapper inherits: -Each wrap should add valuable information: +- `metadata` (cloned via `maps.Clone` so wrapper writes don't mutate the inner) +- `errorContext`, `recovery`, `retry` +- `observer`, `logger` +- `httpStatus`, `retryable` -```go -// Good - adds specific context -err = ewrap.Wrap(err, "failed to process monthly report for January 2024", - ewrap.WithMetadata("report_type", "monthly"), - ewrap.WithMetadata("period", "2024-01")) +You can override any of these by passing the corresponding option to `Wrap`. + +## Wrapping standard errors -// Not as helpful - too generic -err = ewrap.Wrap(err, "processing failed") +```go +ewrap.Wrap(io.EOF, "reading body") +ewrap.Wrap(sql.ErrNoRows, "loading user") ``` -### 2. Preserve Error Types +These work like any other wrap; `errors.Is(err, io.EOF)` returns `true` +and serializers walk the cause chain via `errors.Unwrap`. -Choose error types that make sense for the current context: +## `Wrapf` — formatted ```go -func validateAndSaveUser(user User) error { - err := validateUser(user) - if err != nil { - // Preserve validation error type - return ewrap.Wrap(err, "user validation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)) - } +return ewrap.Wrapf(err, "loading row %d for tenant %s", id, tenantID) +``` - err = saveUser(user) - if err != nil { - // Use database error type for storage issues - return ewrap.Wrap(err, "failed to save user", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) - } +If you need the wrapped error to participate in `%w` semantics, pass it +through `Newf` instead, or wrap explicitly: - return nil -} +```go +ewrap.Newf("loading row %d: %w", id, dbErr) ``` -### 3. Use Appropriate Granularity - -Balance between too much and too little information: +## Adding context while wrapping ```go -func processOrder(order Order) error { - // Wrap high-level business operations - if err := validateOrder(order); err != nil { - return ewrap.Wrap(err, "order validation failed") - } - - // Don't wrap every small utility function - total := calculateTotal(order.Items) +return ewrap.Wrap(err, "payment processing failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityCritical), + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true), + ewrap.WithLogger(logger), +). + WithMetadata("amount", amount). + WithMetadata("currency", "USD"). + WithMetadata("processor", "stripe") +``` - // Wrap significant state transitions or external calls - if err := chargeCustomer(order.CustomerID, total); err != nil { - return ewrap.Wrap(err, "payment processing failed", - ewrap.WithMetadata("amount", total), - ewrap.WithMetadata("customer_id", order.CustomerID)) - } +## Conditional wrapping - return nil +```go +err := db.Query(...) +switch { +case errors.Is(err, sql.ErrNoRows): + return ewrap.Wrap(err, "record not found", + ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityWarning), + ewrap.WithHTTPStatus(http.StatusNotFound)) +case errors.Is(err, sql.ErrConnDone): + return ewrap.Wrap(err, "database connection lost", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), + ewrap.WithRetryable(true)) +case err != nil: + return ewrap.Wrap(err, "database operation failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) } ``` -### 4. Consider Performance +## Wrapping inside helpers — `WrapSkip` -While error wrapping is lightweight, be mindful in hot paths: +If you wrap inside a helper, the captured stack starts in the helper rather +than at the call site: ```go -func processItems(items []Item) error { - for _, item := range items { - // In tight loops, consider if wrapping is necessary - if err := validateItem(item); err != nil { - return err // Maybe don't wrap simple validation errors - } - - // Do wrap significant errors - if err := processItem(item); err != nil { - return ewrap.Wrap(err, "item processing failed", - ewrap.WithMetadata("item_id", item.ID)) - } - } - return nil +func wrapDB(err error, msg string) *ewrap.Error { + // BAD: stack starts here + return ewrap.Wrap(err, msg, ewrap.WithContext(...)) } ``` -## Advanced Error Wrapping - -### Conditional Wrapping - -Sometimes you might want to wrap errors differently based on their type: +Use `WrapSkip(skip, ...)` to advance past the helper frames: ```go -func handleDatabaseOperation() error { - err := db.Query() - if err != nil { - switch { - case errors.Is(err, sql.ErrNoRows): - return ewrap.Wrap(err, "record not found", - ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityWarning)) - case errors.Is(err, sql.ErrConnDone): - return ewrap.Wrap(err, "database connection lost", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) - default: - return ewrap.Wrap(err, "database operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) - } - } - return nil +func wrapDB(err error, msg string) *ewrap.Error { + return ewrap.WrapSkip(1, err, msg, ewrap.WithContext(...)) } ``` -### Multi-Level Wrapping - -For complex operations, you might wrap errors multiple times: - -```go -func processUserOrder(ctx context.Context, userID, orderID string) error { - user, err := getUser(userID) - if err != nil { - return ewrap.Wrap(err, "failed to get user", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) - } +The same pattern works for `New` via `NewSkip`. - order, err := getOrder(orderID) - if err != nil { - return ewrap.Wrap(err, "failed to get order", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityError)) - } +## Best practices - if err := validateUserCanAccessOrder(user, order); err != nil { - return ewrap.Wrap(err, "user not authorized to access order", - ewrap.WithContext(ctx, ewrap.ErrorTypePermission, ewrap.SeverityWarning)) - } +- **One wrap per layer.** Don't wrap the same error twice in the same + function; that just doubles the message. +- **Add information, not noise.** A useful wrap message points at *what + this layer was doing*, not just that it failed. +- **Use `errors.Is/As` for branching, not string matching** on the rendered + message. +- **Don't wrap simple validation errors** in tight loops if you don't add + context — return the inner error directly. - if err := processOrderPayment(order); err != nil { - return ewrap.Wrap(err, "order payment failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityCritical)) - } +## Performance - return nil -} -``` +- A single `Wrap` allocates the `*Error` struct plus the stack PCs slice + (~2 allocations). +- Inherited metadata is cloned shallowly via `maps.Clone`. +- `(*Error).Error()` and `Stack()` are cached on first call; subsequent + reads on the wrapped error are lock-free. diff --git a/docs/docs/features/format-and-slog.md b/docs/docs/features/format-and-slog.md new file mode 100644 index 0000000..88fb27d --- /dev/null +++ b/docs/docs/features/format-and-slog.md @@ -0,0 +1,109 @@ +# `fmt.Formatter` and `slog.LogValuer` + +`*Error` implements both stdlib hooks, so it formats nicely with +`fmt.Printf` and emits structured fields directly into `log/slog`. + +## `fmt.Formatter` + +```go +err := ewrap.New("boom").WithMetadata("k", "v") + +fmt.Printf("%s\n", err) // boom +fmt.Printf("%v\n", err) // boom +fmt.Printf("%q\n", err) // "boom" +fmt.Printf("%+v\n", err) // boom\n\n +``` + +| Verb | Output | +| --- | --- | +| `%s` | `Error()` text | +| `%v` | `Error()` text | +| `%q` | quoted `Error()` text | +| `%+v` | `Error()` + newline + `Stack()` | + +The `%+v` variant is the canonical pkg/errors-style "pretty-print with +stack" for log/dev output. Both `Error()` and `Stack()` are cached, so +formatting the same error multiple times is essentially free after the +first call. + +### Implementation sketch + +```go +func (e *Error) Format(state fmt.State, verb rune) { + switch verb { + case 'v': + if state.Flag('+') { + fmt.Fprintf(state, "%s\n%s", e.Error(), e.Stack()) + return + } + fmt.Fprint(state, e.Error()) + case 'q': + fmt.Fprintf(state, "%q", e.Error()) + default: + fmt.Fprint(state, e.Error()) + } +} +``` + +## `slog.LogValuer` + +```go +slog.Error("payment failed", "err", err) +``` + +Without `LogValuer`, `slog` would render `err` as an opaque string. With +it, the handler receives a structured group: + +```text +level=ERROR msg="payment failed" err.message="boom" + err.type=external err.severity=error err.component=billing + err.request_id=req-123 err.cause="net/http: Bad Gateway" + err.recovery="Retry after backoff." err.k=v +``` + +The emitted attribute set: + +| Key | When | +| --- | --- | +| `message` | always — `e.Error()` | +| `type` | if `WithContext` was used | +| `severity` | if `WithContext` was used | +| `component` | if `ErrorContext.Component` is non-empty | +| `operation` | if `ErrorContext.Operation` is non-empty | +| `request_id` | if `ErrorContext.RequestID` is non-empty | +| `recovery` | if `WithRecoverySuggestion` was used | +| `cause` | if the error has a cause — `cause.Error()` | +| _user metadata_ | one attribute per metadata key | + +The whole payload is wrapped in an `slog.GroupValue`, so it appears under +the attribute key you used at the call site (`err` in the example). + +### When you only have `slog`, you don't need an adapter + +`*Error` satisfies `slog.LogValuer` directly, so any `*slog.Logger` will +render it correctly: + +```go +slog.New(slog.NewJSONHandler(os.Stdout, nil)). + With("service", "billing"). + Error("payment failed", "err", err) +``` + +If you want the inverse (use `*slog.Logger` as an `ewrap.Logger`), import +the [`ewrap/slog`](slog-adapter.md) subpackage. + +## Performance + +Both `Format` and `LogValue` reuse the cached `Error()` and `Stack()` +strings. After the first call: + +- `fmt.Sprintf("%v", err)` — one cached string read, no extra allocations +- `fmt.Sprintf("%+v", err)` — one cached message read + one cached stack + read, joined into a single output buffer +- `slog.Error("...", "err", err)` — `LogValue` builds the attribute slice + fresh per call (since metadata is mutable), but the per-attribute + strings come from the cached values + +For high-volume hot paths where you log the same error many times, prefer +`%v` (no stack) over `%+v` (with stack) — the latter writes the full +trace each time even though it's read from cache. diff --git a/docs/docs/features/logging.md b/docs/docs/features/logging.md index bc80573..05993d4 100644 --- a/docs/docs/features/logging.md +++ b/docs/docs/features/logging.md @@ -1,14 +1,12 @@ # Logging Integration -When errors occur in your application, having detailed, structured logs is crucial for understanding what went wrong and why. The ewrap package provides a flexible logging system that integrates seamlessly with popular logging frameworks while maintaining a clean, consistent interface for error logging. +ewrap defines a tiny three-method `Logger` interface and ships a single +adapter (for stdlib `log/slog`) in a sibling subpackage. Adapters for +zap, zerolog, logrus, glog, etc. are intentionally **not** bundled: the +interface is so small that a working adapter is well under ten lines of +your own code. -## Understanding Logging in ewrap - -The logging system in ewrap is built around a simple yet powerful interface that can adapt to any logging framework. When an error occurs, ewrap can automatically log not just the error message, but also the stack trace, metadata, and contextual information that helps tell the complete story of what happened. - -## The Logger Interface - -Let's start by understanding the core logging interface: +## The interface ```go type Logger interface { @@ -18,306 +16,155 @@ type Logger interface { } ``` -This interface is intentionally simple to make it easy to adapt any logging framework to work with ewrap. The variadic `keysAndValues` parameter allows for structured logging where key-value pairs provide additional context. - -## Built-in Logging Adapters - -ewrap provides adapters for popular logging frameworks. Here's how to use them: +`keysAndValues` is the standard structured-logging convention: alternating +key/value pairs after the message. Implementations must be goroutine-safe +because `(*Error).Log` calls them synchronously from the calling +goroutine. -### Zap Logger Integration +## Attaching a logger ```go -import ( - "go.uber.org/zap" - "github.com/hyp3rd/ewrap/pkg/logger/adapters" -) - -func setupZapLogger() error { - // Create a production-ready Zap logger - zapLogger, err := zap.NewProduction() - if err != nil { - return err - } - - // Create the adapter - logger := adapters.NewZapAdapter(zapLogger) - - // Create an error with logging enabled - err = ewrap.New("operation failed", - ewrap.WithLogger(logger), - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)). - WithMetadata("operation", "user_update"). - WithMetadata("user_id", userID) - - // The error will be automatically logged with all context - return err -} +err := ewrap.New("payment failed", ewrap.WithLogger(logger)) +err.Log() // emits an "error occurred" record with all attached fields ``` -### Logrus Integration +`(*Error).Log` emits a single record containing: -```go -import ( - "github.com/sirupsen/logrus" - "github.com/hyp3rd/ewrap/pkg/logger/adapters" -) - -func setupLogrusLogger() error { - // Configure Logrus - logrusLogger := logrus.New() - logrusLogger.SetFormatter(&logrus.JSONFormatter{}) +- `error` — the message +- `cause` — `e.cause.Error()` if the chain has one +- `stack` — formatted stack trace +- every key/value from the metadata map +- `recovery_message`, `recovery_actions`, `recovery_documentation` if + `WithRecoverySuggestion` was used - // Create the adapter - logger := adapters.NewLogrusAdapter(logrusLogger) +The logger reference is also inherited by `Wrap` when the inner error is +already a `*Error`, so a single `WithLogger` near the root propagates out. - // Use the logger with ewrap - return ewrap.New("database connection failed", - ewrap.WithLogger(logger), - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) -} -``` +## Slog adapter -### Zerolog Integration +Stdlib `log/slog` is the recommended target for new projects. The adapter +is in [`ewrap/slog`](slog-adapter.md): ```go import ( - "github.com/rs/zerolog" - "github.com/hyp3rd/ewrap/pkg/logger/adapters" -) + "log/slog" + "os" -func setupZerolog() error { - // Configure Zerolog - zerologLogger := zerolog.New(os.Stdout).With().Timestamp().Logger() + "github.com/hyp3rd/ewrap" + ewrapslog "github.com/hyp3rd/ewrap/slog" +) - // Create the adapter - logger := adapters.NewZerologAdapter(zerologLogger) +handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) +logger := ewrapslog.New(slog.New(handler)) - // Use with ewrap - return ewrap.New("request validation failed", - ewrap.WithLogger(logger), - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityWarning)) -} +err := ewrap.New("payment failed", ewrap.WithLogger(logger)) +err.Log() ``` -### Slog Integration (Go 1.21+) +If you'd rather log the error directly via `slog`, you don't need an +adapter at all — `*Error` implements `slog.LogValuer`: ```go -import ( - "log/slog" - "os" - "github.com/hyp3rd/ewrap/pkg/logger/adapters" -) - -func setupSlogLogger() error { - slogLogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) - logger := adapters.NewSlogAdapter(slogLogger) - - return ewrap.New("operation failed", - ewrap.WithLogger(logger)) -} +slog.Error("payment failed", "err", err) +// emits structured fields: message, type, severity, request_id, cause, +// metadata, recovery — all without the adapter ``` -## Advanced Logging Patterns +See [fmt.Formatter & slog](format-and-slog.md) for the `LogValuer` details. -### Contextual Logging +## Writing an adapter for another logger -Here's how to create rich, contextual logs that capture the full story of an error: +The whole adapter is three methods. Here's zap: ```go -func processOrder(ctx context.Context, order Order) error { - logger := getContextLogger(ctx) - - // Create an operation logger that will track the entire process - opLogger := &OperationLogger{ - Logger: logger, - Operation: "process_order", - StartTime: time.Now(), - Context: map[string]any{ - "order_id": order.ID, - "user_id": order.UserID, - }, - } - - // Log operation start - opLogger.Info("starting order processing") - - if err := validateOrder(order); err != nil { - return ewrap.Wrap(err, "order validation failed", - ewrap.WithLogger(opLogger), - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)). - WithMetadata("validation_time", time.Since(opLogger.StartTime)) - } - - if err := processPayment(order); err != nil { - return ewrap.Wrap(err, "payment processing failed", - ewrap.WithLogger(opLogger), - ewrap.WithContext(ctx, ewrap.ErrorTypePayment, ewrap.SeverityCritical)). - WithMetadata("processing_time", time.Since(opLogger.StartTime)) - } +import "go.uber.org/zap" - // Log successful completion - opLogger.Info("order processed successfully", - "duration", time.Since(opLogger.StartTime)) +type ZapAdapter struct{ l *zap.SugaredLogger } - return nil -} +func NewZap(l *zap.Logger) *ZapAdapter { return &ZapAdapter{l: l.Sugar()} } +func (a *ZapAdapter) Error(msg string, kv ...any) { a.l.Errorw(msg, kv...) } +func (a *ZapAdapter) Debug(msg string, kv ...any) { a.l.Debugw(msg, kv...) } +func (a *ZapAdapter) Info(msg string, kv ...any) { a.l.Infow(msg, kv...) } ``` -### Creating a Custom Logger - -You might want to create a custom logger that adds specific functionality: +logrus: ```go -type CustomLogger struct { - logger Logger - component string - env string -} +import "github.com/sirupsen/logrus" -func NewCustomLogger(baseLogger Logger, component string) *CustomLogger { - return &CustomLogger{ - logger: baseLogger, - component: component, - env: os.Getenv("APP_ENV"), - } -} +type LogrusAdapter struct{ l *logrus.Logger } -func (l *CustomLogger) Error(msg string, keysAndValues ...any) { - // Add standard context to all error logs - enrichedKV := append([]any{ - "component", l.component, - "environment", l.env, - "timestamp", time.Now().UTC(), - }, keysAndValues...) +func NewLogrus(l *logrus.Logger) *LogrusAdapter { return &LogrusAdapter{l: l} } - l.logger.Error(msg, enrichedKV...) -} - -func (l *CustomLogger) Debug(msg string, keysAndValues ...any) { - enrichedKV := append([]any{ - "component", l.component, - "environment", l.env, - }, keysAndValues...) - - l.logger.Debug(msg, enrichedKV...) +func (a *LogrusAdapter) emit(level logrus.Level, msg string, kv []any) { + fields := logrus.Fields{} + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok { + fields[k] = kv[i+1] + } + } + a.l.WithFields(fields).Log(level, msg) } -func (l *CustomLogger) Info(msg string, keysAndValues ...any) { - enrichedKV := append([]any{ - "component", l.component, - "environment", l.env, - }, keysAndValues...) - - l.logger.Info(msg, enrichedKV...) -} +func (a *LogrusAdapter) Error(msg string, kv ...any) { a.emit(logrus.ErrorLevel, msg, kv) } +func (a *LogrusAdapter) Debug(msg string, kv ...any) { a.emit(logrus.DebugLevel, msg, kv) } +func (a *LogrusAdapter) Info(msg string, kv ...any) { a.emit(logrus.InfoLevel, msg, kv) } ``` -### Logging with Circuit Breakers - -Combining logging with circuit breakers provides insight into system health: +zerolog: ```go -type MonitoredCircuitBreaker struct { - *ewrap.CircuitBreaker - logger Logger - name string -} +import "github.com/rs/zerolog" -func NewMonitoredCircuitBreaker(name string, maxFailures int, timeout time.Duration, logger Logger) *MonitoredCircuitBreaker { - cb := ewrap.NewCircuitBreaker(name, maxFailures, timeout) - return &MonitoredCircuitBreaker{ - CircuitBreaker: cb, - logger: logger, - name: name, - } -} +type ZerologAdapter struct{ l zerolog.Logger } -func (m *MonitoredCircuitBreaker) RecordFailure() { - m.CircuitBreaker.RecordFailure() - m.logger.Error("circuit breaker failure recorded", - "breaker_name", m.name, - "current_state", "open", - "timestamp", time.Now()) -} +func NewZerolog(l zerolog.Logger) *ZerologAdapter { return &ZerologAdapter{l: l} } -func (m *MonitoredCircuitBreaker) RecordSuccess() { - m.CircuitBreaker.RecordSuccess() - m.logger.Info("circuit breaker success recorded", - "breaker_name", m.name, - "current_state", "closed", - "timestamp", time.Now()) +func (a *ZerologAdapter) emit(ev *zerolog.Event, msg string, kv []any) { + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok { + ev = ev.Interface(k, kv[i+1]) + } + } + ev.Msg(msg) } -``` -## Best Practices - -### 1. Structured Logging - -Always use structured logging for better searchability: - -```go -// Good - structured logging -logger.Error("database query failed", - "query", queryString, - "duration_ms", duration.Milliseconds(), - "affected_rows", 0) - -// Avoid - unstructured logging -logger.Error(fmt.Sprintf("database query failed: %s (took %v)", - queryString, duration)) +func (a *ZerologAdapter) Error(msg string, kv ...any) { a.emit(a.l.Error(), msg, kv) } +func (a *ZerologAdapter) Debug(msg string, kv ...any) { a.emit(a.l.Debug(), msg, kv) } +func (a *ZerologAdapter) Info(msg string, kv ...any) { a.emit(a.l.Info(), msg, kv) } ``` -### 2. Consistent Log Levels +Drop one of these into your codebase, pass an instance to `WithLogger`, +and you're done. ewrap stays free of those dependencies. -Use appropriate log levels consistently: +## Recovery suggestions in log output -```go -// Error - for actual errors -logger.Error("failed to process payment", - "error", err, - "user_id", userID) - -// Debug - for detailed troubleshooting information -logger.Debug("attempting payment processing", - "payment_provider", provider, - "amount", amount) - -// Info - for tracking normal operations -logger.Info("payment processed successfully", - "transaction_id", txID, - "amount", amount) -``` - -### 3. Context Preservation - -Ensure context is preserved through the logging chain: +When you attach a `RecoverySuggestion`, `(*Error).Log` automatically +expands it into structured fields: ```go -func processWithContext(ctx context.Context) error { - logger := getContextLogger(ctx) - - // Add request-specific context - requestLogger := enrichLoggerWithContext(logger, ctx) - - err := performOperation() - if err != nil { - return ewrap.Wrap(err, "operation failed", - ewrap.WithLogger(requestLogger), - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)) - } - - return nil -} - -func enrichLoggerWithContext(logger Logger, ctx context.Context) Logger { - // Extract common context values - requestID := ctx.Value("request_id") - userID := ctx.Value("user_id") - - return &ContextLogger{ - base: logger, - requestID: requestID.(string), - userID: userID.(string), - } -} +err := ewrap.New("DB unreachable", + ewrap.WithLogger(logger), + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Verify pool sizing and credentials.", + Actions: []string{"reset pool", "rotate creds"}, + Documentation: "https://runbooks.example.com/db", + })) + +err.Log() +// Fields emitted: error, stack, recovery_message, recovery_actions, +// recovery_documentation ``` + +## Best practices + +- **Set the logger near the root** so wraps inherit it, instead of + threading `WithLogger` through every layer. +- **Don't log inside libraries** — return the error and let the caller + decide. `WithLogger` is for application-layer code. +- **Use slog directly** for new projects unless you've already standardised + on another logger. `LogValuer` gives you fully structured output with no + adapter at all. +- **Keep adapters in a single internal package** in your own repo so all + of your services share the same logger choice without ewrap having to + pick one. diff --git a/docs/docs/features/metadata.md b/docs/docs/features/metadata.md index a7e2583..eea47f3 100644 --- a/docs/docs/features/metadata.md +++ b/docs/docs/features/metadata.md @@ -1,275 +1,162 @@ -# Error Metadata +# Metadata -Metadata is additional information attached to errors that provides crucial context for debugging and error handling. Think of metadata as tags or labels that give you deeper insight into what was happening when an error occurred. In ewrap, metadata is implemented as a flexible key-value store that travels with the error through your application. +ewrap separates **user metadata** (a string-keyed map you control) from +**reserved typed fields** (`ErrorContext`, `RecoverySuggestion`, +`RetryInfo`). Each lives in its own slot so a stray `WithMetadata` key +can't silently overwrite the structured fields. -## Understanding Error Metadata +## User metadata -When an error occurs, the error message alone often doesn't tell the complete story. For example, if a database query fails, you might want to know: +Use `WithMetadata` to attach arbitrary key/value data: -- What query was being executed? -- How long did it take before failing? -- What parameters were used? -- How many retries were attempted? - -Metadata allows you to capture all this contextual information in a structured way. - -## Basic Metadata Usage +```go +err := ewrap.New("checkout failed"). + WithMetadata("order_id", orderID). + WithMetadata("attempt", 2). + WithMetadata("provider", "stripe") +``` -Let's start with the fundamentals of adding and retrieving metadata: +Read it back with `GetMetadata`: ```go -func processUserOrder(userID string, orderID string) error { - err := processOrder(orderID) - if err != nil { - return ewrap.Wrap(err, "failed to process order"). - WithMetadata("user_id", userID). - WithMetadata("order_id", orderID). - WithMetadata("timestamp", time.Now()). - WithMetadata("attempt", 1) - } - return nil -} +val, ok := err.GetMetadata("order_id") ``` -Retrieving metadata is just as straightforward: +Or with the generic, type-checked variant: ```go -func handleError(err error) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - // Get specific metadata values - if userID, exists := wrappedErr.GetMetadata("user_id"); exists { - fmt.Printf("Error occurred for user: %v\n", userID) - } - - // Log all metadata for debugging - if timestamp, exists := wrappedErr.GetMetadata("timestamp"); exists { - fmt.Printf("Error occurred at: %v\n", timestamp) - } - } -} +attempt, ok := ewrap.GetMetadataValue[int](err, "attempt") +provider, ok := ewrap.GetMetadataValue[string](err, "provider") ``` -## Structured Metadata Patterns +`GetMetadataValue` returns the zero value of `T` and `false` if the key is +missing or the stored value isn't of type `T`. + +### Lazy allocation + +The metadata map is **not allocated until the first write**. An error that +never gets metadata pays nothing for the field beyond the nil slice header. + +### Concurrent reads and writes -While metadata values can be of any type, it's often helpful to use structured data for complex information: +`WithMetadata`, `GetMetadata`, and `GetMetadataValue` are protected by a +`sync.RWMutex`, so concurrent use across goroutines is safe. + +## Reserved typed fields + +These slots have dedicated options and accessors. They never appear in the +user metadata map. + +### `ErrorContext` + +Captured via `WithContext(ctx, type, severity)`: ```go -type QueryMetadata struct { - SQL string - Parameters []any - Duration time.Duration - Table string -} - -func executeQuery(query string, params ...any) error { - start := time.Now() - - result, err := db.Exec(query, params...) - if err != nil { - queryMeta := QueryMetadata{ - SQL: query, - Parameters: params, - Duration: time.Since(start), - Table: extractTableName(query), - } - - return ewrap.Wrap(err, "database query failed"). - WithMetadata("query_info", queryMeta). - WithMetadata("query_attempt", 1) - } - - return nil -} +err := ewrap.New("payment failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError)) + +ec := err.GetErrorContext() +// ec.Type, ec.Severity, ec.RequestID, ec.User, ec.Operation, ec.Component, +// ec.Environment, ec.Timestamp, ec.File, ec.Line, ec.Data ``` -## Dynamic Metadata Collection +`WithContext` reads `request_id`, `user`, `operation`, and `component` +out of the supplied `context.Context` if those keys are present. -Sometimes you need to build metadata progressively as an operation proceeds: +You can also attach a pre-built `ErrorContext` after construction: ```go -func processComplexOperation(ctx context.Context, data []byte) error { - // Create error with initial metadata - err := ewrap.New("starting complex operation", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityInfo)). - WithMetadata("start_time", time.Now()). - WithMetadata("data_size", len(data)) - - // Process stages and collect metadata - stages := []string{"validation", "transformation", "storage"} - metrics := make(map[string]time.Duration) - - for _, stage := range stages { - stageStart := time.Now() - - if err := processStage(stage, data); err != nil { - // Add stage-specific metadata to error - return ewrap.Wrap(err, fmt.Sprintf("%s stage failed", stage)). - WithMetadata("failed_stage", stage). - WithMetadata("stage_metrics", metrics). - WithMetadata("stage_duration", time.Since(stageStart)) - } - - metrics[stage] = time.Since(stageStart) - } - - return nil -} +err.WithContext(&ewrap.ErrorContext{Type: ewrap.ErrorTypeNetwork}) ``` -## Metadata for Debugging and Monitoring - -Metadata is particularly valuable for debugging and monitoring. Here's a pattern that combines metadata with logging: +### `RecoverySuggestion` ```go -type OperationTracker struct { - StartTime time.Time - Steps []string - Metrics map[string]any - Attributes map[string]string -} - -func NewOperationTracker() *OperationTracker { - return &OperationTracker{ - StartTime: time.Now(), - Steps: make([]string, 0), - Metrics: make(map[string]any), - Attributes: make(map[string]string), - } -} - -func (ot *OperationTracker) AddStep(step string) { - ot.Steps = append(ot.Steps, step) -} - -func (ot *OperationTracker) AddMetric(key string, value any) { - ot.Metrics[key] = value -} - -func processWithTracking(ctx context.Context, data []byte) error { - tracker := NewOperationTracker() - - // Track operation progress - err := func() error { - tracker.AddStep("initialization") - - if err := validate(data); err != nil { - return ewrap.Wrap(err, "validation failed"). - WithMetadata("tracker", tracker) - } - tracker.AddStep("validation") - - if err := transform(data); err != nil { - return ewrap.Wrap(err, "transformation failed"). - WithMetadata("tracker", tracker) - } - tracker.AddStep("transformation") - - tracker.AddMetric("processing_time", time.Since(tracker.StartTime)) - return nil - }() - - if err != nil { - return ewrap.Wrap(err, "operation failed"). - WithMetadata("final_state", tracker) - } - - return nil -} +err := ewrap.New("DB unreachable", + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Check connectivity and pool sizing.", + Actions: []string{"reset pool", "verify network"}, + Documentation: "https://runbooks.example.com/db", + })) + +rs := err.Recovery() ``` -## Metadata Best Practices +When the error is logged via `(*Error).Log`, the recovery suggestion is +emitted as `recovery_message`, `recovery_actions`, and +`recovery_documentation` fields. -### 1. Keep Metadata Serializable +### `RetryInfo` -Ensure your metadata can be properly serialized when needed: +```go +err := ewrap.New("upstream timeout", + ewrap.WithRetry(3, 5*time.Second)) + +ri := err.Retry() // *RetryInfo, or nil if not set +err.CanRetry() // checks attempts vs ShouldRetry predicate +err.IncrementRetry() +``` + +Customise the retry predicate: ```go -// Good - uses simple types -err = ewrap.New("processing failed"). - WithMetadata("count", 42). - WithMetadata("status", "incomplete") - -// Better - uses structured data that can be serialized -type ProcessMetadata struct { - Count int `json:"count"` - Status string `json:"status"` - Duration string `json:"duration"` -} - -meta := ProcessMetadata{ - Count: 42, - Status: "incomplete", - Duration: time.Since(start).String(), -} - -err = ewrap.New("processing failed"). - WithMetadata("process_info", meta) +err := ewrap.New("rate limited", + ewrap.WithRetry(5, 2*time.Second, + ewrap.WithRetryShould(func(e error) bool { + return ewrap.IsRetryable(e) + }))) ``` -### 2. Use Consistent Keys +The default predicate returns `true` unless the error's `ErrorContext.Type` +is `ErrorTypeValidation`. + +## Why typed fields? + +The previous design stored these under reserved string keys +(`"error_context"`, `"recovery_suggestion"`, `"retry_info"`) in the same +map as user metadata. That made it possible — and easy — to silently +corrupt them with a stray `WithMetadata("error_context", ...)` call. + +Lifting them to typed fields: -Maintain consistent metadata keys across your application: +- Eliminates that footgun. +- Makes the API self-documenting — the type system shows exactly what a + recovery suggestion looks like. +- Avoids the runtime cost of a type assertion on every read. + +## Inheritance through `Wrap` + +When `Wrap` is given a `*Error`, the wrapper inherits **all** typed fields +plus a clone of the metadata map: ```go -// Define common metadata keys as constants -const ( - MetaKeyUserID = "user_id" - MetaKeyRequestID = "request_id" - MetaKeyDuration = "duration" - MetaKeyRetryCount = "retry_count" -) - -func processRequest(ctx context.Context, userID string) error { - requestID := ctx.Value("request_id").(string) - start := time.Now() - - err := performOperation() - if err != nil { - return ewrap.Wrap(err, "operation failed"). - WithMetadata(MetaKeyUserID, userID). - WithMetadata(MetaKeyRequestID, requestID). - WithMetadata(MetaKeyDuration, time.Since(start)) - } - return nil -} -``` +inner := ewrap.New("DB error", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), + ewrap.WithRetryable(true)). + WithMetadata("query", q) -### 3. Structure Complex Data +outer := ewrap.Wrap(inner, "loading user") -For complex metadata, use structured types: +outer.GetErrorContext() // inherited +outer.Retryable() // inherited +outer.GetMetadata("query") // inherited via maps.Clone +``` + +Pass an option to `Wrap` to override: ```go -type HTTPRequestMetadata struct { - Method string - URL string - StatusCode int - Duration time.Duration - Headers map[string][]string -} - -func makeAPICall(ctx context.Context, req *http.Request) error { - start := time.Now() - resp, err := http.DefaultClient.Do(req) - - requestMeta := HTTPRequestMetadata{ - Method: req.Method, - URL: req.URL.String(), - Duration: time.Since(start), - } - - if err != nil { - return ewrap.Wrap(err, "API call failed"). - WithMetadata("request_details", requestMeta) - } - - requestMeta.StatusCode = resp.StatusCode - requestMeta.Headers = resp.Header - - if resp.StatusCode >= 400 { - return ewrap.New("API returned error status"). - WithMetadata("request_details", requestMeta) - } - - return nil -} +outer := ewrap.Wrap(inner, "loading user", + ewrap.WithContext(ctx, ewrap.ErrorTypeNotFound, ewrap.SeverityWarning)) ``` + +## Cheat sheet + +| Concept | Set with | Read with | +| --- | --- | --- | +| User metadata (untyped) | `WithMetadata(key, value)` | `GetMetadata(key)` / `GetMetadataValue[T]` | +| Error context | `WithContext(ctx, type, sev)` option / `(*Error).WithContext(ec)` method | `GetErrorContext()` | +| Recovery guidance | `WithRecoverySuggestion(rs)` | `Recovery()` | +| Retry info | `WithRetry(max, delay, opts...)` | `Retry()` / `CanRetry()` / `IncrementRetry()` | +| HTTP status | `WithHTTPStatus(code)` | `ewrap.HTTPStatus(err)` | +| Retryable flag | `WithRetryable(bool)` | `(*Error).Retryable()` / `ewrap.IsRetryable(err)` | +| Safe message | `WithSafeMessage(s)` | `(*Error).SafeError()` | diff --git a/docs/docs/features/observability.md b/docs/docs/features/observability.md index 0946ce4..d88d908 100644 --- a/docs/docs/features/observability.md +++ b/docs/docs/features/observability.md @@ -1,360 +1,111 @@ -# Observability and Monitoring +# Observability -ewrap provides comprehensive observability features that allow you to monitor error patterns, track system health, and gain insights into application behavior. These features are designed to integrate seamlessly with modern monitoring and alerting systems. +ewrap exposes a single, deliberately small `Observer` interface for +errors. The matching observer for the circuit breaker lives in the +[`breaker`](circuit-breaker.md) subpackage. Both are plain interfaces — wire +them to whatever metrics, tracing, or alerting backend you use. -## Observer Interface - -The Observer interface allows you to receive notifications about various error-related events in your application: +## `ewrap.Observer` ```go type Observer interface { - OnErrorCreated(err *Error, context ErrorContext) - OnCircuitBreakerStateChange(name string, from, to CircuitState) - OnRecoverySuggestionTriggered(suggestion string, context ErrorContext) + RecordError(message string) } ``` -## Setting Up Observability - -### Global Observer Registration +A single method. Implementations must be goroutine-safe because +`(*Error).Log` calls them synchronously from the calling goroutine. -Register observers globally to monitor all error activity: +## Attaching an observer ```go -// Create a custom observer -type MetricsObserver struct { - metricsClient *prometheus.Client - logger *slog.Logger -} - -func (m *MetricsObserver) OnErrorCreated(err *Error, context ErrorContext) { - // Track error frequency by type - m.metricsClient.Counter("errors_total"). - WithLabelValues(string(err.ErrorType()), string(err.Severity())). - Inc() - - // Log structured error information - m.logger.Error("error created", - "type", err.ErrorType(), - "severity", err.Severity(), - "message", err.Error(), - "context", context) +type metricsObserver struct { + counter *prometheus.CounterVec } -func (m *MetricsObserver) OnCircuitBreakerStateChange(name string, from, to CircuitState) { - // Track circuit breaker state transitions - m.metricsClient.Counter("circuit_breaker_state_changes"). - WithLabelValues(name, string(from), string(to)). - Inc() - - // Alert on circuit breaker openings - if to == CircuitStateOpen { - m.logger.Warn("circuit breaker opened", - "name", name, - "previous_state", from) - } +func (m *metricsObserver) RecordError(message string) { + m.counter.WithLabelValues(message).Inc() } -func (m *MetricsObserver) OnRecoverySuggestionTriggered(suggestion string, context ErrorContext) { - // Track recovery suggestion effectiveness - m.metricsClient.Counter("recovery_suggestions_total"). - WithLabelValues(suggestion). - Inc() -} - -// Register the observer globally -ewrap.RegisterGlobalObserver(&MetricsObserver{ - metricsClient: prometheusClient, - logger: slogLogger, -}) -``` - -### Component-Specific Observers - -Attach observers to specific components: - -```go -// Create circuit breaker with observer -cb := ewrap.NewCircuitBreaker("payment-service", 5, time.Minute*2, - ewrap.WithObserver(&PaymentServiceObserver{ - alertManager: alertMgr, - metrics: metrics, - })) - -// Observer for specific circuit breaker -type PaymentServiceObserver struct { - alertManager *AlertManager - metrics *Metrics -} - -func (p *PaymentServiceObserver) OnCircuitBreakerStateChange(name string, from, to CircuitState) { - if to == CircuitStateOpen { - p.alertManager.SendAlert(&Alert{ - Severity: "critical", - Summary: fmt.Sprintf("Payment service circuit breaker opened"), - Details: map[string]string{ - "service": name, - "previous_state": string(from), - "current_state": string(to), - "timestamp": time.Now().Format(time.RFC3339), - }, - }) - } -} +err := ewrap.New("payment failed", ewrap.WithObserver(&metricsObserver{counter: errCounter})) +err.Log() // observer.RecordError("payment failed") ``` -## Error Pattern Analysis +The observer reference is inherited by `Wrap` when the inner error is a +`*Error`, so attaching once at the root applies to every layer that's +later wrapped. -### Frequency Tracking +## Pairing with a logger -Monitor error patterns to identify systemic issues: +`Observer` and `Logger` are independent — you can attach either, both, or +neither. A common setup: ```go -type ErrorAnalyzer struct { - errorCounts map[string]int - errorWindows map[string][]time.Time - mutex sync.RWMutex -} - -func (ea *ErrorAnalyzer) OnErrorCreated(err *Error, context ErrorContext) { - ea.mutex.Lock() - defer ea.mutex.Unlock() - - errorKey := fmt.Sprintf("%s:%s", err.ErrorType(), err.Severity()) - - // Track error counts - ea.errorCounts[errorKey]++ - - // Track error timing for rate analysis - now := time.Now() - ea.errorWindows[errorKey] = append(ea.errorWindows[errorKey], now) - - // Clean old entries (keep last hour) - cutoff := now.Add(-time.Hour) - filtered := ea.errorWindows[errorKey][:0] - for _, timestamp := range ea.errorWindows[errorKey] { - if timestamp.After(cutoff) { - filtered = append(filtered, timestamp) - } - } - ea.errorWindows[errorKey] = filtered - - // Check for error spikes - if len(ea.errorWindows[errorKey]) > 100 { // More than 100 errors in last hour - ea.triggerErrorSpike(errorKey, len(ea.errorWindows[errorKey])) - } -} - -func (ea *ErrorAnalyzer) triggerErrorSpike(errorKey string, count int) { - log.Printf("ERROR SPIKE DETECTED: %s - %d errors in last hour", errorKey, count) -} +err := ewrap.New("payment failed", + ewrap.WithLogger(logger), // structured log output + ewrap.WithObserver(metrics), // metric increment +) +err.Log() ``` -## Circuit Breaker Monitoring +`(*Error).Log` first calls `Observer.RecordError`, then writes the +structured log record. Either is a no-op if not configured. -### State Transition Tracking +## Tracing integration -Monitor circuit breaker health and performance: +You can wire OpenTelemetry, Datadog, or any other tracer through the same +interface: ```go -type CircuitBreakerMonitor struct { - stateHistory map[string][]StateTransition - healthMetrics map[string]*HealthMetrics - mutex sync.RWMutex -} - -type StateTransition struct { - From CircuitState - To CircuitState - Timestamp time.Time -} - -type HealthMetrics struct { - TotalRequests int64 - SuccessfulReqs int64 - FailedRequests int64 - OpenDuration time.Duration - LastStateChange time.Time -} - -func (cbm *CircuitBreakerMonitor) OnCircuitBreakerStateChange(name string, from, to CircuitState) { - cbm.mutex.Lock() - defer cbm.mutex.Unlock() - - // Record state transition - transition := StateTransition{ - From: from, - To: to, - Timestamp: time.Now(), - } - - cbm.stateHistory[name] = append(cbm.stateHistory[name], transition) - - // Update health metrics - if cbm.healthMetrics[name] == nil { - cbm.healthMetrics[name] = &HealthMetrics{} - } - - metrics := cbm.healthMetrics[name] - metrics.LastStateChange = transition.Timestamp - - // Track open duration - if from == CircuitStateOpen && to == CircuitStateHalfOpen { - // Find when it opened - for i := len(cbm.stateHistory[name]) - 2; i >= 0; i-- { - if cbm.stateHistory[name][i].To == CircuitStateOpen { - openDuration := transition.Timestamp.Sub(cbm.stateHistory[name][i].Timestamp) - metrics.OpenDuration += openDuration - break - } - } - } - - // Generate health report - cbm.generateHealthReport(name, metrics) -} - -func (cbm *CircuitBreakerMonitor) generateHealthReport(name string, metrics *HealthMetrics) { - if metrics.TotalRequests > 0 { - successRate := float64(metrics.SuccessfulReqs) / float64(metrics.TotalRequests) * 100 +type otelObserver struct{ tracer trace.Tracer } - log.Printf("Circuit Breaker Health Report - %s: Success Rate: %.2f%%, Open Duration: %v", - name, successRate, metrics.OpenDuration) +func (o *otelObserver) RecordError(message string) { + span := trace.SpanFromContext(context.Background()) + if span.IsRecording() { + span.RecordError(errors.New(message)) } } ``` -## Recovery Suggestion Tracking - -Monitor the effectiveness of recovery suggestions: - -```go -type RecoveryTracker struct { - suggestions map[string]*SuggestionMetrics - mutex sync.RWMutex -} - -type SuggestionMetrics struct { - Count int - FirstSeen time.Time - LastSeen time.Time - Contexts []ErrorContext -} - -func (rt *RecoveryTracker) OnRecoverySuggestionTriggered(suggestion string, context ErrorContext) { - rt.mutex.Lock() - defer rt.mutex.Unlock() - - if rt.suggestions[suggestion] == nil { - rt.suggestions[suggestion] = &SuggestionMetrics{ - FirstSeen: time.Now(), - Contexts: make([]ErrorContext, 0), - } - } - - metrics := rt.suggestions[suggestion] - metrics.Count++ - metrics.LastSeen = time.Now() - metrics.Contexts = append(metrics.Contexts, context) +A richer integration would attach a `context.Context` to the error via +`WithContext` and pull the active span out of it inside the observer. - // Generate actionable insights - if metrics.Count > 10 { - rt.generateRecoveryInsights(suggestion, metrics) - } -} +## Why so minimal? -func (rt *RecoveryTracker) generateRecoveryInsights(suggestion string, metrics *SuggestionMetrics) { - frequency := float64(metrics.Count) / time.Since(metrics.FirstSeen).Hours() +The interface deliberately carries only `message`. Anything richer would +mean ewrap dictating a particular metric label set, tracing API, or sample +rate. Keeping it tight means: - log.Printf("Recovery Suggestion Analysis - '%s': Count: %d, Frequency: %.2f/hour, Duration: %v", - suggestion, metrics.Count, frequency, time.Since(metrics.FirstSeen)) - - // Analyze context patterns - contextTypes := make(map[string]int) - for _, ctx := range metrics.Contexts { - contextTypes[string(ctx.ErrorType)]++ - } +- Zero dependencies for observers. +- You can record whatever's relevant to *your* stack inside the + implementation (`(*Error).Log` runs synchronously in the caller's + goroutine, so you have access to its `context.Context` etc.). +- Substitution is trivial — wrap an existing observer to add sampling, + filtering, or rate-limiting without touching ewrap. - for contextType, count := range contextTypes { - percentage := float64(count) / float64(len(metrics.Contexts)) * 100 - log.Printf(" Context Type '%s': %d occurrences (%.1f%%)", contextType, count, percentage) - } -} -``` +If you want to observe the full structured payload, attach a `Logger` +instead. The logger receives the message, cause, stack, metadata, and +recovery fields all in one record. -## Integration with Monitoring Systems +## Circuit-breaker observability -### Prometheus Integration +The breaker subpackage has its own observer interface: ```go -import "github.com/prometheus/client_golang/prometheus" +import "github.com/hyp3rd/ewrap/breaker" -type PrometheusObserver struct { - errorCounter *prometheus.CounterVec - circuitBreakerGauge *prometheus.GaugeVec - recoveryCounter *prometheus.CounterVec +type breakerMetrics struct { + state *prometheus.GaugeVec } -func NewPrometheusObserver() *PrometheusObserver { - return &PrometheusObserver{ - errorCounter: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "ewrap_errors_total", - Help: "Total number of errors created", - }, - []string{"type", "severity"}, - ), - circuitBreakerGauge: prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "ewrap_circuit_breaker_state", - Help: "Current circuit breaker state (0=closed, 1=open, 2=half-open)", - }, - []string{"name"}, - ), - recoveryCounter: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "ewrap_recovery_suggestions_total", - Help: "Total number of recovery suggestions triggered", - }, - []string{"suggestion_type"}, - ), - } +func (m *breakerMetrics) RecordTransition(name string, from, to breaker.State) { + m.state.WithLabelValues(name).Set(float64(to)) } -func (p *PrometheusObserver) OnCircuitBreakerStateChange(name string, from, to CircuitState) { - var stateValue float64 - switch to { - case CircuitStateClosed: - stateValue = 0 - case CircuitStateOpen: - stateValue = 1 - case CircuitStateHalfOpen: - stateValue = 2 - } - - p.circuitBreakerGauge.WithLabelValues(name).Set(stateValue) -} +cb := breaker.NewWithObserver("payments", 5, 30*time.Second, &breakerMetrics{state: stateGauge}) ``` -## Best Practices - -### Performance Considerations - -1. **Lightweight Observers**: Keep observer implementations fast to avoid impacting error handling performance -1. **Async Processing**: Use goroutines for expensive operations in observers -1. **Buffered Channels**: Use buffered channels for high-throughput scenarios - -### Monitoring Strategy - -1. **Error Rate Monitoring**: Track error rates by type and severity -1. **Circuit Breaker Health**: Monitor state transitions and success rates -1. **Recovery Effectiveness**: Analyze which recovery suggestions are most common -1. **Performance Impact**: Monitor the overhead of observability features - -### Alert Configuration - -1. **Error Spikes**: Alert on sudden increases in error rates -1. **Circuit Breaker Openings**: Immediate alerts when services become unavailable -1. **Recovery Pattern Changes**: Notify when new types of errors appear frequently - -The observability features in ewrap provide deep insights into your application's error patterns and system health, enabling proactive monitoring and faster incident response. +See [Circuit Breaker](circuit-breaker.md) for details on transition +semantics. Importantly, transition callbacks fire **synchronously** after +the breaker lock is released, so they must not invoke the breaker +recursively. diff --git a/docs/docs/features/operational.md b/docs/docs/features/operational.md new file mode 100644 index 0000000..ab8d6fb --- /dev/null +++ b/docs/docs/features/operational.md @@ -0,0 +1,166 @@ +# Operational Features + +Three small, orthogonal features for production use: + +- **HTTP status** — attach and walk a status code along the cause chain. +- **Retryable / Temporary** — classify whether retrying makes sense. +- **Safe message** — emit a redacted variant for logs that may leave the + trust boundary. + +Each is set with an option at construction (or inherited via `Wrap`) and +read either via a method on `*Error` or a top-level walker function. + +## HTTP status + +```go +err := ewrap.New("upstream rejected request", + ewrap.WithHTTPStatus(http.StatusBadGateway)) + +ewrap.HTTPStatus(err) // 502 +ewrap.HTTPStatus(io.EOF) // 0 — no ewrap layer set one +ewrap.HTTPStatus(nil) // 0 +``` + +`HTTPStatus(err)` walks the chain via `errors.As` and returns the **first +non-zero** status it finds. Wrapping a tagged error keeps the status: + +```go +inner := ewrap.New("rejected", ewrap.WithHTTPStatus(http.StatusBadGateway)) +outer := ewrap.Wrap(inner, "fetching invoice") + +ewrap.HTTPStatus(outer) // 502, inherited from inner +``` + +A standard `fmt.Errorf("...: %w", inner)` wrapper also works — `HTTPStatus` +walks past it via `errors.Unwrap`. + +### Typical use in an HTTP handler + +```go +func handle(w http.ResponseWriter, r *http.Request) { + if err := process(r); err != nil { + status := ewrap.HTTPStatus(err) + if status == 0 { + status = http.StatusInternalServerError + } + http.Error(w, err.Error(), status) + return + } +} +``` + +## Retryable / Temporary + +```go +err := ewrap.New("rate limited", ewrap.WithRetryable(true)) + +ewrap.IsRetryable(err) // true +``` + +The classification is **explicit and three-state**: + +```go +err.Retryable() // (value, set bool) +// set == false → not classified +// set == true → value is the explicit classification +``` + +`ewrap.IsRetryable(err)` walks the chain. If no ewrap layer set the +flag, it falls through to the stdlib `interface{ Temporary() bool }`, +which `net.OpError`, `*net.DNSError`, and friends already implement: + +```go +ewrap.IsRetryable(myNetErr) // honours net.OpError.Temporary() +``` + +### Typical use in a retry loop + +```go +for attempt := 1; attempt <= max; attempt++ { + err := callUpstream(req) + if err == nil { + return nil + } + + if !ewrap.IsRetryable(err) { + return err + } + + time.Sleep(backoff(attempt)) +} +``` + +### Combining with `WithRetry` + +`WithRetry` carries a per-error retry **policy** (max attempts, delay, +predicate). `WithRetryable` is a simple **classification** flag. Use +both together when you want a self-describing retryable error: + +```go +err := ewrap.New("upstream timeout", + ewrap.WithRetryable(true), + ewrap.WithRetry(3, 5*time.Second)) +``` + +## Safe (PII-redacted) messages + +`SafeError()` returns a redacted variant of the error chain suitable for +external sinks (third-party logs, customer-visible responses, public +metrics): + +```go +err := ewrap.New("user 'alice@example.com' rejected", + ewrap.WithSafeMessage("user [redacted] rejected")) + +err.Error() // "user 'alice@example.com' rejected" +err.SafeError() // "user [redacted] rejected" +``` + +`SafeError` walks the chain. Each layer contributes either its +`WithSafeMessage` value (if set) or its raw `msg`. Standard wrapped +errors without a `SafeError` method are included verbatim — wrap them in +an `ewrap.Error` with `WithSafeMessage` if they may contain PII: + +```go +root := ewrap.New("token=secret123", ewrap.WithSafeMessage("token=[redacted]")) +outer := ewrap.Wrap(root, "auth failed for user@example.com", + ewrap.WithSafeMessage("auth failed for [redacted]")) + +outer.Error() // "auth failed for user@example.com: token=secret123" +outer.SafeError() // "auth failed for [redacted]: token=[redacted]" +``` + +### Typical use in dual-sink logging + +```go +logger.Error("internal", "err", err.Error()) // full detail to private sink +external.Error("public", "err", err.SafeError()) // redacted to public sink +``` + +## Inheritance through `Wrap` + +All three classifications are inherited when wrapping an `ewrap.Error`: + +```go +inner := ewrap.New("boom", + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true)) + +outer := ewrap.Wrap(inner, "in handler") + +ewrap.HTTPStatus(outer) // 502 +ewrap.IsRetryable(outer) // true +``` + +Pass an option to `Wrap` to override the inherited value at the new +layer. + +## What's intentionally not here + +- **gRPC status codes** — would require pulling in `google.golang.org/grpc`. + Use `WithHTTPStatus` and translate at the boundary, or implement a tiny + gRPC subpackage in your own repo. +- **Message templates / i18n** — out of scope. Build your own helper that + calls `WithSafeMessage` with the localized string. +- **Automatic PII detection** — too domain-specific. `WithSafeMessage` is + the explicit hook; reach for it where the original message can leak. diff --git a/docs/docs/features/serialization.md b/docs/docs/features/serialization.md index 0ff966d..d115fc6 100644 --- a/docs/docs/features/serialization.md +++ b/docs/docs/features/serialization.md @@ -1,394 +1,165 @@ -# Serialization and Error Groups +# Serialization -ewrap provides comprehensive serialization capabilities for both individual errors and error groups, enabling structured export for monitoring systems, APIs, and debugging purposes. The serialization system supports multiple formats and is optimized for performance. +ewrap can serialize a single `*Error` or a whole `*ErrorGroup` to JSON or +YAML. The output schema is stable and walks the entire cause chain — +including non-`*Error` wrappers via `errors.Unwrap` — so transport +consumers don't lose context across module boundaries. -## Error Group Serialization - -### Basic Serialization - -Export entire error groups to structured formats: +## Single error ```go -// Create an error group with multiple errors -pool := ewrap.NewErrorGroupPool(4) -eg := pool.Get() -defer eg.Release() - -// Add various errors -eg.Add(ewrap.New("database connection failed", - ewrap.WithErrorType(ewrap.ErrorTypeDatabase), - ewrap.WithSeverity(ewrap.SeverityCritical))) - -eg.Add(ewrap.New("validation error", - ewrap.WithErrorType(ewrap.ErrorTypeValidation), - ewrap.WithSeverity(ewrap.SeverityError))) - -// Serialize to JSON -jsonOutput, err := eg.ToJSON( +err := ewrap.New("payment failed", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Retry after backoff.", + })). + WithMetadata("provider", "stripe") + +jsonStr, _ := err.ToJSON( ewrap.WithTimestampFormat(time.RFC3339), ewrap.WithStackTrace(true), - ewrap.WithRecoverySuggestion(true)) - -if err != nil { - log.Printf("Serialization failed: %v", err) - return -} +) -fmt.Println(jsonOutput) +yamlStr, _ := err.ToYAML(ewrap.WithStackTrace(false)) ``` -### JSON Output Structure - -The JSON serialization produces a structured format: +### Schema ```json { - "error_group": { - "timestamp": "2024-03-15T14:30:00Z", - "total_errors": 2, - "errors": [ - { - "message": "database connection failed", - "type": "database", - "severity": "critical", - "timestamp": "2024-03-15T14:30:00Z", - "metadata": {}, - "stack_trace": [ - { - "function": "main.connectDatabase", - "file": "/app/main.go", - "line": 42, - "pc": "0x4567890" - } - ], - "recovery_suggestion": "Check database connectivity and connection pool settings" - }, - { - "message": "validation error", - "type": "validation", - "severity": "error", - "timestamp": "2024-03-15T14:30:00Z", - "metadata": {}, - "stack_trace": [] - } - ] - } -} -``` - -### YAML Serialization - -Export error groups to YAML format: - -```go -yamlOutput, err := eg.ToYAML( - ewrap.WithTimestampFormat("2006-01-02T15:04:05Z07:00"), - ewrap.WithStackTrace(false)) // Exclude stack traces for cleaner output - -if err != nil { - log.Printf("YAML serialization failed: %v", err) - return -} - -fmt.Println(yamlOutput) -``` - -### YAML Output Structure - -```yaml -error_group: - timestamp: "2024-03-15T14:30:00Z" - total_errors: 2 - errors: - - message: "database connection failed" - type: "database" - severity: "critical" - timestamp: "2024-03-15T14:30:00Z" - metadata: {} - recovery_suggestion: "Check database connectivity and connection pool settings" - - message: "validation error" - type: "validation" - severity: "error" - timestamp: "2024-03-15T14:30:00Z" - metadata: {} -``` - -## Individual Error Serialization - -### Enhanced JSON Serialization - -Individual errors can be serialized with full context: - -```go -err := ewrap.New("payment processing failed", - ewrap.WithErrorType(ewrap.ErrorTypeExternal), - ewrap.WithSeverity(ewrap.SeverityCritical), - ewrap.WithRecoverySuggestion("Retry with exponential backoff or contact payment provider")). - WithMetadata("payment_id", "pay_12345"). - WithMetadata("amount", 99.99). - WithMetadata("currency", "USD") - -jsonOutput, serErr := err.ToJSON( - ewrap.WithTimestampFormat(time.RFC3339), - ewrap.WithStackTrace(true), - ewrap.WithRecoverySuggestion(true)) - -if serErr != nil { - log.Printf("Error serialization failed: %v", serErr) - return + "message": "payment failed", + "timestamp": "2026-05-02T10:11:12Z", + "type": "external", + "severity": "error", + "stack": "/repo/pay.go:42 example.com/pay.charge\n...", + "context": { + "request_id": "req-123", + "user": "u-1", + "component": "billing", + "operation": "charge", + "file": "/repo/pay.go", + "line": 42, + "environment": "prod" + }, + "metadata": { + "provider": "stripe" + }, + "recovery": { + "message": "Retry after backoff.", + "actions": [], + "documentation": "" + }, + "cause": null } ``` -### Custom Serialization Options - -#### Timestamp Formatting - -Configure timestamp formats for different use cases: - -```go -// RFC3339 format (recommended for APIs) -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat(time.RFC3339)) - -// Custom format for logs -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat("2006-01-02 15:04:05")) - -// Unix timestamp for systems integration -jsonOutput, _ := err.ToJSON(ewrap.WithTimestampFormat("unix")) -``` - -#### Stack Trace Control - -Control stack trace inclusion in serialized output: - -```go -// Include full stack traces (for debugging) -jsonOutput, _ := err.ToJSON(ewrap.WithStackTrace(true)) - -// Exclude stack traces (for production logs) -jsonOutput, _ := err.ToJSON(ewrap.WithStackTrace(false)) +The `cause` field nests the same shape recursively for chained errors. -// Include only application frames -jsonOutput, _ := err.ToJSON( - ewrap.WithStackTrace(true), - ewrap.WithStackFilter(func(frame StackFrame) bool { - return strings.Contains(frame.File, "/myapp/") && - !strings.Contains(frame.File, "/vendor/") - })) -``` +### Format options -#### Recovery Suggestions +| Option | Effect | +| --- | --- | +| `WithTimestampFormat(layout)` | Reformats the `timestamp` field (parses RFC3339 in, emits the supplied layout). Empty layout = leave unchanged. | +| `WithStackTrace(false)` | Removes the `stack` field from the output. | -Control recovery suggestion inclusion: +Use both together for compact, dashboard-friendly output: ```go -// Include recovery suggestions (for operational use) -jsonOutput, _ := err.ToJSON(ewrap.WithRecoverySuggestion(true)) - -// Exclude recovery suggestions (for end-user APIs) -jsonOutput, _ := err.ToJSON(ewrap.WithRecoverySuggestion(false)) +jsonStr, _ := err.ToJSON( + ewrap.WithTimestampFormat(time.DateTime), + ewrap.WithStackTrace(false), +) ``` -## Integration with errors.Join - -### Standard Library Compatibility - -ewrap error groups integrate seamlessly with Go's standard `errors.Join`: +## Error groups ```go -// Create error group eg := pool.Get() -eg.Add(err1) -eg.Add(err2) -eg.Add(err3) - -// Get standard errors.Join result -standardErr := eg.Join() - -// Use with standard library functions -if errors.Is(standardErr, expectedErr) { - // Handle specific error -} +defer eg.Release() -var targetErr *MyCustomError -if errors.As(standardErr, &targetErr) { - // Handle custom error type -} +eg.Add(httpErr) +eg.Add(dbErr) -// The joined error maintains ewrap capabilities -if ewrapGroup, ok := standardErr.(*ewrap.ErrorGroup); ok { - // Can still serialize the group - jsonOutput, _ := ewrapGroup.ToJSON(ewrap.WithTimestampFormat(time.RFC3339)) -} +groupJSON, _ := eg.ToJSON() +groupYAML, _ := eg.ToYAML() ``` -### Preserving ewrap Features +`ErrorGroup` also implements `json.Marshaler` and `yaml.Marshaler` directly, +so encoders that consume them via `json.Marshal` / `yaml.Marshal` work with +zero ceremony. -When using `errors.Join`, ewrap features are preserved: +### Group schema -```go -eg := pool.Get() -eg.Add(ewrap.New("error 1", ewrap.WithErrorType(ewrap.ErrorTypeDatabase))) -eg.Add(ewrap.New("error 2", ewrap.WithErrorType(ewrap.ErrorTypeNetwork))) - -// Join preserves individual error metadata -joinedErr := eg.Join() - -// Individual errors maintain their ewrap features -fmt.Printf("Joined error: %v\n", joinedErr) - -// Can still access individual errors -for _, err := range eg.Errors() { - if ewrapErr, ok := err.(*ewrap.Error); ok { - fmt.Printf("Error type: %s, Severity: %s\n", - ewrapErr.ErrorType(), ewrapErr.Severity()) +```json +{ + "error_count": 2, + "timestamp": "2026-05-02T10:11:12Z", + "errors": [ + { + "message": "fetching user: net/http: Bad Gateway", + "type": "ewrap", + "stack_trace": [ + {"function": "...", "file": "...", "line": 42, "pc": 12345} + ], + "metadata": {"http_status": 502}, + "cause": { + "message": "net/http: Bad Gateway", + "type": "standard" + } + }, + { + "message": "EOF", + "type": "standard" } + ] } ``` -## Performance Optimizations +`type` is `"ewrap"` for `*Error` members and `"standard"` for everything +else. `stack_trace` and `metadata` are emitted only for `*Error` members. -### Go 1.25+ Features +## Cause chain across boundaries -ewrap leverages modern Go features for efficient serialization: +The serializer walks both `*Error` chains and standard wrapped chains: ```go -// Uses maps.Clone for efficient metadata copying -func (err *Error) Clone() *Error { - cloned := &Error{ - message: err.message, - errorType: err.errorType, - severity: err.severity, - timestamp: err.timestamp, - stack: err.stack, - metadata: maps.Clone(err.metadata), // Efficient copying - } - return cloned -} - -// Uses slices.Clone for error group copying -func (eg *ErrorGroup) Clone() *ErrorGroup { - return &ErrorGroup{ - errors: slices.Clone(eg.errors), // Efficient slice copying - mutex: sync.RWMutex{}, - } -} -``` - -### Memory Management +inner := ewrap.New("DB unreachable", + ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical)) +mid := fmt.Errorf("loading user: %w", inner) // standard wrapper +outer := ewrap.Wrap(mid, "handling /users/{id}") // ewrap on top -Serialization is optimized for memory efficiency: - -```go -// Pre-allocated buffers for JSON marshaling -type SerializationBuffer struct { - jsonBuffer bytes.Buffer - yamlBuffer bytes.Buffer -} - -// Reuse buffers across serialization operations -func (sb *SerializationBuffer) SerializeToJSON(err *Error) (string, error) { - sb.jsonBuffer.Reset() // Reuse existing buffer - - encoder := json.NewEncoder(&sb.jsonBuffer) - if err := encoder.Encode(err); err != nil { - return "", err - } +eg := pool.Get() +eg.Add(outer) - return sb.jsonBuffer.String(), nil -} +// outer -> mid (via errors.Unwrap) -> inner (*Error) +// All three layers appear in the serialized cause chain. ``` -## API Integration Examples - -### REST API Error Responses +This works because `toSerializableError` falls through to `errors.Unwrap` +for non-`*Error` causes — you don't have to convert everything to ewrap +upfront. -```go -func handleAPIError(w http.ResponseWriter, r *http.Request, err error) { - if ewrapErr, ok := err.(*ewrap.Error); ok { - jsonResponse, serErr := ewrapErr.ToJSON( - ewrap.WithTimestampFormat(time.RFC3339), - ewrap.WithStackTrace(false), // Don't expose stack traces in API - ewrap.WithRecoverySuggestion(false)) // Keep suggestions internal - - if serErr != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - // Set appropriate status code based on error type - statusCode := getStatusCodeForError(ewrapErr) - w.WriteHeader(statusCode) - - w.Write([]byte(jsonResponse)) - return - } +## Performance - // Fallback for non-ewrap errors - http.Error(w, err.Error(), http.StatusInternalServerError) -} +| Benchmark | ns/op | allocs | +| --- | ---: | ---: | +| `Error.ToJSON` (with context, two metadata keys) | ~17,000 | ~14 | +| `Error.ToYAML` (same) | ~250,000 | ~115 | +| `ErrorGroup.ToJSON` (10 entries) | ~10 µs | ~30 | -func getStatusCodeForError(err *ewrap.Error) int { - switch err.ErrorType() { - case ewrap.ErrorTypeValidation: - return http.StatusBadRequest - case ewrap.ErrorTypeNotFound: - return http.StatusNotFound - case ewrap.ErrorTypePermission: - return http.StatusForbidden - case ewrap.ErrorTypeDatabase, ewrap.ErrorTypeNetwork: - return http.StatusInternalServerError - default: - return http.StatusInternalServerError - } -} -``` +JSON uses `github.com/goccy/go-json`, ~2.5× faster than stdlib +`encoding/json` on this payload shape with about half the allocations. -### Structured Logging Integration +YAML uses `gopkg.in/yaml.v3`. It's significantly slower than JSON; if +serialization is hot, prefer JSON. -```go -func logErrorGroup(logger *slog.Logger, eg *ewrap.ErrorGroup) { - jsonOutput, err := eg.ToJSON( - ewrap.WithTimestampFormat(time.RFC3339), - ewrap.WithStackTrace(true), - ewrap.WithRecoverySuggestion(true)) - - if err != nil { - logger.Error("failed to serialize error group", "error", err) - return - } - - logger.Error("error group occurred", - "error_count", len(eg.Errors()), - "errors", jsonOutput) -} -``` - -### Monitoring System Integration - -```go -func sendToMonitoring(eg *ewrap.ErrorGroup, metricsClient *prometheus.Client) { - for _, err := range eg.Errors() { - if ewrapErr, ok := err.(*ewrap.Error); ok { - // Send metrics - metricsClient.Counter("errors_total"). - WithLabelValues( - string(ewrapErr.ErrorType()), - string(ewrapErr.Severity())). - Inc() - - // Send structured data to monitoring - jsonData, serErr := ewrapErr.ToJSON( - ewrap.WithTimestampFormat(time.RFC3339), - ewrap.WithStackTrace(false)) - - if serErr == nil { - metricsClient.SendCustomMetric("error_details", jsonData) - } - } - } -} -``` +## Tips -The serialization features in ewrap provide comprehensive support for structured error export, enabling seamless integration with monitoring systems, APIs, and debugging workflows while maintaining excellent performance characteristics. +- For machine consumption, **prefer JSON** — both faster and more widely + supported in observability sinks. +- **Strip stacks** for high-volume sinks (`WithStackTrace(false)`) and + attach them in dev/debug paths only. +- **Use `RFC3339`** as the timestamp format unless you have a strong + reason to deviate; it parses cleanly in every common log pipeline. +- **Set `ErrorContext`** on at least one layer so `type` and `severity` + carry signal. Without it both default to `"unknown"` / `"error"`. diff --git a/docs/docs/features/slog-adapter.md b/docs/docs/features/slog-adapter.md new file mode 100644 index 0000000..43d9edb --- /dev/null +++ b/docs/docs/features/slog-adapter.md @@ -0,0 +1,75 @@ +# `ewrap/slog` — slog adapter subpackage + +A 30-line subpackage that lets a stdlib `*log/slog.Logger` satisfy +`ewrap.Logger`. Stdlib-only — no extra deps. + +## Install + +```bash +go get github.com/hyp3rd/ewrap/slog +``` + +## Usage + +```go +import ( + stdslog "log/slog" + "os" + + "github.com/hyp3rd/ewrap" + ewrapslog "github.com/hyp3rd/ewrap/slog" +) + +handler := stdslog.NewJSONHandler(os.Stdout, &stdslog.HandlerOptions{ + Level: stdslog.LevelDebug, +}) +logger := ewrapslog.New(stdslog.New(handler)) + +err := ewrap.New("boom", ewrap.WithLogger(logger)) +err.Log() +``` + +## API + +```go +type Adapter struct{ /* unexported */ } + +func New(logger *slog.Logger) *Adapter + +func (a *Adapter) Error(msg string, keysAndValues ...any) +func (a *Adapter) Debug(msg string, keysAndValues ...any) +func (a *Adapter) Info(msg string, keysAndValues ...any) +``` + +`Adapter` is a thin shim — each method forwards to the wrapped logger +with the same arguments. `keysAndValues` is the standard alternating +key/value convention; `*slog.Logger` accepts it natively. + +## When you need this vs when you don't + +You need the adapter when you're calling `(*Error).Log` (or anything else +that takes an `ewrap.Logger`): + +```go +err := ewrap.New("boom", ewrap.WithLogger(ewrapslog.New(slogger))) +err.Log() // adapter forwards to slogger.Error(...) +``` + +You **don't** need the adapter when you're logging via slog directly. +`*Error` implements `slog.LogValuer`, so this works without any adapter: + +```go +slogger.Error("payment failed", "err", err) +// emits message, type, severity, request_id, cause, metadata as +// structured fields — see fmt.Formatter & slog +``` + +See [fmt.Formatter & slog](format-and-slog.md) for the `LogValuer` +details. + +## Why a subpackage? + +`*log/slog.Logger` is in the stdlib, so the adapter has no third-party +dependencies. It's still a separate subpackage so importing only `ewrap` +doesn't pull in `log/slog` for projects that don't use it (rare but +possible — embedded targets, for example). diff --git a/docs/docs/features/stack-traces.md b/docs/docs/features/stack-traces.md index 9247488..23b67f7 100644 --- a/docs/docs/features/stack-traces.md +++ b/docs/docs/features/stack-traces.md @@ -1,338 +1,169 @@ # Stack Traces -Stack traces are crucial for understanding where and why errors occur in your application. In ewrap, stack traces are automatically captured and enhanced to provide meaningful debugging information while maintaining performance. The latest version includes programmatic stack frame inspection through iterators and structured access. +ewrap captures a stack trace on every constructor call and exposes it via +`(*Error).Stack()`, `fmt.Printf("%+v", err)`, and an iterator API. Captures +are tunable; formatted output is cached so you can read it freely. -## Understanding Stack Traces +## What gets captured -A stack trace represents the sequence of function calls that led to an error. Think of it as a trail of breadcrumbs showing exactly how your program reached a particular point of failure. ewrap captures this information automatically while filtering out unnecessary noise. +- `runtime.Callers` records up to **32** program counters by default. +- The capture skips the ewrap entry point so the first visible frame is + your call to `New` / `Wrap` / `Newf` / `Wrapf`. +- Internal ewrap frames are filtered from the rendered output. Test files + in the same package are allowed through so the library's own tests still + produce useful traces. -## How ewrap Captures Stack Traces +## Reading the stack -When you create a new error using ewrap, it automatically captures the current stack trace: +### As a formatted string ```go -func processUserData(userID string) error { - // This will capture the stack trace automatically - if err := validateUser(userID); err != nil { - return ewrap.New("user validation failed") - } - return nil -} +fmt.Println(err.Stack()) ``` -The captured stack trace includes: - -- Function names -- File names -- Line numbers -- Program counter (PC) values - -However, ewrap goes beyond simple capture by: - -1. Filtering out runtime implementation details -1. Maintaining stack traces through error wrapping -1. Providing formatted output options -1. Offering programmatic access through iterators - -## Programmatic Stack Frame Access - -### Using Stack Iterators - -The new stack iterator provides efficient, lazy access to stack frames: +Output (one frame per line): -```go -func analyzeError(err error) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - iterator := wrappedErr.GetStackIterator() - - for iterator.HasNext() { - frame := iterator.Next() - - fmt.Printf("Function: %s\n", frame.Function) - fmt.Printf("File: %s:%d\n", frame.File, frame.Line) - fmt.Printf("PC: %x\n", frame.PC) - - // Custom logic based on frame information - if strings.Contains(frame.Function, "database") { - handleDatabaseFrame(frame) - } - } - } -} +```text +/path/to/repo/db.go:42 - example.com/repo/db.queryUser +/path/to/repo/handlers.go:71 - example.com/repo/handlers.GetProfile +... ``` -### Accessing All Frames +`Stack()` formats and caches the result on first call via `sync.Once`; +subsequent calls return the cached string with no allocations. -Get all stack frames at once for batch processing: +### As frames you can walk ```go -func generateErrorReport(err error) ErrorReport { - if wrappedErr, ok := err.(*ewrap.Error); ok { - frames := wrappedErr.GetStackFrames() - - return ErrorReport{ - Message: wrappedErr.Error(), - StackFrames: frames, - Timestamp: time.Now(), - } - } - return ErrorReport{} +for it := err.GetStackIterator(); it.HasNext(); { + f := it.Next() + fmt.Printf("%s:%d %s (pc=%x)\n", f.File, f.Line, f.Function, f.PC) } ``` -### Stack Frame Structure +`StackIterator` supports `Next`, `HasNext`, `Reset`, `Frames` (remaining +slice), and `AllFrames` (full slice). -Each stack frame provides detailed information: +For a one-shot snapshot: ```go -type StackFrame struct { - Function string `json:"function" yaml:"function"` // Fully qualified function name - File string `json:"file" yaml:"file"` // Source file path - Line int `json:"line" yaml:"line"` // Line number - PC uintptr `json:"pc" yaml:"pc"` // Program counter -} +frames := err.GetStackFrames() ``` -## Iterator Operations +`StackFrame` is JSON/YAML-tagged so it serializes cleanly. -### Navigation and Control +### Via `%+v` ```go -iterator := wrappedErr.GetStackIterator() - -// Check if more frames are available -if iterator.HasNext() { - frame := iterator.Next() - // Process frame -} - -// Reset iterator to beginning -iterator.Reset() - -// Get remaining frames from current position -remainingFrames := iterator.Frames() - -// Get all frames regardless of current position -allFrames := iterator.AllFrames() +fmt.Printf("%+v\n", err) +// boom +// /path/to/foo.go:12 - example.com/foo.do +// ... ``` -### Filtering and Processing +`(*Error).Format` implements `fmt.Formatter`. Other verbs: -```go -func findApplicationFrames(err error) []StackFrame { - var appFrames []StackFrame - - if wrappedErr, ok := err.(*ewrap.Error); ok { - iterator := wrappedErr.GetStackIterator() +- `%s`, `%v` — the error message only +- `%q` — quoted message +- `%+v` — message plus formatted stack - for iterator.HasNext() { - frame := iterator.Next() +## Tuning capture depth - // Filter for application-specific frames - if strings.Contains(frame.File, "/myapp/") && - !strings.Contains(frame.File, "/vendor/") { - appFrames = append(appFrames, *frame) - } - } - } - - return appFrames -} -``` - -When working with JSON output: +The default depth (32) is plenty for most stacks. Override with +`WithStackDepth`: ```go -err := ewrap.New("database connection failed") -jsonOutput, _ := err.ToJSON(ewrap.WithStackTrace(true)) -fmt.Println(jsonOutput) +ewrap.New("boom", ewrap.WithStackDepth(8)) // shallower +ewrap.New("boom", ewrap.WithStackDepth(0)) // disable capture entirely +ewrap.New("boom", ewrap.WithStackDepth(128)) // deeper ``` -## Stack Trace Filtering +Setting depth to 0 returns a `*Error` with `len(err.stack) == 0` and an +empty `Stack()`. Useful for hot-path errors you know will never need a +trace. + +## Skipping helper frames -ewrap automatically filters stack traces to remove unhelpful information. Consider this example: +If you call `New` or `Wrap` from a thin helper, the captured stack begins +inside the helper rather than at the caller. Use the `Skip` variants to +advance past those frames: ```go -func getUserProfile(id string) (*Profile, error) { - profile, err := db.GetProfile(id) - if err != nil { - // The stack trace will exclude runtime internals - return nil, ewrap.Wrap(err, "failed to retrieve user profile") - } - return profile, nil +func ErrInvalid(field string) *ewrap.Error { + return ewrap.NewSkip(1, "invalid input"). + WithMetadata("field", field) } -``` - -The resulting stack trace might look like this: -```text -/app/services/user.go:25 - getUserProfile -/app/handlers/profile.go:42 - HandleProfileRequest -/app/router/routes.go:156 - ServeHTTP +return ErrInvalid("email") // stack starts at the caller of ErrInvalid ``` -Instead of the more verbose and less helpful unfiltered version: +`WrapSkip` is the wrap analogue: -```text -/app/services/user.go:25 - getUserProfile -/app/handlers/profile.go:42 - HandleProfileRequest -/app/router/routes.go:156 - ServeHTTP -/usr/local/go/src/runtime/asm_amd64.s:1571 - goexit -/usr/local/go/src/runtime/proc.go:203 - main -... +```go +func wrapDB(err error, msg string) *ewrap.Error { + return ewrap.WrapSkip(1, err, msg) +} ``` -## Stack Traces in Error Chains +## How wrap chains compose -When you wrap errors, ewrap preserves the original stack trace while maintaining the error chain: +Each `Wrap` captures its own stack, so deep chains don't lose information: ```go -func processOrder(orderID string) error { - // Original error with its stack trace - err := validateOrder(orderID) - if err != nil { - // Wraps the error while preserving the original stack trace - return ewrap.Wrap(err, "order validation failed") - } - - err = saveOrder(orderID) - if err != nil { - // Each wrap maintains the complete error context - return ewrap.Wrap(err, "failed to save order") - } +root := io.EOF +inner := ewrap.Wrap(root, "ping db") // stack A +outer := ewrap.Wrap(inner, "boot") // stack B - return nil -} +outer.Stack() // shows where outer was created +inner.Stack() // shows where inner was created ``` -## Performance Considerations - -While stack traces are valuable for debugging, they do come with some overhead. ewrap optimizes this by: - -1. Using efficient stack capture mechanisms -1. Implementing lazy formatting -1. Caching stack trace strings -1. Filtering irrelevant frames early - -Here's how to work with stack traces efficiently: +To assemble a full multi-layer trace, walk the chain: ```go -func processItems(items []Item) error { - for _, item := range items { - if err := processItem(item); err != nil { - // In tight loops, consider whether you need the stack trace - if isCriticalError(err) { - return ewrap.Wrap(err, "critical error during item processing") - } - // For non-critical errors, maybe just log and continue - log.Printf("Non-critical error: %v", err) - continue - } +for cur := error(outer); cur != nil; cur = errors.Unwrap(cur) { + var ec *ewrap.Error + if errors.As(cur, &ec) { + fmt.Println("---") + fmt.Println(ec.Stack()) } - return nil } ``` -## Using Stack Traces for Debugging +## Serialization -Stack traces are most valuable when combined with other error context. Here's a comprehensive example: +`(*Error).ToJSON` includes the formatted stack by default; pass +`WithStackTrace(false)` to omit it: ```go -func debugError(err error) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - fmt.Printf("Error Message: %v\n", wrappedErr.Error()) - - // Print the stack trace - fmt.Printf("\nStack Trace:\n%s\n", wrappedErr.Stack()) - - // Get any metadata - if metadata, ok := wrappedErr.GetMetadata("request_id"); ok { - fmt.Printf("\nRequest ID: %v\n", metadata) - } - - // Print error chain - fmt.Println("\nError Chain:") - for e := wrappedErr; e != nil; e = e.Unwrap().(*ewrap.Error) { - fmt.Printf("- %s\n", e.Error()) - } - } -} +jsonStr, _ := err.ToJSON(ewrap.WithStackTrace(false)) ``` -## Best Practices for Stack Traces - -1. **Keep Stack Traces Meaningful** +For `ErrorGroup`, each member's stack frames serialize as a typed +`[]StackFrame` slice (`stack_trace` field) — easy to render in dashboards. - In service handlers, capture enough context without excessive detail: +## Performance - ```go - func (s *Service) HandleRequest(ctx context.Context, req Request) error { - // Capture high-level service context - if err := s.processRequest(ctx, req); err != nil { - return ewrap.Wrap(err, "request processing failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)) - } - return nil - } - ``` - -1. **Combine with Logging** - - Integrate stack traces with your logging system: - - ```go - func logError(err error, logger Logger) { - if wrappedErr, ok := err.(*ewrap.Error); ok { - logger.Error("operation failed", - "error", wrappedErr.Error(), - "stack", wrappedErr.Stack(), - "type", ewrap.GetErrorType(wrappedErr)) - } - } - ``` - -1. **Use in Development and Testing** +| Operation | ns/op | allocs | +| --- | ---: | ---: | +| `runtime.Callers` (depth 32) | ~860 | 1 | +| `Stack()` first call (formatting + filter) | ~2,500 | 1 | +| `Stack()` cached call | **1.7** | **0** | - Stack traces are particularly valuable during development and testing: - - ```go - func TestComplexOperation(t *testing.T) { - err := performComplexOperation() - if err != nil { - t.Errorf("Operation failed with stack trace:\n%+v", err) - } - } - ``` +Capture happens once at construction. Formatting is paid once per error. +After that, `Stack()`, `%+v`, and `LogValue` all read the cached string. -## Common Pitfalls and Solutions +## Internal frame filter -1. **Stack Trace Depth** +The filter recognises a frame as ewrap-internal when: - If you're seeing too much or too little information: +1. The function path starts with `runtime.`, **or** +2. The function path starts with `github.com/hyp3rd/ewrap.` AND the file + does NOT end in `_test.go`. - ```go - // Too much information - err := ewrap.New("operation failed") +That second clause keeps ewrap's own tests visible in their own traces +(useful for debugging the library) while hiding the library's machinery +from end-user code. - // Just right - wrap with context - err := ewrap.New("operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeInternal, ewrap.SeverityError)). - WithMetadata("operation", "user_update") - ``` - -1. **Missing Context** - -Ensure you're capturing relevant context with your stack traces: - -```go -func handleRequest(ctx context.Context, req *Request) error { - if err := validateRequest(req); err != nil { - // Include request context with the stack trace - return ewrap.Wrap(err, "invalid request", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError)). - WithMetadata("request_id", req.ID). - WithMetadata("user_id", req.UserID) - } - return nil -} -``` +If you fork ewrap under a different module path, update the prefix in +`isInternalFrame` (see [errors.go](https://github.com/hyp3rd/ewrap/blob/main/errors.go)). diff --git a/docs/docs/getting-started/installation.md b/docs/docs/getting-started/installation.md index 8765563..910e34f 100644 --- a/docs/docs/getting-started/installation.md +++ b/docs/docs/getting-started/installation.md @@ -1,70 +1,90 @@ # Installation +## Requirements + +- Go **1.25 or later**. ewrap uses `maps.Clone`, `slices.Clone`, + range-over-int, `(*testing.B).Loop`, and `sync.WaitGroup.Go`. + +## Install the core module + ```bash go get github.com/hyp3rd/ewrap ``` -## Usage Examples +This pulls in two transitive deps and nothing else: -### Basic Error Handling +- `gopkg.in/yaml.v3` — YAML serialization +- `github.com/goccy/go-json` — fast JSON encoder used on the serialization hot path -Create and wrap errors with context: +That's the entire footprint of the core module. -```go -import "github.com/hyp3rd/ewrap" -// ... -// Create a new error -err := ewrap.New("database connection failed") - -// Wrap an existing error with context -if err != nil { - return ewrap.Wrap(err, "failed to process request") -} +## Subpackages -// Create a new error with context -err := ewrap.Newf( - "operation failed: %w", err, - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithLogger(logger), -) +ewrap ships small, opt-in subpackages. Importing them is what enables their +features; if you don't import them, you don't pay for them. -err.Log() +### Circuit breaker + +```bash +go get github.com/hyp3rd/ewrap/breaker +``` + +```go +import "github.com/hyp3rd/ewrap/breaker" ``` -### Advanced Error Context +The breaker is a self-contained package with no dependency on the parent +`ewrap` module — you can use it on its own. + +### `slog` adapter -Add rich context and metadata to errors: +```bash +go get github.com/hyp3rd/ewrap/slog +``` ```go -import "github.com/hyp3rd/ewrap" -// ... -err := ewrap.New("operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithLogger(logger)). - WithMetadata("query", "SELECT * FROM users"). - WithMetadata("retry_count", 3) - -// Log the error with all context -err.Log() +import ewrapslog "github.com/hyp3rd/ewrap/slog" ``` -### Error Groups with Pooling +A 30-line adapter that lets a stdlib `*slog.Logger` satisfy `ewrap.Logger`. +Stdlib-only — no extra deps. + +## Logger adapters for other libraries -Use error groups efficiently in high-throughput scenarios: +ewrap intentionally does **not** bundle adapters for zap, zerolog, logrus, or +glog. The `ewrap.Logger` interface has three methods; a working adapter is +under ten lines: ```go -// Create an error group pool with initial capacity -pool := ewrap.NewErrorGroupPool(4) +type zapAdapter struct{ l *zap.Logger } + +func (a *zapAdapter) Error(msg string, kv ...any) { a.l.Sugar().Errorw(msg, kv...) } +func (a *zapAdapter) Debug(msg string, kv ...any) { a.l.Sugar().Debugw(msg, kv...) } +func (a *zapAdapter) Info(msg string, kv ...any) { a.l.Sugar().Infow(msg, kv...) } +``` + +Pass an instance to `ewrap.WithLogger(adapter)`. -// Get an error group from the pool -eg := pool.Get() -defer eg.Release() // Return to pool when done +## Verify the install -// Add errors as needed -eg.Add(err1) -eg.Add(err2) +```bash +cat <<'EOF' > smoke.go +package main + +import ( + "fmt" + + "github.com/hyp3rd/ewrap" +) -if eg.HasErrors() { - return eg.Error() +func main() { + err := ewrap.New("ewrap is installed") + fmt.Printf("%+v\n", err) } +EOF + +go run smoke.go ``` + +The expected output is `"ewrap is installed"` followed by the captured stack +trace (filtered to your code only). diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index 7dc52bd..6573293 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -1,124 +1,158 @@ -# Quick Start Guide +# Quick Start -This guide will help you get started with ewrap quickly. We'll cover the basic concepts and show you how to use the main features of the package. +This guide walks through the core surface of ewrap in five minutes. -## Basic Usage - -### Creating Errors - -The simplest way to create an error with ewrap is using the `New` function: +## Create errors ```go -err := ewrap.New("something went wrong") +import "github.com/hyp3rd/ewrap" + +err := ewrap.New("database connection failed") // captures stack at the call site ``` -### Adding Context +The returned `*Error` implements the `error` interface. You can return it +anywhere a regular `error` is expected. -You can add context to your errors using various options: +## Format with arguments — `Newf` is `%w`-aware ```go -err := ewrap.New("database connection failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithLogger(logger)) +err := ewrap.Newf("query %q failed: %w", q, ioErr) + +errors.Is(err, ioErr) // true — %w preserves the cause chain +err.Error() // "query \"...\" failed: " ``` -### Wrapping Errors +If `format` doesn't contain `%w`, `Newf` behaves like `fmt.Sprintf` plus a +stack capture. -When you want to add context to an existing error: +## Wrap existing errors ```go -if err != nil { - return ewrap.Wrap(err, "failed to process request") +if err := db.Ping(); err != nil { + return ewrap.Wrap(err, "syncing replicas") } ``` -### Using Error Groups +`Wrap` captures its own stack frames, so deep chains carry the full call +history rather than just the innermost site. `Wrap(nil, ...)` returns nil +so you can call it unconditionally if you prefer. -Error groups help you collect and manage multiple errors: +`Wrapf` is the formatted variant: ```go -// Create an error group pool -pool := ewrap.NewErrorGroupPool(4) +return ewrap.Wrapf(err, "loading row %d for tenant %s", id, tenantID) +``` -// Get an error group from the pool -eg := pool.Get() -defer eg.Release() // Don't forget to release it back to the pool +## Add structured context + +```go +err := ewrap.New("payment authorization rejected", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true), + ewrap.WithSafeMessage("payment authorization rejected"), // omits PII + ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{ + Message: "Inspect upstream provider's queue and retry after backoff.", + Documentation: "https://runbooks.example.com/payments/timeout", + }), +). + WithMetadata("provider", "stripe"). + WithMetadata("attempt", 2) +``` -// Add errors as needed -eg.Add(err1) -eg.Add(err2) +Reserved fields (`ErrorContext`, `RecoverySuggestion`, `RetryInfo`) live in +typed fields, not the user metadata map — they have dedicated accessors and +can't be silently overwritten by a stray `WithMetadata` key. -if eg.HasErrors() { - return eg.Error() -} +## Read the structured fields back + +```go +err.GetErrorContext() // *ErrorContext (or nil) +err.Recovery() // *RecoverySuggestion (or nil) +err.Retry() // *RetryInfo (or nil) +err.GetMetadata("attempt") // (any, bool) for user metadata + +ewrap.GetMetadataValue[int](err, "attempt") // generic, type-checked accessor ``` -### Implementing Circuit Breaker +## Walk and classify the chain + +```go +errors.Is(err, ioErr) +errors.As(err, &netErr) +errors.Unwrap(err) + +ewrap.HTTPStatus(err) // walks chain; 0 if no layer set one +ewrap.IsRetryable(err) // true if any layer set Retryable, or stdlib Temporary() +err.SafeError() // redacted variant for external sinks +``` -Protect your system from cascading failures: +## Format and log ```go -cb := ewrap.NewCircuitBreaker("database", 3, time.Minute) - -if cb.CanExecute() { - err := performOperation() - if err != nil { - cb.RecordFailure() - return err - } - cb.RecordSuccess() -} +fmt.Printf("%+v\n", err) // message + filtered stack (fmt.Formatter) +fmt.Printf("%v\n", err) // message only +fmt.Printf("%q\n", err) // quoted + +slog.Error("payment failed", "err", err) // *Error implements slog.LogValuer ``` -## Next Steps +When you've attached a `Logger`, `(*Error).Log` emits a single structured +record with message, cause, stack, recovery, and all metadata: -Now that you understand the basics, you can: +```go +err := ewrap.New("boom", ewrap.WithLogger(logger)) +err.Log() +``` -1. Learn about [Error Types](../features/error-types.md) -1. Explore [Logging Integration](../features/logging.md) -1. Study [Advanced Usage](../advanced/performance.md) -1. Check out complete [Examples](../examples/basic.md) +## Aggregate with `ErrorGroup` -## Best Practices +```go +pool := ewrap.NewErrorGroupPool(4) +eg := pool.Get() +defer eg.Release() -Here are some best practices to follow when using ewrap: +eg.Add(validate(req)) +eg.Add(persist(req)) -1. Always provide meaningful error messages -1. Use appropriate error types and severity levels -1. Release error groups back to their pools -1. Configure circuit breakers based on your system's characteristics -1. Implement proper logging integration -1. Use metadata to add relevant debugging information +if err := eg.Join(); err != nil { // errors.Join semantics + return err +} +``` -## Common Patterns +`(*ErrorGroup).ToJSON()` and `ToYAML()` walk both `*Error` and standard +wrapped chains. -Here are some common patterns you might find useful: +## Add a circuit breaker (opt-in) ```go -func processItem(ctx context.Context, item string) error { - // Create error group from pool - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Validate input - if err := validate(item); err != nil { - eg.Add(ewrap.Wrap(err, "validation failed", - ewrap.WithContext(ctx), - ewrap.WithErrorType(ewrap.ErrorTypeValidation))) - } - - // Process if no validation errors - if !eg.HasErrors() { - if err := process(item); err != nil { - return ewrap.Wrap(err, "processing failed", - ewrap.WithContext(ctx), - ewrap.WithErrorType(ewrap.ErrorTypeInternal)) - } - } - - return eg.Error() +import "github.com/hyp3rd/ewrap/breaker" + +cb := breaker.New("payments", 5, 30*time.Second) + +if !cb.CanExecute() { + return ewrap.New("payments breaker open", ewrap.WithRetryable(true)) +} + +if err := charge(req); err != nil { + cb.RecordFailure() + + return ewrap.Wrap(err, "charging customer", + ewrap.WithHTTPStatus(http.StatusBadGateway)) } + +cb.RecordSuccess() ``` -This is just a starting point. For more detailed information about specific features, check out the relevant sections in the documentation. +The breaker is in a sibling subpackage, so importing only `ewrap` doesn't +bring it into your binary. + +## Where to go next + +- [Error Creation](../features/error-creation.md) — `New`, `Newf`, options +- [Error Wrapping](../features/error-wrapping.md) — `Wrap`, `Wrapf`, chain semantics +- [Stack Traces](../features/stack-traces.md) — capture, filter, depth, caller skip +- [Operational Features](../features/operational.md) — HTTP / retry / safe message +- [`fmt.Formatter` & `slog`](../features/format-and-slog.md) +- [Circuit Breaker](../features/circuit-breaker.md) +- [API Reference](../api/overview.md) diff --git a/docs/docs/index.md b/docs/docs/index.md index d503af2..70ecb07 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,116 +1,96 @@ # ewrap Documentation -Welcome to the documentation for `ewrap`, a sophisticated, modern error handling library for Go applications that provides comprehensive error management with advanced features, observability hooks, and seamless integration with Go 1.25+ features. +`ewrap` is a lightweight, modern Go error library: rich context, stack traces, +structured serialization, `slog` and `fmt.Formatter` integration, HTTP / retry +classification, PII-safe logging, and an opt-in circuit breaker — all in a +tight dependency footprint (yaml + a fast JSON encoder, nothing else). + +## Highlights + +- **Stdlib-first.** Two direct deps in the core module: `gopkg.in/yaml.v3` for + YAML, `github.com/goccy/go-json` for the serialization hot path. +- **Correct by default.** `errors.Is` / `errors.As` work via `Unwrap()`; `Newf` + honors `%w`; every wrap captures its own stack frames. +- **Lazy & cached hot paths.** Lazy metadata map; `Error()` and `Stack()` + cached via `sync.Once`. After the first call, `Stack()` is ~1.7 ns/op, + zero allocations. +- **Modern Go integrations.** `(*Error).Format` for `%+v`; `(*Error).LogValue` + for `slog`; `errors.Join`-aware `ErrorGroup`. +- **Operational features.** HTTP status, retryable / `Temporary()` + classification, safe (PII-redacted) messages, recovery suggestions, + structured `ErrorContext`. +- **Opt-in subpackages.** Circuit breaker lives in `ewrap/breaker`; `slog` + adapter in `ewrap/slog`. Importing `ewrap` alone pulls in only the core. + +## Quick example -## Overview - -ewrap is designed to make error handling in Go applications more robust, informative, and maintainable. It provides a rich set of features while maintaining excellent performance characteristics through careful optimization, efficient memory management, and modern Go language features. - -### Key Features - -- **Advanced Stack Traces**: Programmatic stack frame inspection with iterators and structured access -- **Smart Error Wrapping**: Maintains error chains with unified context handling and metadata preservation -- **Modern Logging Integration**: Support for slog (Go 1.21+), logrus, zap, zerolog with structured output -- **Observability Hooks**: Built-in metrics and tracing for error frequencies and circuit-breaker states -- **Go 1.25+ Optimizations**: Uses `maps.Clone` and `slices.Clone` for efficient copying operations -- **Pool-based Error Groups**: Memory-efficient error aggregation with `errors.Join` compatibility -- **Circuit Breaker Pattern**: Protect systems from cascading failures with state transition monitoring -- **Custom Retry Logic**: Configurable per-error retry strategies with `RetryInfo` extension -- **Recovery Guidance**: Integrated recovery suggestions in error output and logging -- **Structured Serialization**: JSON/YAML export with full error group serialization -- **Thread-Safe Operations**: Zero-allocation hot paths with minimal contention -- **Type-Safe Metadata**: Optional generics support for strongly typed error contexts +```go +package main -## Quick Example +import ( + "context" + "log/slog" + "net/http" + "os" + "time" -Here's a comprehensive example showcasing the modern features of ewrap: + "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/ewrap/breaker" + ewrapslog "github.com/hyp3rd/ewrap/slog" +) -```go func processOrder(ctx context.Context, orderID string) error { - // Set up observability - observer := &MyObserver{metricsClient: metrics, tracer: trace} - - // Get an error group from the pool with errors.Join support - pool := ewrap.NewErrorGroupPool(4) - eg := pool.Get() - defer eg.Release() - - // Create a circuit breaker with observability hooks - cb := ewrap.NewCircuitBreaker("payment-service", 5, time.Minute*2, - ewrap.WithObserver(observer)) - - // Add validation errors with recovery suggestions - if err := validateOrder(orderID); err != nil { - eg.Add(ewrap.Wrap(err, "invalid order", - ewrap.WithContext(ctx, ewrap.ErrorTypeValidation, ewrap.SeverityError), - ewrap.WithRecoverySuggestion("Validate order format and required fields"), - ewrap.WithLogger(slogLogger))) - } + logger := ewrapslog.New(slog.New(slog.NewJSONHandler(os.Stdout, nil))) - // Handle database operations with custom retry logic - shouldRetry := func(err error, attempt int) bool { - return attempt < 3 && ewrap.IsType(err, ewrap.ErrorTypeNetwork) + cb := breaker.New("payments", 5, 30*time.Second) + if !cb.CanExecute() { + return ewrap.New("payments breaker open", + ewrap.WithRetryable(true), + ewrap.WithLogger(logger)) } - if !eg.HasErrors() && cb.CanExecute() { - if err := saveToDatabase(orderID); err != nil { - cb.RecordFailure() - dbErr := ewrap.Wrap(err, "database operation failed", - ewrap.WithContext(ctx, ewrap.ErrorTypeDatabase, ewrap.SeverityCritical), - ewrap.WithRetryInfo(3, time.Second*5, shouldRetry), - ewrap.WithRecoverySuggestion("Check database connectivity and connection pool")) - - // Inspect stack frames programmatically - if iterator := dbErr.GetStackIterator(); iterator.HasNext() { - frame := iterator.Next() - // Custom handling based on stack frame information - handleCriticalFrame(frame) - } - - return dbErr - } - cb.RecordSuccess() + if err := charge(ctx, orderID); err != nil { + cb.RecordFailure() + + return ewrap.Wrap(err, "charging customer", + ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError), + ewrap.WithHTTPStatus(http.StatusBadGateway), + ewrap.WithRetryable(true), + ewrap.WithSafeMessage("charge failed"), + ewrap.WithLogger(logger), + ). + WithMetadata("order_id", orderID) } - // Use errors.Join compatibility for standard library integration - if err := eg.Join(); err != nil { - // Serialize the entire error group for structured logging - if jsonOutput, serErr := eg.ToJSON(ewrap.WithTimestampFormat(time.RFC3339)); serErr == nil { - structuredLogger.Error("order processing failed", "errors", jsonOutput) - } - return err - } + cb.RecordSuccess() return nil } ``` -## Getting Started - -To start using ewrap in your project, visit the [Installation](getting-started/installation.md) guide, followed by the [Quick Start](getting-started/quickstart.md) tutorial. +This example uses every subsystem: structured logging, the `breaker` +subpackage, classification (`HTTPStatus`, `Retryable`), PII redaction +(`SafeMessage`), and metadata. ## Why ewrap -ewrap was created to address common challenges in modern Go error handling: - -### Traditional Challenges Solved - -1. **Context Loss**: Traditional error handling often loses important context during error propagation -1. **Performance Overhead**: Many error handling libraries introduce significant memory and CPU overhead -1. **Memory Management**: Poor memory management in error handling leads to increased GC pressure -1. **Inconsistent Logging**: Different parts of applications handle error logging differently -1. **Missing Stack Traces**: Getting meaningful, filterable stack traces is challenging -1. **Circuit Breaking**: Protecting systems from cascading failures requires complex implementation - -### Modern Go Challenges Addressed - -1. **Go 1.25+ Feature Integration**: Lack of libraries leveraging modern Go performance features -1. **Observability Gaps**: Missing built-in support for metrics and tracing in error handling -1. **Recovery Guidance**: Errors without actionable remediation suggestions -1. **Type Safety**: Metadata handling without compile-time guarantees -1. **Standard Library Integration**: Poor integration with `errors.Join` and modern error patterns -1. **Serialization Complexity**: Difficulty in structured error export for monitoring systems - -ewrap provides solutions to all these challenges while maintaining backward compatibility and excellent performance characteristics. - -ewrap solves these challenges while maintaining excellent performance characteristics and providing a clean, intuitive API. +| Need | What ewrap provides | +| --- | --- | +| Wrap with stack traces | `New`, `Wrap`, `Newf` (`%w`-aware), `Wrapf` | +| Structured cause chain | `Unwrap()`; `errors.Is/As` work as you'd expect | +| Pretty stack output | `fmt.Printf("%+v", err)` via `fmt.Formatter` | +| Structured logging | `slog.LogValuer` exposes typed fields automatically | +| Multi-error aggregation | `ErrorGroup` (pooled) + `errors.Join` | +| HTTP / retry policy | `WithHTTPStatus` / `WithRetryable` + walkers | +| PII-safe logs | `WithSafeMessage` / `(*Error).SafeError()` | +| Cascade protection | `breaker` subpackage (no extra deps for non-users) | +| Transport | `ToJSON`, `ToYAML` walk both `*Error` and standard chains | + +## Next steps + +- [Installation](getting-started/installation.md) +- [Quick Start](getting-started/quickstart.md) +- [Error Creation](features/error-creation.md) and [Wrapping](features/error-wrapping.md) +- [HTTP / Retry / Safe operational features](features/operational.md) +- [Circuit breaker subpackage](features/circuit-breaker.md) +- [API Reference](api/overview.md) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2f6fbaf..ff3e60f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -93,9 +93,16 @@ nav: - Error Wrapping: features/error-wrapping.md - Stack Traces: features/stack-traces.md - Error Groups: features/error-groups.md - - Circuit Breaker: features/circuit-breaker.md - Metadata: features/metadata.md - Logging: features/logging.md + - Observability: features/observability.md + - Serialization: features/serialization.md + - Operational Features: + - HTTP / Retry / Safe: features/operational.md + - fmt.Formatter and slog: features/format-and-slog.md + - Subpackages: + - breaker (circuit breaker): features/circuit-breaker.md + - slog adapter: features/slog-adapter.md - Advanced Usage: - Error Strategies: advanced/error-strategies.md - Performance Optimization: advanced/performance.md @@ -124,4 +131,4 @@ extra: extra_css: - stylesheets/extra.css -copyright: Copyright © 2024 Francesco Cosentino +copyright: Copyright © Francesco Cosentino From 0d431fa7cb7ad7a3fcd70a70f81e66a539823f53 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 10:27:12 +0200 Subject: [PATCH 06/17] ci(lint): remove pinned golangci-lint version from workflow Comment out the explicit `version` input for the `golangci-lint-action` step, allowing the action to use its own default version rather than resolving it from the settings output. This simplifies the workflow and avoids potential failures if the settings-derived version becomes stale or unavailable. --- .github/workflows/lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 016098e..112115a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -61,5 +61,5 @@ jobs: run: staticcheck ./... - name: golangci-lint uses: golangci/golangci-lint-action@v9.2.0 - with: - version: "${{ steps.settings.outputs.golangci_lint_version }}" + # with: + # version: "${{ steps.settings.outputs.golangci_lint_version }}" From ad557dcfed086a0b97e97d7484665d3d5a5f65cd Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 12:16:49 +0200 Subject: [PATCH 07/17] ci: upgrade CodeQL workflow to advanced setup with multi-language support - Rename workflow from "CodeQL" to "CodeQL Advanced" - Add `actions` and `ruby` languages to the analysis matrix alongside `go` - Introduce per-language `build-mode` configuration (autobuild for Go, none for actions/ruby) replacing the single global Autobuild step - Update runner selection to use `macos-latest` for Swift, `ubuntu-latest` otherwise - Add a `Run manual build steps` step (gated on `build-mode == manual`) to surface explicit failures for compiled languages that need a manual build - Fix action pin: `actions/checkout` downgraded from v6 to v4 for compatibility with current CodeQL action versions - Update CodeQL scheduled scan cron to `31 1 * * 4` - Reorder permissions block and add `packages: read` for private CodeQL pack fetching --- .github/workflows/codeql.yml | 88 ++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6e0a2fc..ea7bc97 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,3 @@ ---- # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # @@ -10,60 +9,93 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: "CodeQL Advanced" on: push: branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above branches: [ "main" ] schedule: - - cron: "33 23 * * 3" + - cron: "31 1 * * 4" jobs: analyze: - name: Analyze - runs-on: ubuntu-latest + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories actions: read contents: read - security-events: write strategy: fail-fast: false matrix: - language: [ "go" ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + - language: ruby + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} - queries: security-extended,security-and-quality - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 From fad3d54f961d63f688f9ea887e0e0bffdb62a4ee Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 12:20:52 +0200 Subject: [PATCH 08/17] fix(ci): replace stale starter references with ewrap Update GCI import prefix default and provenance archive naming from `github.com/hyp3rd/starter` to `github.com/hyp3rd/ewrap` across lint and provenance workflows. Also apply minor YAML formatting cleanups (branch list spacing, long-line wrapping) in .github/lint.yml. --- .github/lint.yml | 16 +++++++++++----- .github/workflows/lint.yml | 2 +- .github/workflows/provenance.yml | 4 ++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/lint.yml b/.github/lint.yml index 6ed678a..dfda9ae 100644 --- a/.github/lint.yml +++ b/.github/lint.yml @@ -4,7 +4,7 @@ name: lint on: pull_request: push: - branches: [main] + branches: [ main ] jobs: lint: @@ -18,7 +18,7 @@ jobs: source .project-settings.env set +a echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" - echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/starter}" >> "$GITHUB_OUTPUT" + echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/ewrap}" >> "$GITHUB_OUTPUT" echo "golangci_lint_version=${GOLANGCI_LINT_VERSION}" >> "$GITHUB_OUTPUT" echo "proto_enabled=${PROTO_ENABLED:-true}" >> "$GITHUB_OUTPUT" - name: Setup Go @@ -32,7 +32,8 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ hashFiles('**/go.sum') }} + key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ + hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}- - name: Install tools @@ -48,9 +49,14 @@ jobs: go mod tidy git diff --exit-code go.mod go.sum - name: gci - run: gci write -s standard -s default -s blank -s dot -s "prefix(${{ steps.settings.outputs.gci_prefix }})" -s localmodule --skip-vendor --skip-generated $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") + run: gci write -s standard -s default -s blank -s dot -s "prefix(${{ + steps.settings.outputs.gci_prefix }})" -s localmodule --skip-vendor + --skip-generated $(find . -type f -name '*.go' -not -path + "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not + -path "./.git/*") - name: gofumpt - run: gofumpt -l -w $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") + run: gofumpt -l -w $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not + -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") - name: staticcheck run: staticcheck ./... - name: golangci-lint diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 112115a..0fa6fda 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: source .project-settings.env set +a echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" - echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/starter}" >> "$GITHUB_OUTPUT" + echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/ewrap}" >> "$GITHUB_OUTPUT" echo "golangci_lint_version=${GOLANGCI_LINT_VERSION}" >> "$GITHUB_OUTPUT" echo "proto_enabled=${PROTO_ENABLED:-true}" >> "$GITHUB_OUTPUT" - name: Setup Go diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 4cb5099..53b2415 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -25,8 +25,8 @@ jobs: set -euo pipefail ref="${GITHUB_REF_NAME}" safe_ref="${ref//\//-}" - archive="starter-${safe_ref}.tar.gz" - git archive --format=tar.gz --prefix="starter-${safe_ref}/" -o "${archive}" "${ref}" + archive="ewrap-${safe_ref}.tar.gz" + git archive --format=tar.gz --prefix="ewrap-${safe_ref}/" -o "${archive}" "${ref}" echo "archive=${archive}" >> "$GITHUB_OUTPUT" - name: Compute subjects id: subjects From d6bcf87e95cb31f8d57ed0698948afa56c61d5d7 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 12:30:15 +0200 Subject: [PATCH 09/17] docs: extract package-level doc comments into dedicated docs.go files Move package doc comments from implementation files into separate docs.go files for the root `ewrap`, `breaker`, and `slog` packages. This follows the Go convention of keeping package documentation in a dedicated file, keeping implementation files free of doc-only preamble. - Add `docs.go` to root package (`ewrap`) with module-level doc comment - Add `breaker/docs.go` with circuit-breaker package doc (moved from breaker.go) - Add `slog/docs.go` with slog adapter package doc (moved from slog.go) - Reformat README.md badges onto individual lines for better readability - Add Stdlib to the cspell custom dictionary --- .cspell/custom-dictionary.txt | 1 + README.md | 10 ++++++++-- breaker/breaker.go | 7 ------- breaker/docs.go | 9 +++++++++ docs.go | 5 +++++ slog/docs.go | 3 +++ slog/slog.go | 2 -- 7 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 breaker/docs.go create mode 100644 docs.go create mode 100644 slog/docs.go diff --git a/.cspell/custom-dictionary.txt b/.cspell/custom-dictionary.txt index 2de108e..b3e9be3 100644 --- a/.cspell/custom-dictionary.txt +++ b/.cspell/custom-dictionary.txt @@ -7,5 +7,6 @@ myapp Println retryable Retryable +Stdlib subpackages uintptr diff --git a/README.md b/README.md index 5102082..1fb1cfb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # ewrap -[![Go](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml/badge.svg)](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml) [![Docs](https://img.shields.io/badge/docs-passing-brightgreen)](https://hyp3rd.github.io/ewrap/) [![Go Report Card](https://goreportcard.com/badge/github.com/hyp3rd/ewrap)](https://goreportcard.com/report/github.com/hyp3rd/ewrap) [![Go Reference](https://pkg.go.dev/badge/github.com/hyp3rd/ewrap.svg)](https://pkg.go.dev/github.com/hyp3rd/ewrap) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![GitHub Sponsors](https://img.shields.io/github/sponsors/hyp3rd/sponsors) +[![Go](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml/badge.svg)](https://github.com/hyp3rd/ewrap/actions/workflows/go.yml) +[![Docs](https://img.shields.io/badge/docs-passing-brightgreen)](https://hyp3rd.github.io/ewrap/) +[![Go Report Card](https://goreportcard.com/badge/github.com/hyp3rd/ewrap)](https://goreportcard.com/report/github.com/hyp3rd/ewrap) +[![Go Reference](https://pkg.go.dev/badge/github.com/hyp3rd/ewrap.svg)](https://pkg.go.dev/github.com/hyp3rd/ewrap) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +![GitHub Sponsors](https://img.shields.io/github/sponsors/hyp3rd/sponsors) A lightweight, modern Go error library: rich context, stack traces, structured serialization, `slog`/`fmt.Formatter` integration, HTTP/retry classification, @@ -9,7 +14,8 @@ footprint (yaml + a fast JSON encoder, nothing else). ## Highlights -- **Stdlib-first.** Two direct deps in the core module: [`gopkg.in/yaml.v3`][yaml] for YAML, +- **Stdlib-first.** Two direct deps in the core module: + [`gopkg.in/yaml.v3`][yaml] for YAML, [`github.com/goccy/go-json`][goccy] for the serialization hot path (~2.5× faster than `encoding/json`). - **Correct by default.** `errors.Is` / `errors.As` work via `Unwrap()`; `Newf` honors `%w`; every wrap captures its own stack frames. diff --git a/breaker/breaker.go b/breaker/breaker.go index fc76ed1..151a764 100644 --- a/breaker/breaker.go +++ b/breaker/breaker.go @@ -1,10 +1,3 @@ -// Package breaker implements the classic circuit-breaker pattern. It is -// independent of the parent ewrap module — consumers who only need error -// wrapping do not pay for it. -// -// The breaker is goroutine-safe. All state transitions happen under a single -// lock; observer and OnStateChange callbacks fire synchronously after the -// lock is released, so callbacks must not invoke the breaker recursively. package breaker import ( diff --git a/breaker/docs.go b/breaker/docs.go new file mode 100644 index 0000000..11029d8 --- /dev/null +++ b/breaker/docs.go @@ -0,0 +1,9 @@ +package breaker + +// Package breaker implements the classic circuit-breaker pattern. It is +// independent of the parent ewrap module — consumers who only need error +// wrapping do not pay for it. +// +// The breaker is goroutine-safe. All state transitions happen under a single +// lock; observer and OnStateChange callbacks fire synchronously after the +// lock is released, so callbacks must not invoke the breaker recursively. diff --git a/docs.go b/docs.go new file mode 100644 index 0000000..5a43348 --- /dev/null +++ b/docs.go @@ -0,0 +1,5 @@ +// ewrap is a lightweight, modern Go error library: rich context, stack traces, structured +// serialization, `slog`/`fmt.Formatter` integration, HTTP/retry classification, +// PII-safe logging, and an opt-in circuit breaker — all in a tight dependency +// footprint (yaml + a fast JSON encoder, nothing else). +package ewrap diff --git a/slog/docs.go b/slog/docs.go new file mode 100644 index 0000000..8d7fe0a --- /dev/null +++ b/slog/docs.go @@ -0,0 +1,3 @@ +// Package slog provides an adapter that lets a stdlib *slog.Logger satisfy +// the ewrap.Logger interface. +package slog diff --git a/slog/slog.go b/slog/slog.go index 92a2770..d726d0f 100644 --- a/slog/slog.go +++ b/slog/slog.go @@ -1,5 +1,3 @@ -// Package slog provides an adapter that lets a stdlib *slog.Logger satisfy -// the ewrap.Logger interface. package slog import "log/slog" From e82815584824e373a5b3579bd1b3d914cb565027 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 15:25:30 +0200 Subject: [PATCH 10/17] chore: pin CI action to commit SHA and fix doc conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin golangci/golangci-lint-action to a specific commit hash for improved supply-chain security instead of using a mutable version tag - Fix package doc comments in docs.go and breaker/docs.go to follow Go conventions (must begin with "Package ") - Correct GitHub Sponsors badge URL in README (remove erroneous /sponsors suffix) - Fix typo: "categorisation" → "categorization" in README - Extend cspell custom dictionary and config with missing Go-related terms (ewrapslog, glog, gopkg, stdslog, benchmem, gofumpt, gosec) --- .cspell/custom-dictionary.txt | 4 ++++ .github/workflows/lint.yml | 2 +- README.md | 4 ++-- breaker/docs.go | 3 +-- cspell.config.yaml | 3 +++ docs.go | 2 +- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.cspell/custom-dictionary.txt b/.cspell/custom-dictionary.txt index b3e9be3..16e0a12 100644 --- a/.cspell/custom-dictionary.txt +++ b/.cspell/custom-dictionary.txt @@ -1,12 +1,16 @@ # Custom Dictionary Words Errorf +ewrapslog +glog goexit golangci +gopkg monkeypatch myapp Println retryable Retryable Stdlib +stdslog subpackages uintptr diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0fa6fda..520c3e2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -60,6 +60,6 @@ jobs: - name: staticcheck run: staticcheck ./... - name: golangci-lint - uses: golangci/golangci-lint-action@v9.2.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # with: # version: "${{ steps.settings.outputs.golangci_lint_version }}" diff --git a/README.md b/README.md index 1fb1cfb..10a2cf3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/hyp3rd/ewrap)](https://goreportcard.com/report/github.com/hyp3rd/ewrap) [![Go Reference](https://pkg.go.dev/badge/github.com/hyp3rd/ewrap.svg)](https://pkg.go.dev/github.com/hyp3rd/ewrap) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -![GitHub Sponsors](https://img.shields.io/github/sponsors/hyp3rd/sponsors) +![GitHub Sponsors](https://img.shields.io/github/sponsors/hyp3rd) A lightweight, modern Go error library: rich context, stack traces, structured serialization, `slog`/`fmt.Formatter` integration, HTTP/retry classification, @@ -193,7 +193,7 @@ cb := breaker.NewWithObserver("payments", 5, 30*time.Second, &metrics{}) ## Error types and severity -Pre-defined enums for categorisation. Their `String()` form is what shows up +Pre-defined enums for categorization. Their `String()` form is what shows up in `ErrorOutput.Type` / `Severity`, JSON, and `slog` fields. ```go diff --git a/breaker/docs.go b/breaker/docs.go index 11029d8..0781707 100644 --- a/breaker/docs.go +++ b/breaker/docs.go @@ -1,5 +1,3 @@ -package breaker - // Package breaker implements the classic circuit-breaker pattern. It is // independent of the parent ewrap module — consumers who only need error // wrapping do not pay for it. @@ -7,3 +5,4 @@ package breaker // The breaker is goroutine-safe. All state transitions happen under a single // lock; observer and OnStateChange callbacks fire synchronously after the // lock is released, so callbacks must not invoke the breaker recursively. +package breaker diff --git a/cspell.config.yaml b/cspell.config.yaml index fbca3bb..d512602 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -33,6 +33,7 @@ dictionaries: - custom-dictionary words: - Atoi + - benchmem - codacy - daixiang - eamodio @@ -52,10 +53,12 @@ words: - goccy - gofiber - GOFILES + - gofumpt - golines - gomod - GOPATH - goroutines + - gosec - GOTOOLCHAIN - govulncheck - httptest diff --git a/docs.go b/docs.go index 5a43348..b38a3ee 100644 --- a/docs.go +++ b/docs.go @@ -1,4 +1,4 @@ -// ewrap is a lightweight, modern Go error library: rich context, stack traces, structured +// Package ewrap is a lightweight, modern Go error library: rich context, stack traces, structured // serialization, `slog`/`fmt.Formatter` integration, HTTP/retry classification, // PII-safe logging, and an opt-in circuit breaker — all in a tight dependency // footprint (yaml + a fast JSON encoder, nothing else). From e5a4fc852192cde5cb82b4c051c14830ba957ddc Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 16:00:22 +0200 Subject: [PATCH 11/17] fix(lint): re-enable linters with config and fix nolint directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-enable `embeddedstructfieldcheck`, `gomoddirectives`, `lll`, `recvcheck`, `tagliatelle`, and `tagalign` linters (moved from `disable` to configured) - Disable `nolintlint` to suppress noisy meta-lint warnings - Add `tagliatelle` config enforcing snake_case for JSON/YAML struct tags - Remove `gofiber/fiber` from `wrapcheck` ignore globs - Move `//nolint:revive` to function-level directive in `profile_test.go` and extract inline comment for clarity - Fix typo: parallelised → parallelized - Add `paralleltest` to cspell custom dictionary - Remove redundant package-level doc comment from `errors.go` --- .cspell/custom-dictionary.txt | 1 + .golangci.yaml | 44 +++++++++++++++++++++++++++++------ errors.go | 2 -- test/profile_test.go | 7 ++++-- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/.cspell/custom-dictionary.txt b/.cspell/custom-dictionary.txt index 16e0a12..f9298bd 100644 --- a/.cspell/custom-dictionary.txt +++ b/.cspell/custom-dictionary.txt @@ -7,6 +7,7 @@ golangci gopkg monkeypatch myapp +paralleltest Println retryable Retryable diff --git a/.golangci.yaml b/.golangci.yaml index b073c6e..4eb7d5a 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -53,16 +53,17 @@ linters: - gomodguard_v2 disable: - depguard - - embeddedstructfieldcheck + # - embeddedstructfieldcheck - exhaustruct - - gomoddirectives + # - gomoddirectives - gomodguard - ireturn - - lll + # - lll + - nolintlint - nonamedreturns - - recvcheck - - tagliatelle - - tagalign + # - recvcheck + # - tagliatelle + # - tagalign - testpackage - wsl @@ -173,12 +174,41 @@ linters: disabled: false exclude: [ "" ] + tagliatelle: + # Checks the struct tag name case. + case: + # Defines the association between tag name and case. + # Any struct tag name can be used. + # Supported string cases: + # - `camel` + # - `pascal` + # - `kebab` + # - `snake` + # - `upperSnake` + # - `goCamel` + # - `goPascal` + # - `goKebab` + # - `goSnake` + # - `upper` + # - `lower` + # - `header` + rules: + json: snake + yaml: snake + # xml: camel + # toml: camel + # bson: camel + # avro: snake + # mapstructure: kebab + # env: upperSnake + # envconfig: upperSnake + # whatever: snake + wrapcheck: # An array of strings that specify globs of packages to ignore. # Default: [] ignore-package-globs: - github.com/hyp3rd/* - - github.com/gofiber/fiber/* varnamelen: # The longest distance, in source lines, that is being considered a "small scope". diff --git a/errors.go b/errors.go index d011c15..1d0e230 100644 --- a/errors.go +++ b/errors.go @@ -1,5 +1,3 @@ -// Package ewrap provides enhanced error handling capabilities with stack traces, -// error wrapping, custom error types, and logging integration. package ewrap import ( diff --git a/test/profile_test.go b/test/profile_test.go index 2c44961..e55c644 100644 --- a/test/profile_test.go +++ b/test/profile_test.go @@ -94,8 +94,11 @@ func goroutineProfileCase() profileCase { // forceGC explicitly triggers garbage collection so heap profiles capture a // post-collection snapshot. Profile-only helper; production code never needs // this. +// +//nolint:revive func forceGC() { - runtime.GC() //nolint:revive // explicit GC is intentional for profile snapshots + // explicit GC is intentional for profile snapshots. + runtime.GC() } // TestProfileErrorOperations runs a comprehensive profiling suite for error operations. @@ -103,7 +106,7 @@ func forceGC() { // of our error handling implementation. // // This test mutates the global runtime.MemProfileRate and emits profile files -// to the working directory, so it cannot be parallelised. +// to the working directory, so it cannot be parallelized. // //nolint:paralleltest // mutates runtime.MemProfileRate global state func TestProfileErrorOperations(t *testing.T) { From 16857d25576c228ccd5219a17762476730928b1a Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 16:00:48 +0200 Subject: [PATCH 12/17] fix(lint): re-enable linters with config and fix nolint directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-enable `embeddedstructfieldcheck`, `gomoddirectives`, `lll`, `recvcheck`, `tagliatelle`, and `tagalign` linters (moved from `disable` to configured) - Disable `nolintlint` to suppress noisy meta-lint warnings - Add `tagliatelle` config enforcing snake_case for JSON/YAML struct tags - Remove `gofiber/fiber` from `wrapcheck` ignore globs - Move `//nolint:revive` to function-level directive in `profile_test.go` and extract inline comment for clarity - Fix typo: parallelised → parallelized - Add `paralleltest` to cspell custom dictionary - Remove redundant package-level doc comment from `errors.go` --- .golangci.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 4eb7d5a..0e1ca86 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -53,17 +53,11 @@ linters: - gomodguard_v2 disable: - depguard - # - embeddedstructfieldcheck - exhaustruct - # - gomoddirectives - gomodguard - ireturn - # - lll - nolintlint - nonamedreturns - # - recvcheck - # - tagliatelle - # - tagalign - testpackage - wsl From 512a88154f314098dce1282b8238645c92b8e49e Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 16:27:41 +0200 Subject: [PATCH 13/17] chore: rename docs.go to doc.go and clean up project scaffolding - Rename docs.go to doc.go in root, breaker/, and slog/ packages to follow Go naming conventions - Add godoc Makefile target for local documentation server on :8089 - Remove unused .gitkeep placeholders from configs/ and scripts/ --- Makefile | 4 ++++ breaker/{docs.go => doc.go} | 0 configs/.gitkeep | 0 docs.go => doc.go | 0 scripts/.gitkeep | 0 slog/{docs.go => doc.go} | 0 6 files changed, 4 insertions(+) rename breaker/{docs.go => doc.go} (100%) delete mode 100644 configs/.gitkeep rename docs.go => doc.go (100%) delete mode 100644 scripts/.gitkeep rename slog/{docs.go => doc.go} (100%) diff --git a/Makefile b/Makefile index 19078d4..b5046c8 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,10 @@ sec: @echo "\nRunning gosec..." gosec -exclude-generated ./... +godoc: + @echo "Generating documentation..." + godoc -v -http=:8089 -play -index + ci: .PHONY # check_command_exists is a helper function that checks if a command exists. diff --git a/breaker/docs.go b/breaker/doc.go similarity index 100% rename from breaker/docs.go rename to breaker/doc.go diff --git a/configs/.gitkeep b/configs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs.go b/doc.go similarity index 100% rename from docs.go rename to doc.go diff --git a/scripts/.gitkeep b/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/slog/docs.go b/slog/doc.go similarity index 100% rename from slog/docs.go rename to slog/doc.go From 203991868fa4ac3582cfb38933f1def3a995d1fd Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 16:43:19 +0200 Subject: [PATCH 14/17] ci: add explicit read permissions to workflow files Add top-level `permissions: contents: read` to codeql and security workflows to follow the principle of least privilege for GitHub Actions. --- .github/workflows/codeql.yml | 20 ++++++++++++-------- .github/workflows/security.yml | 10 +++++++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ea7bc97..04e9004 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,3 +1,4 @@ +--- # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # @@ -19,6 +20,9 @@ on: schedule: - cron: "31 1 * * 4" +permissions: + contents: read + jobs: analyze: name: Analyze (${{ matrix.language }}) @@ -49,14 +53,14 @@ jobs: build-mode: autobuild - language: ruby build-mode: none - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 3f871e6..ff23314 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,9 +4,11 @@ name: security on: pull_request: push: - branches: [main] + branches: [ main ] schedule: - cron: "0 3 * * 1" +permissions: + contents: read jobs: security: @@ -31,7 +33,8 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ hashFiles('**/go.sum') }} + key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ + hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}- - name: Modules @@ -39,4 +42,5 @@ jobs: - name: govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./... - name: gosec - run: go install github.com/securego/gosec/v2/cmd/gosec@latest && gosec -exclude-generated ./... + run: go install github.com/securego/gosec/v2/cmd/gosec@latest && gosec + -exclude-generated ./... From 16fc7f3246940480992e91c72b0b106e84614290 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 17:00:59 +0200 Subject: [PATCH 15/17] chore(ci): add read permissions to provenance workflow --- .github/workflows/provenance.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/provenance.yml b/.github/workflows/provenance.yml index 53b2415..4de71b9 100644 --- a/.github/workflows/provenance.yml +++ b/.github/workflows/provenance.yml @@ -6,6 +6,8 @@ on: types: [ published ] workflow_dispatch: +permissions: + contents: read jobs: source: runs-on: ubuntu-latest From 548d38aaa8a98141e500706869b4659dade82bc9 Mon Sep 17 00:00:00 2001 From: "F." Date: Sat, 2 May 2026 17:16:56 +0200 Subject: [PATCH 16/17] docs: add markdownlint config to ignore line length violations --- .markdownlint.json | 3 +++ .pre-commit-config.yaml | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..1344b31 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD013": false +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8f7c056..e6dac9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,7 @@ repos: - id: check-merge-conflict - id: check-json - id: pretty-format-json + exclude: .markdownlint.json - id: end-of-file-fixer - id: mixed-line-ending - id: trailing-whitespace @@ -15,7 +16,7 @@ repos: - id: check-yaml files: .*\.(yaml|yml)$ exclude: mkdocs.yml - args: [--allow-multiple-documents] + args: [ --allow-multiple-documents ] - id: requirements-txt-fixer - id: no-commit-to-branch - repo: https://github.com/adrienverge/yamllint.git @@ -23,7 +24,7 @@ repos: hooks: - id: yamllint files: \.(yaml|yml)$ - types: [file, yaml] + types: [ file, yaml ] entry: yamllint --strict -f parsable - repo: https://github.com/hadolint/hadolint rev: v2.14.0 @@ -43,7 +44,7 @@ repos: - --no-summary - --files - .git/COMMIT_EDITMSG - stages: [commit-msg] + stages: [ commit-msg ] always_run: true - repo: https://github.com/markdownlint/markdownlint.git rev: v0.15.0 From 6aa145c1619f94f0a55066e3b01f1e720dbabd8f Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 00:15:29 +0200 Subject: [PATCH 17/17] chore: update Makefile benchmark target. --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index b5046c8..557ed9f 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,8 @@ test: test-race: go test -race ./... -benchmark: - go test -bench=. -benchmem ./pkg/ewrap +bench: + go test -bench=. -benchmem ./test go test -bench=Benchmark -benchmem ./test # go test -run=TestProfile -cpuprofile=cpu.prof -memprofile=mem.prof ./test @@ -122,7 +122,7 @@ help: @echo "update-deps\t\t\tUpdate all dependencies in the project." @echo "prepare-toolchain\t\tPrepare the development toolchain by installing necessary tools." @echo "update-toolchain\t\tUpdate the development toolchain tools to their latest versions." - @echo "benchmark\t\t\tRun benchmarks for the project." + @echo "bench\t\t\t\tRun benchmarks for the project." @echo "sec\t\t\t\tRun the govulncheck and gosec security analysis tools on all packages in the project." @echo "vet\t\t\t\tRun go vet and shadow analysis on all packages in the project." @echo "lint\t\t\t\tRun the staticcheck and golangci-lint static analysis tools on all packages in the project." @@ -131,4 +131,4 @@ help: @echo @echo "For more information, see the project README." -.PHONY: update-deps lint sec test test-race benchmark +.PHONY: update-deps lint sec test test-race bench