Skip to content

Release 0.6.0 through 0.7.0: security, reliability, and typing - #4

Merged
shihweilo merged 5 commits into
masterfrom
release/0.6.x
Aug 18, 2026
Merged

Release 0.6.0 through 0.7.0: security, reliability, and typing#4
shihweilo merged 5 commits into
masterfrom
release/0.6.x

Conversation

@shihweilo

Copy link
Copy Markdown
Owner

Brings the library from 0.5.2 to 0.7.0 across four commits. The first lands the
Pydantic migration that was sitting uncommitted in the working tree; the rest
address findings from a review of the client.

Every commit passes its own tests in isolation, verified in a clean worktree.

Commits

Commit Release Summary
c827699 0.6.0 Migrate CardHolderData to Pydantic v2
3129e5a 0.6.1 Request timeouts, log redaction, version single-sourcing, py.typed
fd412ca 0.7.0 Pooled session, selective retries, raise_on_error, currency selection
84076f6 0.7.0 InvalidResponseError on a malformed 2xx body

Highlights

Credentials and cardholder PII no longer reach logs. Debug logging previously
emitted the x-api-key partner key, the full request body (partner_key,
prime, card_key, card_token, and the entire cardholder block), and the raw
response body including card_secret and card_info. Any application running at
DEBUG was writing payment credentials and personal data to its logs. These are
now redacted before reaching the logger, matched case-insensitively at any depth,
and the log records are built lazily so nothing is computed when debug is off.

Requests now carry a timeout. There was none, so a stalled TapPay endpoint
could block the calling thread indefinitely and exhaust a web application's
worker pool. Defaults to (3.05, 27.0) seconds, overridable per client and per
call.

Retries are deliberately selective. Only the read-only query endpoints retry.
Payment, refund, capture, bind and remove endpoints never do: TapPay exposes no
idempotency key, so retrying a request that actually succeeded upstream would
charge the cardholder twice. A test asserts max_retries.total == 0 across every
write path so the guarantee cannot silently regress.

Business failures can now surface as exceptions. TapPay reports declines with
an HTTP 200 and a non-zero status, invisible to HTTP-level error handling.
raise_on_error=True raises TapPayError; it defaults to False so existing
callers are unaffected.

Connection pooling. A per-client requests.Session replaces module-level
requests.post, so repeated calls reuse an established TLS connection rather
than renegotiating one each time.

Also: py.typed (PEP 561), a single-sourced version (__version__ reported
0.5.2 while the package was 0.6.0), currency selection, close() and context
manager support, a dev extra, mypy in CI, and Python 3.13 in the matrix.

Test coverage

73% to 100% (235 statements), 82 tests. ruff and mypy clean.

Breaking changes

  1. CardHolderData requires keyword arguments (0.6.0). Positional
    construction raises TypeError. EmailStr is also stricter than the plain
    str field it replaced. Migration guide is in CHANGELOG.md.
  2. Tests mocking tappay.client.requests.post will no longer intercept
    anything
    (0.7.0). Requests now go through client.session.post; patch that
    instead.
  3. Models.Currencies is now a str enum. Members still compare equal to
    their string form and still serialize as "TWD" through json.dumps, but
    str() and f-strings render them as Currencies.TWD on Python 3.11+, so a
    member must never be interpolated into a request payload. A test pins this.

Review notes

  • CI is split into a quality job (ruff + mypy on one interpreter) and a test
    matrix. Current mypy releases cannot target Python 3.8, and type checking does
    not need to repeat across every interpreter.
  • The Python 3.8 matrix entry is the most likely CI failure here: 3.8 is EOL and
    now actively constrains tooling. If it fails to resolve dependencies, that is
    an argument for raising the floor to 3.9+ rather than working around it.
  • InvalidResponseError messages deliberately omit the response body. Exception
    text routinely reaches logs and error trackers, and an unparseable body cannot
    be redacted by key the way a JSON payload can.

Not included

Async client, response models, the Python 3.8 floor bump, and renaming the
_dict-suffixed get_records parameters.

🤖 Generated with Claude Code

shihweilo and others added 5 commits August 18, 2026 08:55
Replace the hand-rolled CardHolderData class with a Pydantic v2 BaseModel,
gaining automatic validation, EmailStr checking, and model_dump-based
serialization in place of the manual to_dict construction.

Add pydantic>=2.0 and email-validator>=2.0 as dependencies, and an example
script demonstrating the validation behaviour.

BREAKING CHANGE: CardHolderData now requires keyword arguments. Positional
construction, e.g. CardHolderData("0912345678", "Wang", "a@b.com"), raises
TypeError. EmailStr is also stricter than the plain str field it replaces,
so addresses that were previously accepted may now fail validation.
See the migration guide in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Security: debug logging previously emitted the x-api-key partner key, the
full request body (partner_key, prime, card_key, card_token and the entire
cardholder block), and the raw response body including card_secret and
card_info. Any application running at DEBUG level was writing payment
credentials and cardholder PII to its logs. These values are now replaced
with ***REDACTED*** before reaching the logger, matched case-insensitively
at any nesting depth. Log records are built lazily behind isEnabledFor(),
so no redaction or response parsing happens when debug logging is off.

Requests previously had no timeout at all, so a stalled TapPay endpoint
could block the calling thread indefinitely and exhaust a web application's
worker pool. Client now takes a timeout argument defaulting to (3.05, 27.0)
seconds, and every API method accepts a keyword-only timeout override. The
parameter is keyword-only deliberately: **kwargs forwards into the request
body, so a positional timeout would otherwise be posted as an API field.
A sentinel distinguishes an omitted argument from an explicit None, keeping
None's "no timeout" meaning intact at the constructor.

Also:
- Add a py.typed marker (PEP 561) so the type hints this library already
  ships are visible to consumers' type checkers instead of being ignored.
- Source the version from installed distribution metadata. __version__
  reported 0.5.2 while the package was 0.6.0; it is now declared only in
  pyproject.toml, so __version__, client.VERSION and the User-Agent header
  cannot drift apart.
- AuthenticationError was raised as a bare class with no message; it now
  reports "401 response from <host>" like the other error branches.
- Correct return annotations to Optional[Dict[str, Any]]; every method can
  return None on an HTTP 204.
- Remove leftover development scratch notes from client.py.

Test coverage rises from 73% to 92% (client.py 65% to 89%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the module-level requests.post with a per-client requests.Session,
so repeated calls reuse an established TLS connection instead of paying for
a fresh handshake each time. Add close() and context manager support to
release pooled connections.

Mount retries per-URL rather than session-wide. requests resolves adapters
by longest matching prefix, so the read-only query endpoints pick up a
retrying adapter (2 attempts, exponential backoff, 429/500/502/503/504)
while everything else falls back to a non-retrying one. Payment, refund,
capture, bind and remove endpoints are excluded deliberately: TapPay
exposes no idempotency key, so retrying a request that actually succeeded
upstream would charge the cardholder twice. A test asserts this directly
against every write path so the guarantee cannot silently regress.

TapPay reports declines and other business failures with an HTTP 200 and a
non-zero status field, invisible to HTTP-level error handling. Add
raise_on_error=True to raise TapPayError carrying .status, .msg and the
full .response. Defaults to False to preserve existing behaviour.

Add a keyword-only currency argument to pay_by_prime, pay_by_token and
bind_card, still defaulting to TWD. Models.Currencies becomes a str-backed
enum of 15 currencies; members compare equal to their string form and
serialize as "TWD" through json.dumps, and plain strings remain accepted.

Tooling:
- Add a dev optional dependency group, [tool.mypy] config, and a mypy step
  in CI. CI is split into a quality job and a test matrix because current
  mypy releases cannot target Python 3.8.
- Add Python 3.13 to the matrix and classifiers.
- Raise the requests floor to 2.26 and add urllib3>=1.26; both are needed
  for the Retry(allowed_methods=...) API.
- Drop --cov from pytest addopts so a bare pytest works without pytest-cov.
- Correct the README badge from black to ruff.

Test coverage rises from 92% to 100% (223 statements, 73 tests). Tests now
patch client.session.post rather than tappay.client.requests.post.

BREAKING CHANGE: downstream tests that mock tappay.client.requests.post
will no longer intercept anything; patch client.session.post instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An intermediary such as a proxy, load balancer or WAF can answer with an
HTML error page under a 2xx status, and an empty body decodes no better.
Either case previously escaped as a bare json.JSONDecodeError raised from
inside requests, with nothing to indicate which host or call produced it.

Decode defensively and convert the failure into InvalidResponseError,
reporting the status code, host, content type and body length, and keeping
the original decode error as __cause__. It subclasses ServerError so that
existing `except ServerError` handlers continue to catch it.

The failure message deliberately omits the body. Exception text routinely
reaches logs and error trackers, and an unparseable body cannot be redacted
by key the way a JSON payload can, so a truncated preview would reopen the
leak that 0.6.1 closed. A test asserts a malformed body containing a card
token never appears in the message.

Folded into the unreleased 0.7.0 rather than a new version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failures, both caused by verifying with commands that differed from the
ones CI runs.

Test collection failed on every matrix entry with "No module named 'tests'".
`python -m pytest` puts the current directory on sys.path but a bare `pytest`
does not, and the test module imports shared helpers from tests.conftest.
Local runs used the former, CI the latter. Set pythonpath = ["."] in the
pytest configuration so both resolve.

`ruff format --check .` failed on README.md and CHANGELOG.md. Ruff 0.16 began
formatting Python code blocks inside Markdown; the local environment had
0.14.9, which ignores them. Add magic trailing commas to the affected
examples so the formatter keeps them exploded rather than collapsing them
onto one line, and strip the trailing whitespace the originals carried.

Pin ruff to >=0.16,<0.17 in the dev extra. An unpinned formatter means a new
release can break CI with no change to the repository, which is what happened
here.

Also ignore .gemini/, which is tool scratch output that ruff was picking up
locally.

Verified by running the exact CI commands (bare ruff, mypy and pytest) in a
clean virtualenv built from the dev extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shihweilo
shihweilo merged commit 5733ee8 into master Aug 18, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant