Skip to content

fix(local): report a taken host port instead of a raw APIError - #9294

Merged
bnusunny merged 6 commits into
aws:developfrom
bnusunny:fix/local-port-collision-error-mapping
Nov 6, 2026
Merged

bnusunny merged 6 commits into
aws:developfrom
bnusunny:fix/local-port-collision-error-mapping

Conversation

@bnusunny

@bnusunny bnusunny commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Which issue(s) does this change fix?

no linked issue: split out of #9290 during review, where the reviewer showed the handler covered only one of the daemon's three wordings. Related: #2837.

Why is this change necessary?

Container.start translates a port collision into PortAlreadyInUse, but only for one daemon's wording:

except docker.errors.APIError as ex:
    if "Ports are not available" in str(ex):
        raise PortAlreadyInUse(ex.explanation.decode()) from ex
    raise ex

Ports are not available is Docker Desktop's phrasing. A daemon refuses a publish in three different ways depending on which layer says no, and the other two fall through to raise ex. None of the local command handlers list docker.errors.APIError — samcli/commands/local/invoke/cli.py converts PortAlreadyInUse, ContainerNotStartableException and DockerContainerCreationFailedException — so the user gets a traceback instead of a message.

Container.start reaches whichever engine get_validated_container_client returned, so the match has to hold for Docker and Finch. I drove it at a real engine for each case rather than pattern-matching on documentation:

what refuses the publish Docker wording Finch / nerdctl wording before
another container holds the port (port allocator) Bind for ...: port is already allocated bind for ...: port is already allocated raw APIError
an ordinary process holds the port (bind-level) bind: address already in use address already in use (bare) raw APIError
the address does not exist on the engine's host bind: cannot assign requested address cannot assign requested address (bare) raw APIError

The bind: prefix is Docker's userland-proxy formatting; nerdctl surfaces Go's bare errno text and its binary contains no bind: -prefixed form of either message. So the matches are deliberately prefix-free — a prefixed match covers Docker only. Measured against Docker 25.0.16 and Finch 1.17.2 / nerdctl 2.3.5 on Linux; the Docker Desktop wording (Ports are not available) is as reported.

The prefix turned out to matter on Docker as well, not only on Finch: the hands-on runner reproduced the process-held-port case on Docker 29.9.2, where the daemon reports failed to bind host port 127.0.0.1:5891/tcp: address already in use with no bind: prefix at all. A prefixed match misses that too, so matching the errno fragment rather than any one engine's sentence is what makes this hold across both engines and across Docker versions.

The second is the likely shape whenever the daemon is not on the developer's own machine, because there the port's holder is usually not a container.

There is also a latent bug in the same three lines: ex.explanation has been a str since docker-py 7.0, so .decode() would raise AttributeError on the one path the condition did match. No test covered it.

How does it address the issue?

Both port-taken wordings now raise PortAlreadyInUse, listed as named constants beside the class rather than inline literals.

The third case is not a port conflict and is not reported as one — but it should not be a traceback either. It is what a stale or mistyped --container-host-interface produces, so it raises ContainerNotStartableException (already converted by the local commands) with text naming that flag:

The container engine cannot publish the container's port on the requested
interface (listen tcp4 192.168.99.99:3093: bind: cannot assign requested
address). Pass --container-host-interface with an address that exists on
the machine running the container engine.

The engine's detail is parenthesised because it does not end in punctuation, so interpolating it bare ran it into the next sentence.

The message says "container engine" rather than Docker because either engine can be the one refusing the publish; a test asserts it names no single engine.

explanation is read directly, with a fallback to the full message: APIError.__init__ defaults it to None and the response.text path can yield "", so without the fallback the reported reason would be blank. It is never decoded — the pin is docker>=7.1,<7.3, and in that range docker/errors.py builds explanation from response.json()["message"] or (response.text or "").strip(), both str, so the .decode() above was unreachable as well as wrong.

Raising the exception is not enough on the eager warm-container path, so samcli/commands/local/cli_common/invoke_context.py changes too. _initialize_all_functions_containers allowlisted only PortAlreadyInUse and funnelled everything else into ContainersInitializationException("Lambda functions containers initialization failed"), so with --warm-containers EAGER the guidance above survived only in an ERROR log line. Both exceptions now re-raise as themselves. That branch also has to release what it created: it runs from __enter__, so a raise there means __exit__ never runs, and the PortAlreadyInUse branch was leaking every container started before the failure while the branches on either side of it both clean up. This also un-masks the pre-existing NoFreePortsError → ContainerNotStartableException path at container.py:175.

Verified end to end by driving Container.start against the daemon once per case:

container holds port   -> PortAlreadyInUse: driver failed programming external connectivity...
process holds port     -> PortAlreadyInUse: driver failed programming external connectivity...
interface missing      -> ContainerNotStartableException: The container engine cannot publish...

What side effects does this change have?

Mandatory Checklist

  • Review the generative AI contribution guidelines
  • Add input/output type hints to new functions/methods
  • Write design document if needed (Do I need to write a design document?) — not needed, no new interface
  • Write/update unit tests
  • Write/update integration tests — the failure needs a host port held by a second process or container, which the integration suite has no harness for; covered by unit tests plus the live runs above
  • Write/update functional tests if needed
  • make pr passes
  • make update-reproducible-reqs if dependencies were changed — no dependency changes
  • Write documentation

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 94ee3f3..4649fe9
Files: 2
Comments: 2

Comment thread samcli/local/docker/container.py Outdated
PORT_TAKEN_WORDINGS = (
"Ports are not available", # Docker Desktop
"port is already allocated", # Linux daemon, port held by another container
"bind: address already in use", # Linux daemon, port held by a non-Docker process

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GENERAL] The three wordings are Docker-only, but Container.start can be talking to Finch. self.docker_client resolves through utils.get_validated_container_client(), which can return FinchContainerClient (samcli/local/docker/container_client.py:477), and nerdctl does not phrase a publish failure the way moby's port allocator or the userland proxy does. On that backend a taken host port still escapes as a raw APIError — the exact failure this PR removes for Docker.

The codebase already treats backend wording differences as a real concern rather than an edge case: is_dockerfile_error is implemented separately on DockerContainerClient ("Cannot locate specified Dockerfile") and FinchContainerClient ("no such file or directory") for the same reason.

The cheapest fix is to drop the layer-specific prefix so the match is not tied to the userland proxy's phrasing:

PORT_TAKEN_WORDINGS = (
    "Ports are not available",  # Docker Desktop
    "port is already allocated",  # Linux daemon, port held by another container
    "address already in use",  # bind refused: port held by a non-Docker process
)

"address already in use" is the OS-level text every backend surfaces, and a container-start APIError carrying it is a publish conflict in practice, so widening it does not realistically misclassify anything. The same applies to HOST_INTERFACE_MISSING_WORDING and to the message it produces, which names Docker explicitly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good instinct — backend wording drift is real, and is_dockerfile_error is the right precedent to cite. But I measured this against Finch rather than reasoning about it, and the conclusion doesn't hold: Finch never reaches this handler.

I ran the Docker-compatible socket FinchContainerClient actually connects to (unix:///var/run/finch.sock, platform_config.py:130) with Finch 1.17.2 / nerdctl v2.3.5 / containerd 2.2.7, doing create then start with a published port, once per case:

case Docker Finch
ordinary process holds the port start(): bind: address already in use create(): bind for 0.0.0.0:3101 failed: port is already allocated
another container holds the port start(): port is already allocated create(): bind for 0.0.0.0:3102 failed: port is already allocated
address absent on the host start(): bind: cannot assign requested address no error — the publish is accepted, exit 0

Three things follow:

  1. Finch fails at create, not start. nerdctl reserves host ports while parsing the port mapping, so the APIError is raised out of Container.create() and this handler is never entered. Widening its wordings cannot affect Finch either way.
  2. Its wording is port is already allocated — already matched here. So even on the start path there would be nothing to add.
  3. The third case has no Finch analogue. Finch happily publishes on an address that doesn't exist on the host, so there is no error to translate.

On the specific suggestion: dropping the prefix to "address already in use" would widen the match without buying any Finch coverage, because Finch never emits that string. I'd rather keep bind: address already in use, which is the exact text I observed, than loosen a substring match on speculation. Happy to revisit if someone sees a real backend emitting the bare form.

What the measurement did turn up is a genuine defect, just not in this handler. Because Finch raises at create, a taken port on Finch today produces:

Container creation failed: failed to parse port mapping "0.0.0.0:3001:80/tcp":
bind for 0.0.0.0:3001 failed: port is already allocated, check template for
potential issue

That's Container.create's blanket DockerAPIError → DockerContainerCreationFailedException (container.py:305-308). It's a UserException, so no traceback — but it tells the user to check their template for what is a port conflict, and it's the one place PortAlreadyInUse should be raised for Finch users. I'll open that separately rather than grow this PR; it needs the same create-path treatment for both backends, and the taxonomy question there is worth its own review.

Comment thread samcli/local/docker/container.py Resolved
@github-actions github-actions Bot added area/local/start-api sam local start-api command area/local/invoke sam local invoke command area/local/start-invoke labels Sep 28, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 94ee3f3..e0cf14e
Files: 4
Comments: 1

Comment thread samcli/local/docker/container.py Resolved
Container.start recognised one daemon's wording for a port collision and let the
others through as a bare docker.errors.APIError. None of the local command
handlers convert that type, so the user gets a traceback instead of a message.

A daemon refuses a publish in three different ways, depending on which layer
says no. Verified against Docker 25 on Linux by driving Container.start at a
port held by another container, a port held by an ordinary process, and an
address that does not exist on the daemon host:

  another container holds the port  port is already allocated
  a local process holds the port    bind: address already in use
  the interface does not exist      bind: cannot assign requested address

Only the first was matched, alongside Docker Desktop's "Ports are not
available". The second now maps to PortAlreadyInUse as well.

The third is not a port conflict and must not be reported as one, but it should
not be a traceback either: it is what a stale or mistyped
--container-host-interface produces. It now raises
ContainerNotStartableException, which the local commands already convert, with
text naming that flag.

Also fixes the explanation decode in the same handler. docker-py has handed
back a str since 7.0, so the existing .decode() would itself have raised
AttributeError on the one path it did match; bytes are still tolerated for older
versions.
_initialize_all_functions_containers allowlists PortAlreadyInUse and funnels
everything else into ContainersInitializationException("Lambda functions
containers initialization failed"). The ContainerNotStartableException added in
this PR was hitting that wrapper, so with --warm-containers EAGER and a stale
--container-host-interface the user got the generic message and the guidance
survived only in an ERROR log line. The same masking applied to the pre-existing
NoFreePortsError -> ContainerNotStartableException path in container.py.

Both exceptions name the flag the user has to change, so both are now re-raised
as themselves; all three local commands already convert them.

The allowlisted branch also has to clean up. It runs from __enter__, so a raise
there means __exit__ never runs, and the existing PortAlreadyInUse branch was
leaking every container created before the failure -- the two branches around it
both call _clean_running_containers_and_related_resources.
Container.start reaches whichever engine get_validated_container_client
returned, which is Docker or Finch, so matching on one engine's whole
sentence misses the other.

Docker's userland proxy prefixes the bind errno with "bind: "; nerdctl
surfaces the bare text and the nerdctl binary carries no "bind: "-prefixed
form of either message. So "bind: address already in use" never matched a
Finch bind-level refusal, and neither did "bind: cannot assign requested
address" - both escaped as a raw docker.errors.APIError, which is the
failure this handler exists to remove. Both matches are now prefix-free.

"port is already allocated" already covered Finch: it reports
"bind for 127.0.0.1:38199 failed: port is already allocated" (lowercase
"bind for", against Docker's "Bind for"), which the fragment match catches.
Measured against Finch 1.17.2 / nerdctl 2.3.5 and Docker 25.0.16.

The ContainerNotStartableException message no longer says "Docker host" or
"Docker daemon", since either engine can be the one refusing the publish,
and a test asserts the message names no single engine.
@bnusunny
bnusunny force-pushed the fix/local-port-collision-error-mapping branch from e0cf14e to 9fc415e Compare November 3, 2026 15:13
@aws-sam-tooling-bot

aws-sam-tooling-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🧪 Hands-on test: No problems observed in 7 scenarios

Commit 9dc0693 · build LambdaToolingHandsOn-Docker:7f2f406b-2857-4588-934a-634c80ccdaca. The plan's scenarios ran on an isolated build host; a problem is reported only if it reproduced at least twice. Passing scenarios show only that these checks behaved as expected, not that the change is correct.

Scenarios

Scenario Planned check Result Runner's note
S1 Baseline: an ordinary invoke with no port conflict still works, which also builds the rapid image used by later scenari… ✅ pass Head: samdev local invoke HelloFunction exited 0 and printed {"ok": true}; the rapid image was built. SAM_CLI_DEV=1 installs the CLI entry point as 'samdev', not 'sam', so all scenarios use samdev fr…
S2 Main fix: the debug host port is held by an ordinary process (bind-level refusal). Before the PR this escaped as a raw … ✅ pass Docker 29.9.2, debug port 5891 held by python http.server. Head: EXIT=1 with a single line 'Error: failed to set up container networking: driver failed programming external connectivity on endpoint .…
S3 Port-allocator case: the debug host port is held by another container, so the daemon's port allocator refuses it ('port… ✅ pass Port 5892 held by an alpine container. Daemon wording: 'Bind for 127.0.0.1:5892 failed: port is already allocated'. Head: EXIT=1 with a clean 'Error: ... Bind for 127.0.0.1:5892 failed: port is alrea…
S4 Eager warm-container path (invoke_context._initialize_all_functions_containers, topic of dismissed finding lt-58aacadce… ✅ pass start-lambda --warm-containers EAGER -d 5893 with 5893 held by http.server. Head: EXIT=1 (not 124) with 'Error: ... failed to bind host port 127.0.0.1:5893/tcp: address already in use', no generic wr…
S5 New ContainerNotStartableException path: drive Container.create/start against the real daemon with a host interface tha… ✅ pass Container.create/start driven against the real daemon with container_host_interface=192.0.2.77 (find_free_port patched). Daemon wording: 'failed to bind host port 192.0.2.77:5894/tcp: cannot assign r…
S6 Edge case: a mistyped --container-host-interface with a local daemon. find_free_port in Container.__init__ binds locall… ✅ pass Head: samdev local invoke --container-host-interface 192.0.2.77 with a local daemon exited 1 with 'Error: No free ports on the host machine from 5000 to 9000'. As the plan expected, find_free_port in…
S7 The PR's own unit tests for the new wordings, the explanation fallback and the eager-path pass-through all pass, alongs… ✅ pass Head: tests/unit/local/docker/test_container.py and tests/unit/commands/local/cli_common/test_invoke_context.py: 161 passed. A -k run selecting every_engine_wording, host_interface_does_not_exist and…
Runner's notes in full
  • S1: Head: samdev local invoke HelloFunction exited 0 and printed {"ok": true}; the rapid image was built. SAM_CLI_DEV=1 installs the CLI entry point as 'samdev', not 'sam', so all scenarios use samdev from /work/scratch/venv-head or venv-base.
  • S2: Docker 29.9.2, debug port 5891 held by python http.server. Head: EXIT=1 with a single line 'Error: failed to set up container networking: driver failed programming external connectivity on endpoint ...: failed to bind host port 127.0.0.1:5891/tcp: address already in use'. grep found no Traceback or APIError. Base: EXIT=1 with a full Python traceback ending in docker.errors.APIError and the 'An unexpected error was encountered' bug-report prompt.
  • S3: Port 5892 held by an alpine container. Daemon wording: 'Bind for 127.0.0.1:5892 failed: port is already allocated'. Head: EXIT=1 with a clean 'Error: ... Bind for 127.0.0.1:5892 failed: port is already allocated' line and no traceback (the log's exit_code 1 comes from grep -c finding 0 Traceback/APIError matches). Base: EXIT=1 with a traceback and an 'unexpected error ... APIError' report.
  • S4: start-lambda --warm-containers EAGER -d 5893 with 5893 held by http.server. Head: EXIT=1 (not 124) with 'Error: ... failed to bind host port 127.0.0.1:5893/tcp: address already in use', no generic wrapper, LEFTOVER=0 lambda containers (BEFORE=0). Base: EXIT=1 with 'Error: Lambda functions containers initialization failed'; the port detail appears only in a preceding log line. Base also LEFTOVER=0.
  • S5: Container.create/start driven against the real daemon with container_host_interface=192.0.2.77 (find_free_port patched). Daemon wording: 'failed to bind host port 192.0.2.77:5894/tcp: cannot assign requested address'. Head: 'RESULT ContainerNotStartableException | The container engine cannot publish the container's port on the requested interface (). Pass --container-host-interface with an address that exists on the machine running the container engine.' The message is well formed. Base: 'RESULT APIError | 500 Server Error ... cannot assign requested address'.
  • S6: Head: samdev local invoke --container-host-interface 192.0.2.77 with a local daemon exited 1 with 'Error: No free ports on the host machine from 5000 to 9000'. As the plan expected, find_free_port in Container.__init__ fails first, so the new --container-host-interface message is not reached on a local daemon. This is pre-existing behavior, not a regression.
  • S7: Head: tests/unit/local/docker/test_container.py and tests/unit/commands/local/cli_common/test_invoke_context.py: 161 passed. A -k run selecting every_engine_wording, host_interface_does_not_exist and actionable_exceptions_are_not_wrapped: 9 passed.

🤖 Automated hands-on test — not a review approval · model claude-opus-5.5 · Kiro kiro-cli 2.33.1 · build LambdaToolingHandsOn-Docker:7f2f406b-2857-4588-934a-634c80ccdaca · commit 9dc0693

History (4)
Commit Result When (UTC) Build
9dc0693 No problems observed 2026-11-06 20:03 LambdaToolingHandsOn-Docker:7f2f406b-2857-4588-934a-634c80ccdaca
99bfb07 No problems observed 2026-11-05 22:34 LambdaToolingHandsOn-Docker:65a2e417-5091-4683-a34f-980dc1792447
c142786 No problems observed 2026-11-03 15:47 LambdaToolingHandsOn-Docker:e3d7b84b-beed-41ba-b521-9c6bdf6099c3
9fc415e No problems observed 2026-11-03 15:24 LambdaToolingHandsOn-Docker:755b3325-29c9-4f09-b73f-60feaef76167

The interpolated detail does not end in punctuation, so the message read
"...cannot assign requested address Pass --container-host-interface with...".
Parenthesising the detail fixes it without having to normalise punctuation
that comes from the engine. Flagged by the hands-on runner on 9fc415e.
@bnusunny

bnusunny commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — this run is more useful than its green verdict suggests, because S2 independently shows the prefix-free match matters on Docker too, not only on Finch.

That scenario held the port with an ordinary host process on Docker 29.9.2, and the daemon said:

failed to bind host port 127.0.0.1:5891/tcp: address already in use

No bind: prefix. I had measured this case on Docker 25.0.16, where the userland proxy reports Error starting userland proxy: listen tcp4 127.0.0.1:5891: bind: address already in use, and I wrote the match around that string. So the original bind: address already in use would have missed this on current Docker as well — S2 would have stayed a raw APIError traceback. The widening was raised as a Finch concern; it turns out to have been a Docker 29 bug at the same time, which is a good argument for matching the errno fragment rather than any engine's sentence.

Also fixed the cosmetic defect from S6, in c142786a4: the engine's detail does not end in punctuation, so the message read ...cannot assign requested address Pass --container-host-interface.... The detail is now parenthesised — (<detail>). Pass --container-host-interface ... — which avoids normalising punctuation that comes from the engine, with a test pinning the join.

One limitation of the run worth stating for the record, since it is the thing this finding was about: per S1's note there is no Finch engine on the build host, so the Finch wordings are covered by unit-test strings taken from a live Finch measurement (Finch 1.17.2 / nerdctl 2.3.5, driven through /var/run/finch.sock) rather than by an end-to-end Finch run here. The Docker paths are end-to-end.

vicheey
vicheey previously approved these changes Nov 5, 2026

@reedham-aws reedham-aws left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you explain how this and #9290 are related, but it really doesn't make a whole lot of sense to me. I also am wondering if this closes #9295, since we're now catching PortAlreadyInUse with the specific wording.

Comment thread samcli/local/docker/container.py Outdated
Comment on lines +492 to +494
# The engine's own detail is parenthesised: it does not end in punctuation,
# so interpolating it bare ran it into the next sentence ("...requested
# address Pass --container-host-interface...").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment does not make sense to me, could you either revise it or explain to me?

@bnusunny bnusunny Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and thanks — that comment was explaining how the message used to be wrong rather than what the code is doing, which is no use to a reader who never saw the bug. Revised in 99bfb072c:

# detail is the engine's own text and has no trailing period, so it is wrapped
# in parentheses to keep it from running into the sentence that follows.

For the record, the thing it is guarding against: detail comes from the engine, e.g. failed to bind host port 192.0.2.1:6706/tcp: cannot assign requested address, with no trailing punctuation. Interpolating it bare produced ...requested address Pass --container-host-interface with... in one run-on sentence. Parenthesising it avoids having to normalise punctuation that is not ours.

The comment explained how the message used to be wrong rather than what a
reader needs to know about it now. Reviewer could not follow it.
@bnusunny

bnusunny commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

@reedham-aws — both fair, and the second one is a good question with a non-obvious answer.

Does this close #9295?

No. Your reasoning is right as far as the wording goes — port is already allocated is in PORT_TAKEN_WORDINGS now. What does not line up is the call site.

That list is only consulted in Container.start() (container.py:487). On Finch the port conflict never reaches start(): Finch reserves host ports while parsing the port mapping, so it fails out of Container.create() instead, and that path still has its own blanket handler (container.py:318-321):

Container creation failed: ... port is already allocated, check template for potential issue

So a Finch user whose host port is taken is still told to check their template for a port conflict. This PR does not touch create() at all — its container.py change is two hunks, the constants block and start().

Measured, driving each engine's own socket:

port held by Docker 25.0.16 / 29.9.2 Finch 1.17.2 / nerdctl 2.3.5
another container raises from start() raises from create()
an ordinary process raises from start() raises from create()

#9295 is the create() half and needs its own change, which is written and waiting behind these two.

The #9290 relationship

Agreed that was hard to follow — it was three facts in one paragraph. I have rewritten that section of the description as a table. Short version:

what refuses the publish happens on develop today needs #9290
another container holds the port yes no
another process holds the port yes no
the address isn't on the engine's host no yes

The first two rows are why this PR stands on its own. The third row is the only coupling: #9290 stops pre-checking the port locally, which is what makes that case reachable at all.

The practical consequence, and the only thing you need from it at merge time: merge this one before #9290. #9290 alone turns that third case from a clean No free ports on the host machine line into a raw docker.errors.APIError traceback, because the mapping that makes it a clean error is in this PR. The hands-on runner reproduced that on #9290 independently, 2 of 2 runs.

I also revised the code comment you flagged — it was describing a bug I had already fixed rather than the rule, which is why it did not parse.

reedham-aws
reedham-aws previously approved these changes Nov 6, 2026
Comment thread samcli/local/docker/container.py Outdated
Comment on lines +501 to +511
@staticmethod
def _api_error_detail(ex: docker.errors.APIError) -> str:
"""
Returns the daemon's own explanation for an APIError, falling back to the full message.

docker-py hands back a str today and bytes in older versions.
"""
explanation = ex.explanation
if isinstance(explanation, bytes):
explanation = explanation.decode()
return explanation or str(ex)

@reedham-aws reedham-aws Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I didn't comment on this earlier, but I don't see why wee need this method to decode bytes. Either we have the newer version of docker-py or we don't?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right — dropped in 9dc069352, and the helper went with it since it had one call site.

Checked rather than argued: the pin is docker>=7.1,<7.3 (resolving to 7.2.0), and in that range docker/errors.py sets explanation from response.json()["message"] or (response.text or "").strip(), both str on every path. So the bytes branch could not run.

Where it came from, since it was my mistake and not inherited blindly: the line before this PR was

raise PortAlreadyInUse(ex.explanation.decode()) from ex

which assumed the opposite — and would raise AttributeError on a str, on the one wording it did match. I kept that assumption while fixing the crash instead of checking which type the pin actually gives. Your question is what settled it.

What is left is one line:

detail = ex.explanation or message

The or is load-bearing rather than defensive: APIError.__init__ defaults explanation=None, and the response.text path can yield "", so without it the reported reason comes out blank. I swapped the bytes test for one covering that fallback and mutation-checked it — removing the or message turns exactly that test red.

Net −11 lines. make pr green: 9763 passed, 94.08% coverage, ruff/black/mypy clean.

Sorry about the approval — the push dismissed it, since develop has dismiss_stale_reviews on. Only the two files above changed from the revision you read.

…ng bytes

The pin is docker>=7.1,<7.3, and in that range docker/errors.py sets
explanation from response.json()["message"] or (response.text or "").strip(),
both str on every path. So the bytes branch could not run, and the helper that
held it had one call site.

The pre-existing code was ex.explanation.decode(), which assumed the opposite
and would raise AttributeError on a str. This change keeps the fix for that and
drops the speculative half: explanation is read directly, with a fallback to
the full message because APIError.__init__ defaults explanation to None and the
response.text path can yield "".

The test that asserted the bytes path is replaced by one covering the fallback,
which is the branch that can actually be reached.
@bnusunny
bnusunny merged commit 1eee817 into aws:develop Nov 6, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants