Repository: planetlabs/planet-client-python
Pull request #648 (2022-07-22 04:05:03)

Nicer download links
Getting the locations right for nice download.

I'm just using the direct github links, in particular the raw ones, so download actually works. I think this also likely helps with mkdocs builds, since I'm not sure how those work with relative links?

The major downside of this is that the links could change, as these link to 'main', which isn't guaranteed to always have these. I suppose we could link to a specific tag (once we get a new tag / cut a new release). That'd probably make the most sense.


Patch:
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
new file mode 100644
index 0000000..e1db7e9
--- /dev/null
+++ b/.github/workflows/publish-pypi.yml
@@ -0,0 +1,32 @@
+name: Publish to PyPi
+
+on:
+  workflow_dispatch:
+
+jobs:
+  publish:
+    name: Build, verify, & upload package to PyPi
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Set up Python
+        uses: actions/setup-python@v3
+        with:
+          python-version: 3.7
+
+      - name: Pip cache
+        uses: actions/cache@v2
+        with:
+          path: ~/.cache/pip
+          key: ${{ runner.os }}-pip
+          restore-keys: |
+            ${{ runner.os }}-pip
+
+      - name: Build, verify, and upload to TestPyPI
+        run: |
+          pip install --upgrade nox
+          nox -s build publish_pypi
+        env:
+          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..c03ab2b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,51 @@
+name: Release to GitHub and PyPI
+
+on:
+  workflow_dispatch:
+    inputs:
+      prerelease:
+        description: 'Is this a pre-release?'     
+        required: true
+        type: boolean
+
+jobs:
+  package:
+    name: Build, verify, & upload package
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v3
+        with:
+          python-version: "3.10"
+
+      - name: Create GitHub Release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions.
+        with:
+          tag_name: ${{ github.ref }}
+          release_name: ${{ github.ref }}
+          draft: false
+          prerelease: ${{ inputs.logLevel }}
+
+      - run: python -m pip install build twine check-wheel-contents
+      - run: python -m build --sdist --wheel .
+      - run: ls -l dist
+      - run: check-wheel-contents dist/*.whl
+
+      - name: Check long_description
+        run: python -m twine check dist/*
+
+      - name: Upload to TestPyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.2
+        with:
+          repository_url: https://test.pypi.org/legacy/
+          user: __token__
+          password: ${{ secrets.TEST_PYPI_PASSWORD }}
+
+      - name: Upload to PyPI
+        uses: pypa/gh-action-pypi-publish@v1.4.2
+        with:
+          user: __token__
+          password: ${{ secrets.PYPI_PASSWORD }}
\ No newline at end of file
diff --git a/CHANGES.txt b/CHANGES.txt
index 4fe7560..ee87aaf 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,3 +1,13 @@
+2.0a2 (2022-07-04)
+
+- Upgrade httpx to 0.23, change how quota exceptions are caught
+- Increase reliability of communication with Planet servers and avoid clobbering them
+- Add CLI data stats
+- CLI search-quick -> search, API quick-search -> search
+- limit option: remove None as a possibility, value of zero means no limit
+- fix sort param field
+- add sort option to planet data search-quick
+
 2.0a1 (2022-06-28)
 
 This is the first pre-release of the new features and interfaces coming in
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5eeacfe..5668272 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,21 +3,83 @@
 Thank you for your interest in contributing to the Planet SDK for Python! This
 document explains how to contribute successfully.
 
-## Pull Requests and Continuous Integration
+## Workflows
 
-When a Pull Request (PR) is created, the Continuous Integration (CI) runs a 
-series of checks. Before a PR can be considered, all checks must pass.
+### Reporting Bugs
+
+To report a bug, open a new issue with the 'bug' label.
+
+In the issue, provide the following:
+1. Code snippet reproducing the behavior, reduced to the simplest possible case
+1. Operating System and Python version
+1. Installed dependencies / versions
+1. Error traceback
+
+### Pull Requests
+
+Pull Request (PR) Requirements:
+
+1. Must have a descriptive title. This populates the release changelog.
+1. Must include updates to relative documentation in docstrings and `docs` folder. See [Documentation](#documentation) section for information on docstring formatting and building.
+1. Must pass all Continuous Integration (CI) checks. See below for more information on CI checks.
+1. Must have at least one approval by a planet maintainer.
+  * For Planet team, **FYI** can be used to specify cases when all that is needed is indication that changes have been noted.
+1. Should be driven by an issue. Reference the issue in the PR.
+1. Should be as small and focused as possible.
 
 The CI checks:
+
 * all tests on all supported versions of Python
+* test coverage
 * linting / formatting
+* type annotation
 * docs build successfully
 
-To minimize the feedback loop, we have configured Nox so that it can be used
-to run all of these checks on the local machine. See the Tools section for
-information on how to run Nox.
+To minimize the feedback loop, we have configured Nox so that it can be used to run all of the CI checks on the local machine. See the [Development Tools](#development-tools) section for information on running CI checks locally with Nox.
+
+### Releasing
+
+*Planet maintainers only*
+
+Releasing is a two-step process: (1) releasing on Github and test.pypi and (2) releasing to pypi. Releasing on Github will automatically trigger a release on test.pypi via a Github Action. Following manual confirmation of a successful and satisfactory release on test.py, release on pypi is triggered manually with the Github Action "Automatically Publish on TestPyPi".
+
+###### Step 1: Release on Github
+
+1. Create a PR with the following:
+  * Update `CHANGES.txt` (**PROPOSAL**: change this to `docs/CHANGELOG.md`)
+    * Include added, changed, depricated or removed features and bug fixes.
+       A list of merged PRs and their titles since the last release can be obtained with `git log <RELEASE_TAG>..HEAD | awk '/Merge pull request/{print;getline;getline;print}`
+    * Sort according to importance
+    * **PROPOSAL**: Adhere to [Keep a Changelog](https://keepachangelog.com/)
+  * Bump version in `planet/__version__.py`
+    * **PROPOSAL**: Version number is determined by [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
+1. Merge the PR
+1. Create a new github release:
+  * Set Tag to the version number specified in `planet/__version__.py`
+  * Copy Description from the new entry in `docs/CHANGELOG.md`
+  * Select "This is a pre-release" if applicable
+  * Select "Create a discussion for this release"
+
+###### Step 2: Release on pypi
 
-## Tools
+1. Verify the test release on [test.pypi.org](https://test.pypi.org/project/planet/)
+1. Run the Github Action "Publish on PyPi"
+
+###### Local publishing
+
+Publishing to testpypi and pypi can also be performed locally with:
+
+```console
+  $ nox -s build publish-testpypi
+```
+then
+```console
+  $ nox -s publish-pypi
+```
+this approach requires specifying the pypi/testpypi api token as the password at the prompt.
+
+
+## <a name="development-tools"></a>Development Tools
 
 This repository uses two primary tools for development:
 * [Nox](https://nox.thea.codes/) to automate testing
@@ -56,6 +118,18 @@ speed up the run by reusing the environment:
     $ nox -r
 ```
 
+If only one test is desired, specify it with `-s`. To only run linting:
+
+```console
+$ nox -s lint
+```
+
+To determine which tests are available:
+
+```console
+    $ nox --list
+```
+
 The configuration for Nox is given in `noxfile.py`. See the Nox link above for
 advanced usage.
 
@@ -94,6 +168,17 @@ To reformat the file:
     $ yapf --in-place [file]
 ```
 
+These commands can be performed on the entire repository, when run from the repository root directory, with:
+
+```console
+    $ yapf --diff -r .
+```
+
+and
+
+```console
+    $ yapf --diff -r .
+```
 The configuration for YAPF is given in `setup.cfg` and `.yapfignore`.
 See the YAPF link above for advanced usage.
 
@@ -131,6 +216,20 @@ To run tests on python 3.7:
     $ nox -s test-3.7
 ```
 
+Configuration can be passed onto pytest through Nox.
+
+To only run tests in a specific file:
+
+```console
+    $ nox -s test3.7 -- tests/unit/test_http.py
+```
+
+Or to only run tests filtered by keyword:
+
+```console
+   $ nox -s test3.7 -- -k test__Limiter
+```
+
 ## Code coverage
 
 To measure code coverage and see a report:
@@ -161,7 +260,7 @@ type-related bugs. The nox session that calls mypy is named analyze.
     $ nox -s analyze
 ```
 
-## Documentation
+## <a name="documentation"></a> Documentation
 
 Documentation is built from Markdown files in the `docs` directory using
 [MkDocs](https://www.mkdocs.org/) according to `mkdocs.yml`. The API reference
@@ -185,6 +284,8 @@ To build and host an automatically-updated local version of the documentation:
     $ nox -s watch
 ```
 
+Copy the local url from the console output and paste it into your browser to view the live rendered docs.
+
 In addition to verifying that the documentation renders correctly locally,
 the accuracy of the code examples must be verified. See Testing Documentation
 below.
diff --git a/docs/cli-tutorial.md b/docs/cli-tutorial.md
index d296fbb..5a4fcb7 100644
--- a/docs/cli-tutorial.md
+++ b/docs/cli-tutorial.md
@@ -334,8 +334,10 @@ of at least one being corrupted in creases
 Now we'll dive into the variety of ways to customize your order. These can all be
 combined with all the commands listed above.
 
-We'll work with a geojson saved online. You should download the 
-[geometry](data/geometry.geojson). 
+We'll work with a geojson that is already saved. You should download the 
+[geometry](https://raw.githubusercontent.com/planetlabs/planet-client-python/main/docs/data/geometry.geojson)
+(and you can see it [on github](https://github.com/planetlabs/planet-client-python/blob/main/docs/data/geometry.geojson)
+or it is also stored in the repo in the [data/](data/) directory. 
 
 You can move that geometry to your current directory and use the following command, or
 tweak the geometry.geojson to refer to where you downloaded it.
@@ -407,8 +409,8 @@ planet orders request --item-type PSScene --name "Two Scenes Composited" \
 #### Clip & Composite
 
 To clip and composite you need to specify the clip in the tools (instead of `--clip`), as you can
-not use `--clip` and `--tools` in the same call. You can see the full JSON for the tools in 
-[here](data/tools-clip-composite.json).
+not use `--clip` and `--tools` in the same call. There is not yet CLI calls to generate the `tools.json`,
+so you can just grab the [full tools.json](https://raw.githubusercontent.com/planetlabs/planet-client-python/main/docs/data/tools-clip-composite.json)
 
 ```console
 planet orders request --item-type PSScene --name "Two Scenes Clipped and Composited" \
@@ -418,8 +420,8 @@ planet orders request --item-type PSScene --name "Two Scenes Clipped and Composi
 One cool little trick is that you can even stream in the JSON directly with `curl`, piping it into the request:
 
 ```console
-curl -s https://gist.githubusercontent.com/cholmes/378d050a263ae433ddbbb91c3439994b/raw/ebcaa54cacdc6f696a0506705785f8ff8dae9af1/ \
-tools-clip-composite.json | planet orders request --item-type PSScene --name "Streaming Clip & Composite" \
+curl -s https://raw.githubusercontent.com/planetlabs/planet-client-python/main/docs/data/tools-clip-composite.json \
+| planet orders request --item-type PSScene --name "Streaming Clip & Composite" \
  --bundle analytic_sr_udm2 --id 20220605_124027_64_242b,20220605_124025_34_242b --tools - | planet orders create
 ```
 
diff --git a/docs/guide.md b/docs/guide.md
index 7a1f57a..f825ceb 100644
--- a/docs/guide.md
+++ b/docs/guide.md
@@ -329,7 +329,7 @@ the context of a `Session` with the `DataClient`:
 >>> async def main():
 ...     async with Session() as sess:
 ...         cl = DataClient(sess)
-...         items = await cl.search(sfilter)
+...         items = await cl.search(['PSScene'], sfilter)
 ...
 >>> asyncio.run(main())
 ```
diff --git a/noxfile.py b/noxfile.py
index 6158e7b..1339ac5 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,3 +1,6 @@
+from pathlib import Path
+import shutil
+
 import nox
 
 nox.options.stop_on_first_error = True
@@ -7,6 +10,8 @@ nox.options.sessions = ['lint', 'analyze', 'test', 'coverage', 'docs']
 
 source_files = ("planet", "examples", "tests", "setup.py", "noxfile.py")
 
+BUILD_DIRS = ['build', 'dist']
+
 
 @nox.session
 def analyze(session):
@@ -77,6 +82,7 @@ def docs(session):
 
 @nox.session
 def watch(session):
+    '''Build and serve live docs for editing'''
     session.install("-e", ".[docs]")
 
     session.run("mkdocs", "serve")
@@ -91,3 +97,57 @@ def examples(session):
     # Because these example scripts can be long-running, output the
     # example's stdout so we know what's happening
     session.run('pytest', '--no-cov', 'examples/', '-s', *options)
+
+
+@nox.session
+def build(session):
+    '''Build package'''
+    # check preexisting
+    exist_but_should_not = [p for p in BUILD_DIRS if Path(p).is_dir()]
+    if exist_but_should_not:
+        session.error(
+            f"Pre-existing {', '.join(exist_but_should_not)}. Run clean session and try again"
+        )
+
+    session.install('build', 'twine', 'check-wheel-contents')
+
+    session.run(*'python -m build --sdist --wheel'.split())
+    session.run('check-wheel-contents', 'dist')
+
+
+@nox.session
+def clean(session):
+    '''Remove build directories'''
+    to_remove = [Path(d) for d in BUILD_DIRS if Path(d).is_dir()]
+    for p in to_remove:
+        shutil.rmtree(p)
+
+
+@nox.session
+def publish_testpypi(session):
+    '''Publish to TestPyPi using API token'''
+    _publish(session, 'testpypi')
+
+
+@nox.session
+def publish_pypi(session):
+    '''Publish to PyPi using API token'''
+    _publish(session, 'pypi')
+
+
+def _publish(session, repository):
+    missing = [p for p in BUILD_DIRS if not Path(p).is_dir()]
+    if missing:
+        session.error(
+            f"Missing one or more build directories: {', '.join(missing)}. Run build session and try again"
+        )
+
+    session.install('twine')
+
+    files = [str(f) for f in Path('dist').iterdir()]
+    session.run("twine", "check", *files)
+    session.run("twine",
+                "upload",
+                f"--repository={repository}",
+                '-u=__token__',
+                *files)
diff --git a/planet/__version__.py b/planet/__version__.py
index 5df003e..c76ca41 100644
--- a/planet/__version__.py
+++ b/planet/__version__.py
@@ -1,1 +1,1 @@
-__version__ = '2.0a1'
+__version__ = '2.0a2'
diff --git a/planet/cli/cli.py b/planet/cli/cli.py
index 41cf057..d75eb33 100644
--- a/planet/cli/cli.py
+++ b/planet/cli/cli.py
@@ -27,20 +27,17 @@ LOGGER = logging.getLogger(__name__)
 
 @click.group()
 @click.pass_context
-@click.option('--verbosity',
-              default="warning",
-              help=("Optional: set verbosity level to warning, info, or debug.\
-                  Defaults to warning."))
 @click.option('--quiet',
               is_flag=True,
               default=False,
               help='Disable ANSI control output.')
 @click.version_option(version=planet.__version__)
+@click.option('--verbosity',
+              default="warning",
+              help=("Optional: set verbosity level to warning, info, or debug.\
+                  Defaults to warning."))
 def main(ctx, verbosity, quiet):
-    """Planet API Client
-    Parameters:
-        ctx -- context object
-        verbosity -- user input for verbosity."""
+    """Planet SDK for Python CLI"""
     _configure_logging(verbosity)
 
     # ensure that ctx.obj exists and is a dict (in case `cli()` is called
diff --git a/planet/cli/data.py b/planet/cli/data.py
index 5f13850..1abdcde 100644
--- a/planet/cli/data.py
+++ b/planet/cli/data.py
@@ -19,6 +19,7 @@ import click
 
 from planet import data_filter, DataClient
 from planet.clients.data import SEARCH_SORT, SEARCH_SORT_DEFAULT, STATS_INTERVAL
+from planet.specs import get_item_types
 
 from . import types
 from .cmds import coro, translate_exceptions
@@ -26,6 +27,9 @@ from .io import echo_json
 from .options import limit, pretty
 from .session import CliSession
 
+ALL_ITEM_TYPES = get_item_types()
+valid_item_string = "Valid entries for ITEM_TYPES: " + "|".join(ALL_ITEM_TYPES)
+
 
 @asynccontextmanager
 async def data_client(ctx):
@@ -43,7 +47,7 @@ async def data_client(ctx):
               default=None,
               help='Assign custom base Orders API URL.')
 def data(ctx, base_url):
-    '''Commands for interacting with the Orders API'''
+    '''Commands for interacting with the Data API'''
     ctx.obj['AUTH'] = None
     ctx.obj['BASE_URL'] = base_url
 
@@ -58,6 +62,17 @@ def assets_to_filter(ctx, param, assets: List[str]) -> Optional[dict]:
     return data_filter.asset_filter(assets) if assets else None
 
 
+def check_item_types(ctx, param, item_types) -> Optional[List[dict]]:
+    # Set difference between given item types and all item types
+    set_diff = set([item.lower() for item in item_types]) - set(
+        [a.lower() for a in ALL_ITEM_TYPES])
+    if set_diff:
+        raise click.BadParameter(
+            f'{item_types} should be one of {ALL_ITEM_TYPES}')
+    else:
+        return item_types
+
+
 def date_range_to_filter(ctx, param, values) -> Optional[List[dict]]:
 
     def _func(obj):
@@ -226,11 +241,13 @@ def filter(ctx,
         echo_json(filt, pretty)
 
 
-@data.command()
+@data.command(epilog=valid_item_string)
 @click.pass_context
 @translate_exceptions
 @coro
-@click.argument("item_types", type=types.CommaSeparatedString())
+@click.argument("item_types",
+                type=types.CommaSeparatedString(),
+                callback=check_item_types)
 @click.argument("filter", type=types.JSON(), default="-", required=False)
 @limit
 @click.option('--name', type=str, help='Name of the saved search.')
@@ -264,12 +281,14 @@ async def search(ctx, item_types, filter, limit, name, sort, pretty):
             echo_json(item, pretty)
 
 
-@data.command()
+@data.command(epilog=valid_item_string)
 @click.pass_context
 @translate_exceptions
 @coro
 @click.argument('name')
-@click.argument("item_types", type=types.CommaSeparatedString())
+@click.argument("item_types",
+                type=types.CommaSeparatedString(),
+                callback=check_item_types)
 @click.argument("filter", type=types.JSON(), default="-", required=False)
 @click.option('--daily-email',
               is_flag=True,
@@ -296,11 +315,13 @@ async def search_create(ctx, name, item_types, filter, daily_email, pretty):
         echo_json(items, pretty)
 
 
-@data.command()
+@data.command(epilog=valid_item_string)
 @click.pass_context
 @translate_exceptions
 @coro
-@click.argument("item_types", type=types.CommaSeparatedString())
+@click.argument("item_types",
+                type=types.CommaSeparatedString(),
+                callback=check_item_types)
 @click.argument('interval', type=click.Choice(STATS_INTERVAL))
 @click.argument("filter", type=types.JSON(), default="-", required=False)
 async def stats(ctx, item_types, interval, filter):
diff --git a/planet/cli/orders.py b/planet/cli/orders.py
index 12b642e..73138d5 100644
--- a/planet/cli/orders.py
+++ b/planet/cli/orders.py
@@ -263,6 +263,12 @@ async def create(ctx, request: str, pretty):
     type=types.JSON(),
     help="""Credentials for cloud storage provider to enable cloud delivery of
     data. Can be a json string, filename, or '-' for stdin.""")
+@click.option(
+    '--stac/--no-stac',
+    default=True,
+    is_flag=True,
+    help="""Do not request metadata to be in SpatioTemporal Asset Catalog
+    (STAC) format.""")
 @pretty
 async def request(ctx,
                   name,
@@ -273,6 +279,7 @@ async def request(ctx,
                   item_type,
                   email,
                   cloudconfig,
+                  stac,
                   pretty):
     """Generate an order request.
 
@@ -305,10 +312,16 @@ async def request(ctx,
     else:
         delivery = None
 
+    if stac:
+        stac_json = {'stac': {}}
+    else:
+        stac_json = {}
+
     request = planet.order_request.build_request(name,
                                                  products=[product],
                                                  delivery=delivery,
                                                  notifications=notifications,
-                                                 tools=tools)
+                                                 tools=tools,
+                                                 stac=stac_json)
 
     echo_json(request, pretty)
diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py
index 3f465a0..95b7f9e 100644
--- a/planet/cli/subscriptions.py
+++ b/planet/cli/subscriptions.py
@@ -13,6 +13,7 @@ from planet.clients.subscriptions import SubscriptionsClient
 @click.group()
 @click.pass_context
 def subscriptions(ctx):
+    '''Commands for interacting with the Subscriptions API'''
     # None means that order of precedence is 1) environment variable,
     # 2) secret file.
     ctx.obj['AUTH'] = None
diff --git a/planet/exceptions.py b/planet/exceptions.py
index 7fcf32f..b35f0aa 100644
--- a/planet/exceptions.py
+++ b/planet/exceptions.py
@@ -63,6 +63,11 @@ class ServerError(APIError):
     pass
 
 
+class BadGateway(APIError):
+    '''Bad gateway, HTTP 502'''
+    pass
+
+
 class InvalidIdentity(APIError):
     '''Raised when logging in with invalid credentials'''
     pass
diff --git a/planet/http.py b/planet/http.py
index 90c3cf8..c9b2cf8 100644
--- a/planet/http.py
+++ b/planet/http.py
@@ -15,9 +15,11 @@
 """Functionality to perform HTTP requests"""
 from __future__ import annotations  # https://stackoverflow.com/a/33533514
 import asyncio
+from collections import Counter
 from http import HTTPStatus
 import logging
 import random
+import time
 
 import httpx
 
@@ -25,9 +27,29 @@ from .auth import Auth, AuthType
 from . import exceptions, models
 from .__version__ import __version__
 
+# NOTE: configuration of the session was performed using the data API quick
+# search endpoint. These values can be re-tested, tested with a new endpoint or
+# refined using session_configuration.py in the scripts directory.
+
+# For how this list was determined, see
+# https://github.com/planetlabs/planet-client-python/issues/580
+RETRY_EXCEPTIONS = [
+    httpx.ConnectError,
+    httpx.ReadError,
+    httpx.ReadTimeout,
+    httpx.RemoteProtocolError,
+    exceptions.BadGateway,
+    exceptions.TooManyRequests
+]
 MAX_RETRIES = 5
 MAX_RETRY_BACKOFF = 64  # seconds
 
+# For how these settings were determined, see
+# https://github.com/planetlabs/planet-client-python/issues/580
+READ_TIMEOUT = 30.0
+RATE_LIMIT = 10  # per second
+MAX_ACTIVE = 50
+
 LOGGER = logging.getLogger(__name__)
 
 
@@ -39,42 +61,130 @@ class BaseSession:
 
     @staticmethod
     def _log_request(request):
-        LOGGER.info(f'{request.method} {request.url} - Sent')
+        request_pre = f'{request.method} {request.url}'
+        LOGGER.info(f'{request_pre} - Sent')
+        LOGGER.debug(f'{request_pre} - {request.headers}')
+        request.read()
+        LOGGER.debug(f'{request_pre} - {request.content}')
 
     @staticmethod
     def _log_response(response):
         request = response.request
         LOGGER.info(f'{request.method} {request.url} - '
                     f'Status {response.status_code}')
+        LOGGER.debug(response.headers)
 
     @classmethod
-    def _raise_for_status(cls, response):
-        status = response.status_code
-
-        miminum_bad_request_code = HTTPStatus.MOVED_PERMANENTLY
-        if status < miminum_bad_request_code:
-            return
+    def _convert_and_raise(cls, error):
+        response = error.response
 
-        exception = {
+        error_types = {
             HTTPStatus.BAD_REQUEST: exceptions.BadQuery,
             HTTPStatus.UNAUTHORIZED: exceptions.InvalidAPIKey,
             HTTPStatus.FORBIDDEN: exceptions.NoPermission,
             HTTPStatus.NOT_FOUND: exceptions.MissingResource,
             HTTPStatus.CONFLICT: exceptions.Conflict,
             HTTPStatus.TOO_MANY_REQUESTS: exceptions.TooManyRequests,
-            HTTPStatus.INTERNAL_SERVER_ERROR: exceptions.ServerError
-        }.get(status, exceptions.APIError)
-        LOGGER.debug(f"Exception type: {exception}")
+            HTTPStatus.INTERNAL_SERVER_ERROR: exceptions.ServerError,
+            HTTPStatus.BAD_GATEWAY: exceptions.BadGateway
+        }
+        error_type = error_types.get(response.status_code, exceptions.APIError)
+        raise error_type(response.text)
+
+    @classmethod
+    def _raise_for_status(cls, response):
+        try:
+            response.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            e.response.read()
+            cls._convert_and_raise(e)
+
+        return
+
 
-        msg = response.text
-        LOGGER.debug(f"Response text: {msg}")
+class _Limiter:
+    """Limit number of workers and rate of requests.
 
-        if exception == exceptions.TooManyRequests:
-            # differentiate between over quota and rate-limiting
-            if 'quota' in msg.lower():
-                exception = exceptions.OverQuota
+    Avoids clobbering the API with thousands of async requests.
 
-        raise exception(msg)
+    Setting rate_limit to zero disables rate (cadence) limiting.
+    Setting max_workers to zero disables capping maximum workers.
+
+    This is inspired by asyncio-throttle[1] but altered to enforce cadence
+    based on finding that the API returns TooManyRequestError if 2 calls are
+    made too close to eachother (even though max rate limit is 5 calls per
+    second)[2].
+
+    In investigating options, aiolimiter[3] was also looked at but it seems to
+    have odd behavior with httpx [4].
+
+    References:
+    [1] https://github.com/hallazzang/asyncio-throttle
+    [2] https://github.com/planetlabs/planet-client-python/issues/580#issuecomment-1182752851
+    [3] https://github.com/mjpieters/aiolimiter
+    [4] https://github.com/mjpieters/aiolimiter/issues/73
+
+    The behavior of limiting in communication with live servers can be tested
+    and refined using session_configuration.py in the scripts directory.
+    """
+
+    def __init__(self, rate_limit=0, max_workers=0):
+        # Configuration
+        if rate_limit > 0:
+            self.cadence = 1.0 / rate_limit
+            LOGGER.debug(f'Throttling cadence set to {self.cadence}s.')
+        else:
+            self.cadence = 0
+
+        self.limit = max_workers
+        if self.limit:
+            LOGGER.debug(f'Workers capped at {self.limit}.')
+
+        self.retry_interval = 0.01
+
+        # track state
+        self._running = 0
+        self._last_call = None
+
+    @staticmethod
+    def _get_now():
+        return time.monotonic()
+
+    async def throttle(self):
+        if self.cadence:
+            while True:
+                now = self._get_now()
+                if self._last_call is None:
+                    # first call, no need to throttle
+                    self._last_call = now
+                    break
+                elif now - self._last_call >= self.cadence:
+                    LOGGER.debug(
+                        f'Throught throttle, delta: {now - self._last_call}')
+                    self._last_call = now
+                    break
+                await asyncio.sleep(self.retry_interval)
+
+    async def acquire(self):
+        if self.limit:
+            while True:
+                if self._running < self.limit:
+                    self._running += 1
+                    LOGGER.debug('Worker acquired.')
+                    break
+                await asyncio.sleep(self.retry_interval)
+
+    def release(self):
+        if self.limit and self._running:
+            LOGGER.debug('Worker released.')
+            self._running -= 1
+
+    async def __aenter__(self):
+        await self.acquire()
+        await self.throttle()
+
+    async def __aexit__(self, exc_type, exc, tb):
+        self.release()
 
 
 class Session(BaseSession):
@@ -119,7 +229,6 @@ class Session(BaseSession):
 
         Parameters:
             auth: Planet server authentication.
-
         """
         if auth is None:
             # Try getting credentials from environment before checking
@@ -130,7 +239,10 @@ class Session(BaseSession):
             except exceptions.PlanetError:
                 auth = Auth.from_file()
 
-        self._client = httpx.AsyncClient(auth=auth)
+        LOGGER.info(f'Session read timeout set to {READ_TIMEOUT}.')
+        timeout = httpx.Timeout(10.0, read=READ_TIMEOUT)
+        self._client = httpx.AsyncClient(auth=auth, timeout=timeout)
+
         self._client.headers.update({'User-Agent': self._get_user_agent()})
         self._client.headers.update({'X-Planet-App': 'python-sdk'})
 
@@ -140,17 +252,27 @@ class Session(BaseSession):
         async def alog_response(*args, **kwargs):
             return self._log_response(*args, **kwargs)
 
-        async def araise_for_status(*args, **kwargs):
-            return self._raise_for_status(*args, **kwargs)
-
         self._client.event_hooks['request'] = [alog_request]
         self._client.event_hooks['response'] = [
-            alog_response, araise_for_status
+            alog_response, self._raise_for_status
         ]
 
         self.max_retries = MAX_RETRIES
         self.max_retry_backoff = MAX_RETRY_BACKOFF
 
+        self._limiter = _Limiter(rate_limit=RATE_LIMIT, max_workers=MAX_ACTIVE)
+        self.outcomes: Counter[str] = Counter()
+
+    @classmethod
+    async def _raise_for_status(cls, response):
+        try:
+            response.raise_for_status()
+        except httpx.HTTPStatusError as e:
+            await e.response.aread()
+            cls._convert_and_raise(e)
+
+        return
+
     async def __aenter__(self):
         return self
 
@@ -182,15 +304,22 @@ class Session(BaseSession):
             try:
                 resp = await func(*a, **kw)
                 break
-            except exceptions.TooManyRequests as e:
-                if num_tries > self.max_retries:
-                    raise e
+            except Exception as e:
+                if type(e) in RETRY_EXCEPTIONS:
+                    if num_tries > self.max_retries:
+                        raise e
+                    else:
+                        self.outcomes.update([type(e)])
+                        LOGGER.info(f'Try {num_tries}')
+                        LOGGER.info(f'Retrying: caught {type(e)}: {e}')
+                        wait_time = self._calculate_wait(
+                            num_tries, self.max_retry_backoff)
+                        LOGGER.info(f'Retrying: sleeping {wait_time}s')
+                        await asyncio.sleep(wait_time)
                 else:
-                    LOGGER.debug(f'Try {num_tries}')
-                    wait_time = self._calculate_wait(num_tries,
-                                                     self.max_retry_backoff)
-                    LOGGER.info(f'Too Many Requests: sleeping {wait_time}s')
-                    await asyncio.sleep(wait_time)
+                    raise e
+
+        self.outcomes.update(['Successful'])
         return resp
 
     @staticmethod
@@ -239,9 +368,10 @@ class Session(BaseSession):
         return await self._retry(self._request, request, stream=stream)
 
     async def _request(self, request, stream=False):
-        """Submit a request"""
-        http_resp = await self._client.send(request.http_request,
-                                            stream=stream)
+        """Submit a request with rate/worker limiting."""
+        async with self._limiter:
+            http_resp = await self._client.send(request.http_request,
+                                                stream=stream)
         return models.Response(request, http_resp)
 
     def stream(self, request: models.Request) -> Stream:
diff --git a/planet/order_request.py b/planet/order_request.py
index 104b028..911927b 100644
--- a/planet/order_request.py
+++ b/planet/order_request.py
@@ -28,7 +28,8 @@ def build_request(name: str,
                   delivery: dict = None,
                   notifications: dict = None,
                   order_type: str = None,
-                  tools: List[dict] = None) -> dict:
+                  tools: List[dict] = None,
+                  stac: dict = None) -> dict:
     '''Prepare an order request.
 
     ```python
@@ -86,6 +87,9 @@ def build_request(name: str,
     if tools:
         details['tools'] = tools
 
+    if stac:
+        details['metadata'] = stac
+
     return details
 
 
diff --git a/planet/specs.py b/planet/specs.py
index f3971ab..9c08a0b 100644
--- a/planet/specs.py
+++ b/planet/specs.py
@@ -15,7 +15,7 @@
 """Functionality for validating against the Planet API specification."""
 import json
 import logging
-
+import itertools
 from .constants import DATA_DIR
 
 PRODUCT_BUNDLE_SPEC_NAME = 'orders_product_bundle_2022_02_02.json'
@@ -37,19 +37,32 @@ SUPPORTED_FILE_FORMATS = ['COG', 'PL_NITF']
 LOGGER = logging.getLogger(__name__)
 
 
-class SpecificationException(Exception):
+class NoMatchException(Exception):
     '''No match was found'''
     pass
 
 
+class SpecificationException(Exception):
+    '''No match was found'''
+
+    def __init__(self, value, supported, field_name):
+        self.value = value
+        self.supported = supported
+        self.field_name = field_name
+        self.opts = ', '.join(["'" + s + "'" for s in supported])
+
+    def __str__(self):
+        return f'{self.field_name} - \'{self.value}\' is not one of {self.opts}.'
+
+
 def validate_bundle(bundle):
     supported = get_product_bundles()
     return _validate_field(bundle, supported, 'product_bundle')
 
 
 def validate_item_type(item_type, bundle):
-    bundle = validate_bundle(bundle)
-    supported = get_item_types(bundle)
+    validated_bundle = validate_bundle(bundle)
+    supported = get_item_types(validated_bundle)
     return _validate_field(item_type, supported, 'item_type')
 
 
@@ -73,30 +86,23 @@ def validate_file_format(file_format):
 
 def _validate_field(value, supported, field_name):
     try:
-        value = get_match(value, supported)
+        value = get_match(value, supported, field_name)
     except (NoMatchException):
-        opts = ', '.join(["'" + s + "'" for s in supported])
-        msg = f'{field_name} - \'{value}\' is not one of {opts}.'
-        raise SpecificationException(msg)
+        raise SpecificationException(value, supported, field_name)
     return value
 
 
-class NoMatchException(Exception):
-    '''No match was found'''
-    pass
-
-
-def get_match(test_entry, spec_entries):
+def get_match(test_entry, spec_entries, field_name):
     '''Find and return matching spec entry regardless of capitalization.
 
     This is helpful for working with the API spec, where the capitalization
     is hard to remember but must be exact otherwise the API throws an
     exception.'''
     try:
-        match = next(t for t in spec_entries
-                     if t.lower() == test_entry.lower())
+        match = next(e for e in spec_entries
+                     if e.lower() == test_entry.lower())
     except (StopIteration):
-        raise NoMatchException
+        raise SpecificationException(test_entry, spec_entries, field_name)
 
     return match
 
@@ -107,10 +113,20 @@ def get_product_bundles():
     return spec['bundles'].keys()
 
 
-def get_item_types(product_bundle):
-    '''Get item types supported by Orders API for the given product bundle.'''
+def get_item_types(product_bundle=None):
+    '''If given product bundle, get specific item types supported by Orders
+    API. Otherwise, get all item types supported by Orders API.'''
     spec = _get_product_bundle_spec()
-    return spec['bundles'][product_bundle]['assets'].keys()
+
+    if product_bundle:
+        item_types = set(spec['bundles'][product_bundle]['assets'].keys())
+    else:
+        item_types = set(
+            itertools.chain.from_iterable(
+                spec['bundles'][bundle]['assets'].keys()
+                for bundle in get_product_bundles()))
+
+    return item_types
 
 
 def _get_product_bundle_spec():
diff --git a/scripts/session_configuration.py b/scripts/session_configuration.py
new file mode 100644
index 0000000..74a0565
--- /dev/null
+++ b/scripts/session_configuration.py
@@ -0,0 +1,184 @@
+# Copyright 2022 Planet Labs PBC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""Test Session Configuration.
+
+This is a script for identifying the best session configuration for fast,
+reliable, and polite communication with the Planet services.
+"""
+import argparse
+import asyncio
+from collections import Counter
+import inspect
+import logging
+import sys
+
+import pytest
+
+import planet
+from planet import data_filter
+
+LOGGER = logging.getLogger(__name__)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('execution_number', range(3))
+@pytest.mark.parametrize('rate_limit, max_active', [(10, 50), (10, 100),
+                                                    (0, 0)])
+@pytest.mark.parametrize('limit, num_concurrent', [(100, 3)])
+async def test_configuration(execution_number,
+                             limit,
+                             num_concurrent,
+                             rate_limit,
+                             max_active,
+                             monkeypatch):
+    """Find configuration that successfully completes and runs the fastest"""
+    # Setting rate_limit and max_active to zero disables limiting
+    LOGGER.warning(
+        'order: limit, num_concurrent, rate_limit, max_active, execution_number'
+    )
+
+    async def _search(cl):
+        sfilter = data_filter.permission_filter()
+        items = await cl.search(['PSScene'], sfilter, limit=limit)
+        return list([i async for i in items])
+
+    await _runit(_search,
+                 planet.DataClient,
+                 monkeypatch,
+                 num_concurrent=num_concurrent,
+                 read_timeout=30,
+                 rate_limit=rate_limit,
+                 max_active=max_active)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('execution_number', range(3))
+@pytest.mark.parametrize('rate_limit, max_active', [(0, 0)])
+@pytest.mark.parametrize('limit, num_concurrent', [(100, 3)])
+async def test_api_rate_limiting(execution_number,
+                                 limit,
+                                 num_concurrent,
+                                 rate_limit,
+                                 max_active,
+                                 monkeypatch):
+    """Test API rate limit behavior"""
+    LOGGER.warning(
+        'order: limit, num_concurrent, rate_limit, max_active, execution_number'
+    )
+
+    async def _search(cl):
+        sfilter = data_filter.permission_filter()
+        items = await cl.search(['PSScene'], sfilter, limit=limit)
+        return list([i async for i in items])
+
+    monkeypatch.setattr(planet.http, 'RETRY_EXCEPTIONS', [])
+    await _runit(_search,
+                 planet.DataClient,
+                 monkeypatch,
+                 num_concurrent=num_concurrent,
+                 read_timeout=30,
+                 rate_limit=rate_limit,
+                 max_active=max_active)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('execution_number', range(5))
+@pytest.mark.parametrize('rate_limit, max_active', [(10, 50)])
+@pytest.mark.parametrize('limit, num_concurrent', [(100, 1000)])
+async def test_reliability(execution_number,
+                           limit,
+                           num_concurrent,
+                           rate_limit,
+                           max_active,
+                           monkeypatch):
+    """Stress test the reliability of the communication with Planet services.
+    """
+    LOGGER.warning(
+        'order: limit, num_concurrent, rate_limit, max_active, execution_number'
+    )
+
+    async def _search(cl):
+        sfilter = data_filter.permission_filter()
+        items = await cl.search(['PSScene'], sfilter, limit=limit)
+        return list([i async for i in items])
+
+    await _runit(_search,
+                 planet.DataClient,
+                 monkeypatch,
+                 num_concurrent=num_concurrent,
+                 read_timeout=30,
+                 rate_limit=rate_limit,
+                 max_active=max_active)
+
+
+async def _runit(func,
+                 client,
+                 monkeypatch,
+                 num_concurrent,
+                 read_timeout,
+                 rate_limit,
+                 max_active,
+                 retry_exceptions=None):
+    """Run a method of a client with different session settings"""
+    monkeypatch.setattr(planet.http, 'READ_TIMEOUT', read_timeout)
+    monkeypatch.setattr(planet.http, 'RATE_LIMIT', rate_limit)
+    monkeypatch.setattr(planet.http, 'MAX_ACTIVE', max_active)
+
+    if retry_exceptions:
+        monkeypatch.setattr(planet.http, 'RETRY_EXCEPTIONS', retry_exceptions)
+
+    async with planet.Session() as sess:
+        cl = client(sess)
+        tasks = [asyncio.create_task(func(cl)) for _ in range(num_concurrent)]
+
+        res = await asyncio.gather(*tasks, return_exceptions=True)
+        LOGGER.warning(f'Session outcomes: {sess.outcomes}')
+
+        exceptions = [r for r in res if isinstance(r, BaseException)]
+        if len(exceptions):
+            task_exceptions = Counter([type(e) for e in exceptions])
+            LOGGER.warning(f'Task exceptions: {task_exceptions}')
+            raise exceptions[0]
+
+
+if __name__ == "__main__":
+    # automatically find the tests within this module for specifying as
+    # possible choices for test argument
+    this_module = sys.modules[__name__]
+    test_options = [
+        t[0] for t in inspect.getmembers(this_module, inspect.isfunction)
+        if t[0].startswith('test_')
+    ]
+
+    log_levels = ('debug', 'info', 'warning', 'error', 'critical')
+
+    parser = argparse.ArgumentParser(
+        description='Session configuration tests.')
+    parser.add_argument('--log-level', default='warning', choices=log_levels)
+    parser.add_argument('test',
+                        choices=test_options,
+                        help='specify the test to run')
+    args = parser.parse_args()
+
+    cmds = [
+        '--tb=line',  # we are only interested in the types of exceptions raised
+        '--log-format=%(asctime)s.%(msecs)03d %(levelname)s %(message)s',
+        '--log-date-format=%Y-%m-%d %H:%M:%S',
+        f'--log-cli-level={args.log_level}',
+        '--durations=0',  # record and report runtimes
+        __file__,
+        f'-k={args.test}'
+    ]
+    print(f'Running: pytest {" ".join(cmds)}')
+    sys.exit(pytest.main(cmds))
diff --git a/setup.py b/setup.py
index 13554b4..1255154 100644
--- a/setup.py
+++ b/setup.py
@@ -26,15 +26,13 @@ with open('planet/__version__.py') as f:
 install_requires = [
     'click>=8.0.0',
     'geojson',
-    'httpx==0.16.1',
+    'httpx==0.23.0',
     'jsonschema',
     'pyjwt>=2.1',
     'tqdm>=4.56',
 ]
 
-test_requires = [
-    'pytest', 'pytest-asyncio==0.16', 'pytest-cov', 'respx==0.16.3'
-]
+test_requires = ['pytest', 'pytest-asyncio==0.16', 'pytest-cov', 'respx==0.19']
 
 lint_requires = ['flake8', 'mypy', 'yapf']
 


Test patch:
diff --git a/.github/workflows/autopublish-testpypi.yml b/.github/workflows/autopublish-testpypi.yml
new file mode 100644
index 0000000..13de7a7
--- /dev/null
+++ b/.github/workflows/autopublish-testpypi.yml
@@ -0,0 +1,34 @@
+name: Autopublish to TestPyPi
+
+on:
+  push:
+    tags:
+      - '*'
+
+jobs:
+  publish:
+    name: Build, verify, & upload package
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v3
+      - name: Set up Python
+        uses: actions/setup-python@v3
+        with:
+          python-version: 3.7
+
+      - name: Pip cache
+        uses: actions/cache@v2
+        with:
+          path: ~/.cache/pip
+          key: ${{ runner.os }}-pip
+          restore-keys: |
+            ${{ runner.os }}-pip
+
+      - name: Build, verify, and upload to TestPyPI
+        run: |
+          pip install --upgrade nox
+          nox -s build publish_testpypi
+        env:
+          TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 852131b..4676344 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -16,13 +16,21 @@ import copy
 
 import pytest
 
-from planet import Session
+import planet
+
+
+@pytest.fixture(autouse=True, scope='function')
+def test_disable_limiter(monkeypatch):
+    """Disable rate/max worker limiting on Session requests to avoid
+    slowing down tests"""
+    monkeypatch.setattr(planet.http, 'RATE_LIMIT', 0)
+    monkeypatch.setattr(planet.http, 'MAX_ACTIVE', 0)
 
 
 @pytest.fixture
 @pytest.mark.asyncio
 async def session():
-    async with Session() as ps:
+    async with planet.Session() as ps:
         yield ps
 
 
diff --git a/tests/integration/test_data_cli.py b/tests/integration/test_data_cli.py
index 60a1a64..f146cf0 100644
--- a/tests/integration/test_data_cli.py
+++ b/tests/integration/test_data_cli.py
@@ -23,6 +23,7 @@ from click.testing import CliRunner
 import pytest
 
 from planet.cli import cli
+from planet.specs import get_item_types
 
 LOGGER = logging.getLogger(__name__)
 
@@ -55,6 +56,23 @@ def test_data_command_registered(invoke):
     # Add other sub-commands here.
 
 
+def test_data_search_command_registered(invoke):
+    """planet-data search command prints help and usage message."""
+    runner = CliRunner()
+    result = invoke(["search", "--help"], runner=runner)
+    all_item_types = [a for a in get_item_types()]
+    assert result.exit_code == 0
+    assert "Usage" in result.output
+    assert "limit" in result.output
+    assert "name" in result.output
+    assert "sort" in result.output
+    assert "pretty" in result.output
+    assert "help" in result.output
+    for a in all_item_types:
+        assert a in result.output.replace('\n', '').replace(' ', '')
+    # Add other sub-commands here.
+
+
 PERMISSION_FILTER = {"type": "PermissionFilter", "config": ["assets:download"]}
 STD_QUALITY_FILTER = {
     "type": "StringInFilter",
@@ -358,8 +376,8 @@ def test_data_filter_update(invoke, assert_and_filters_equal, default_filters):
 @respx.mock
 @pytest.mark.asyncio
 @pytest.mark.parametrize("filter", ['{1:1}', '{"foo"}'])
-@pytest.mark.parametrize(
-    "item_types", ['PSScene', 'SkySatScene', ('PSScene', 'SkySatScene')])
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 def test_data_search_cmd_filter_invalid_json(invoke, item_types, filter):
     """Test for planet data search_quick. Test with multiple item_types.
     Test should fail as filter does not contain valid JSON."""
@@ -375,8 +393,8 @@ def test_data_search_cmd_filter_invalid_json(invoke, item_types, filter):
 
 
 @respx.mock
-@pytest.mark.parametrize(
-    "item_types", ['PSScene', 'SkySatScene', ('PSScene', 'SkySatScene')])
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 def test_data_search_cmd_filter_success(invoke, item_types):
     """Test for planet data search_quick. Test with multiple item_types.
     Test should succeed as filter contains valid JSON."""
@@ -495,8 +513,8 @@ def test_data_search_cmd_limit(invoke,
 @respx.mock
 @pytest.mark.asyncio
 @pytest.mark.parametrize("filter", ['{1:1}', '{"foo"}'])
-@pytest.mark.parametrize(
-    "item_types", ['PSScene', 'SkySatScene', ('PSScene', 'SkySatScene')])
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 def test_data_search_create_filter_invalid_json(invoke, item_types, filter):
     """Test for planet data search_create. Test with multiple item_types.
     Test should fail as filter does not contain valid JSON."""
@@ -514,8 +532,8 @@ def test_data_search_create_filter_invalid_json(invoke, item_types, filter):
 
 
 @respx.mock
-@pytest.mark.parametrize(
-    "item_types", ['PSScene', 'SkySatScene', ('PSScene', 'SkySatScene')])
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 def test_data_search_create_filter_success(invoke, item_types):
     """Test for planet data search_create. Test with multiple item_types.
     Test should succeed as filter contains valid JSON."""
@@ -601,8 +619,8 @@ def test_data_stats_invalid_filter(invoke, filter):
 
 
 @respx.mock
-@pytest.mark.parametrize(
-    "item_types", ['PSScene', 'SkySatScene', ('PSScene', 'SkySatScene')])
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 @pytest.mark.parametrize("interval, exit_code", [(None, 1), ('hou', 2),
                                                  ('hour', 0)])
 def test_data_stats_invalid_interval(invoke, item_types, interval, exit_code):
@@ -630,8 +648,8 @@ def test_data_stats_invalid_interval(invoke, item_types, interval, exit_code):
 
 
 @respx.mock
-@pytest.mark.parametrize(
-    "item_types", ['PSScene', 'SkySatScene', ('PSScene', 'SkySatScene')])
+@pytest.mark.parametrize("item_types",
+                         ['PSScene', 'SkySatScene', 'PSScene, SkySatScene'])
 @pytest.mark.parametrize("interval", ['hour', 'day', 'week', 'month', 'year'])
 def test_data_stats_success(invoke, item_types, interval):
     """Test for planet data stats. Test with multiple item_types.
diff --git a/tests/integration/test_orders_cli.py b/tests/integration/test_orders_cli.py
index 11eb711..f47c6bb 100644
--- a/tests/integration/test_orders_cli.py
+++ b/tests/integration/test_orders_cli.py
@@ -44,6 +44,11 @@ def invoke():
     return _invoke
 
 
+@pytest.fixture
+def stac_json():
+    return {'stac': {}}
+
+
 @respx.mock
 def test_cli_orders_list_basic(invoke, order_descriptions):
     next_page_url = TEST_ORDERS_URL + '/blob/?page_marker=IAmATest'
@@ -451,7 +456,10 @@ def test_cli_orders_create_basic_success(expected_ids,
     [('4500474_2133707_2021-05-20_2419', ['4500474_2133707_2021-05-20_2419']),
      ('4500474_2133707_2021-05-20_2419,4500474_2133707_2021-05-20_2420',
       ['4500474_2133707_2021-05-20_2419', '4500474_2133707_2021-05-20_2420'])])
-def test_cli_orders_request_basic_success(expected_ids, id_string, invoke):
+def test_cli_orders_request_basic_success(expected_ids,
+                                          id_string,
+                                          invoke,
+                                          stac_json):
     result = invoke([
         'request',
         '--name=test',
@@ -469,6 +477,8 @@ def test_cli_orders_request_basic_success(expected_ids, id_string, invoke):
             "item_type": "PSOrthoTile",
             "product_bundle": "analytic"
         }],
+        "metadata":
+        stac_json
     }
     assert order_request == json.loads(result.output)
 
@@ -503,7 +513,8 @@ def test_cli_orders_request_id_empty(invoke):
 def test_cli_orders_request_clip_success(geom_fixture,
                                          request,
                                          invoke,
-                                         geom_geojson):
+                                         geom_geojson,
+                                         stac_json):
 
     geom = request.getfixturevalue(geom_fixture)
 
@@ -529,7 +540,9 @@ def test_cli_orders_request_clip_success(geom_fixture,
             'clip': {
                 'aoi': geom_geojson
             }
-        }]
+        }],
+        "metadata":
+        stac_json
     }
     assert order_request == json.loads(result.output)
 
@@ -566,7 +579,7 @@ def test_cli_orders_request_both_clip_and_tools(invoke, geom_geojson):
     assert "Specify only one of '--clip' or '--tools'" in result.output
 
 
-def test_cli_orders_request_cloudconfig(invoke):
+def test_cli_orders_request_cloudconfig(invoke, stac_json):
     config_json = {
         'amazon_s3': {
             'aws_access_key_id': 'aws_access_key_id',
@@ -596,12 +609,14 @@ def test_cli_orders_request_cloudconfig(invoke):
             "product_bundle": "analytic",
         }],
         "delivery":
-        config_json
+        config_json,
+        "metadata":
+        stac_json
     }
     assert order_request == json.loads(result.output)
 
 
-def test_cli_orders_request_email(invoke):
+def test_cli_orders_request_email(invoke, stac_json):
     result = invoke([
         'request',
         '--name=test',
@@ -621,14 +636,16 @@ def test_cli_orders_request_email(invoke):
             "product_bundle": "analytic",
         }],
         "notifications": {
-            "email": True
-        }
+            "email": True,
+        },
+        "metadata":
+        stac_json
     }
     assert order_request == json.loads(result.output)
 
 
 @respx.mock
-def test_cli_orders_request_tools(invoke, geom_geojson):
+def test_cli_orders_request_tools(invoke, geom_geojson, stac_json):
     tools_json = [{'clip': {'aoi': geom_geojson}}, {'composite': {}}]
 
     result = invoke([
@@ -649,6 +666,32 @@ def test_cli_orders_request_tools(invoke, geom_geojson):
             "product_bundle": "analytic",
         }],
         "tools":
-        tools_json
+        tools_json,
+        "metadata":
+        stac_json
+    }
+    assert order_request == json.loads(result.output)
+
+
+@respx.mock
+def test_cli_orders_request_no_stac(invoke):
+
+    result = invoke([
+        'request',
+        '--name=test',
+        '--id=4500474_2133707_2021-05-20_2419',
+        '--bundle=analytic',
+        '--item-type=PSOrthoTile',
+        '--no-stac'
+    ])
+
+    order_request = {
+        "name":
+        "test",
+        "products": [{
+            "item_ids": ["4500474_2133707_2021-05-20_2419"],
+            "item_type": "PSOrthoTile",
+            "product_bundle": "analytic",
+        }]
     }
     assert order_request == json.loads(result.output)
diff --git a/tests/pytest.ini b/tests/pytest.ini
index 401d96f..cd8c265 100644
--- a/tests/pytest.ini
+++ b/tests/pytest.ini
@@ -1,2 +1,4 @@
 [pytest]
 log_cli = True
+log_format = %(asctime)s %(levelname)s %(message)s
+log_date_format = %Y-%m-%d %H:%M:%S
diff --git a/tests/unit/test_data_item_type.py b/tests/unit/test_data_item_type.py
new file mode 100644
index 0000000..41d7b78
--- /dev/null
+++ b/tests/unit/test_data_item_type.py
@@ -0,0 +1,55 @@
+# Copyright 2022 Planet Labs PBC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+import logging
+import pytest
+import click
+from planet.cli.data import check_item_types
+
+LOGGER = logging.getLogger(__name__)
+
+
+class MockContext:
+
+    def __init__(self):
+        self.obj = {}
+
+
+@pytest.mark.parametrize("item_types",
+                         [
+                             'PSScene3Band',
+                             'MOD09GQ',
+                             'MYD09GA',
+                             'REOrthoTile',
+                             'SkySatCollect',
+                             'SkySatScene',
+                             'MYD09GQ',
+                             'Landsat8L1G',
+                             'Sentinel2L1C',
+                             'MOD09GA',
+                             'Sentinel1',
+                             'PSScene',
+                             'PSOrthoTile',
+                             'PSScene4Band',
+                             'REScene'
+                         ])
+def test_item_type_success(item_types):
+    ctx = MockContext()
+    result = check_item_types(ctx, 'item_types', [item_types])
+    assert result == [item_types]
+
+
+def test_item_type_fail():
+    ctx = MockContext()
+    with pytest.raises(click.BadParameter):
+        check_item_types(ctx, 'item_type', "bad_item_type")
diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py
index aab2c20..a835e2c 100644
--- a/tests/unit/test_http.py
+++ b/tests/unit/test_http.py
@@ -12,6 +12,7 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
+import asyncio
 import logging
 from http import HTTPStatus
 import math
@@ -40,11 +41,10 @@ def mock_request():
 def mock_response():
 
     def mocker(code, text='', json={"message": "nope"}):
-        r = Mock()
-        r.status_code = code
-        r.text = text
-        r.json = Mock(return_value=json)
-        return r
+        mock_request = httpx.Request('GET', 'url')
+        return httpx.Response(status_code=code,
+                              json=json,
+                              request=mock_request)
 
     return mocker
 
@@ -61,17 +61,135 @@ def test_basesession__raise_for_status(mock_response):
         http.BaseSession._raise_for_status(
             mock_response(HTTPStatus.TOO_MANY_REQUESTS, text='', json={}))
 
-    with pytest.raises(exceptions.OverQuota):
-        http.BaseSession._raise_for_status(
-            mock_response(HTTPStatus.TOO_MANY_REQUESTS,
-                          text='exceeded QUOTA"',
-                          json={}))
+    with pytest.raises(exceptions.APIError):
+        http.BaseSession._raise_for_status(mock_response(HTTPStatus.FORBIDDEN))
 
     with pytest.raises(exceptions.APIError):
         http.BaseSession._raise_for_status(
             mock_response(HTTPStatus.METHOD_NOT_ALLOWED, json={}))
 
 
+@pytest.mark.asyncio
+async def test__Limiter_max_workers(monkeypatch):
+    """Test that the worker cap is enforced.
+
+    This test async queues up more tasks than the limiter worker cap. Each task
+    registers itself as running, continues to run until an external flag
+    is changed, then registers itself as done. Along with the tasks, a
+    controller task is run asynchronously. This task waits for the other tasks
+    to queue up, checks that the number of tasks running is equal to the
+    limiter worker cap, then changes the state of the external flag, releasing
+    the tasks.
+    """
+    max_workers = 2
+    limiter = http._Limiter(rate_limit=0, max_workers=max_workers)
+
+    # this value seems small enough to speed up the test but large enough
+    # to avoid undue CPU churn
+    short_wait = 0.001
+
+    limiter.retry_interval = short_wait
+
+    active = 0
+    calls = 0
+    hold_flag = True
+
+    async def test_func():
+        async with limiter:
+            nonlocal active, calls, hold_flag
+            active += 1
+            calls += 1
+
+            # wait until hold is released
+            while hold_flag:
+                await asyncio.sleep(short_wait)
+
+            active -= 1
+
+    async def control():
+        nonlocal active, hold_flag
+
+        # this value seems large enough to allow test functions to start
+        # but small enough to not noticeably slow down tests
+        await asyncio.sleep(.01)
+
+        # confirm number of workers is capped
+        assert active == max_workers
+
+        # release hold so workers can complete
+        hold_flag = False
+
+    total_calls = 2 * max_workers  # just needs to be more than worker cap
+    test_functions = [test_func() for _ in range(total_calls)]
+    await asyncio.gather(*test_functions, control())
+    assert calls == total_calls
+
+
+@pytest.mark.asyncio
+async def test__Limiter_rate_limit(monkeypatch):
+    """Test that the rate limit is enforced.
+
+    This test async queues up tasks and then adjusts the time seen by
+    _Limiter, checking that tasks are called according to the rate limit.
+    """
+    rate_limit = 5  # calls per second
+    cadence = .2  # rate of 5/s -> period (cadence) of 200ms
+    limiter = http._Limiter(rate_limit=rate_limit, max_workers=0)
+
+    # this value seems small enough to speed up the test but large enough
+    # to avoid undue CPU churn
+    limiter.retry_interval = 0.001
+
+    # this value seems large enough for one async function to wait for others
+    # to progress to the next stage but small enough to not noticeably slow
+    # down tests. It needs to be larger than limiter.retry_interval
+    other_fcns_wait = 0.01
+
+    # establish control over the time _Limiter reads
+    current_time = 0
+    monkeypatch.setattr(http._Limiter, '_get_now', lambda x: current_time)
+
+    calls = 0
+
+    async def test_func():
+        async with limiter:
+            nonlocal calls
+            calls += 1
+
+    async def control():
+        nonlocal calls, current_time
+
+        # on call gets made right out of the gate
+        await asyncio.sleep(other_fcns_wait)
+        assert calls == 1
+
+        # we haven't reached cadence delay, no new calls should be made
+        current_time = 0.9 * cadence
+        await asyncio.sleep(other_fcns_wait)
+        assert calls == 1
+
+        # we passed cadence delay, a new call should have been made
+        current_time = 1.1 * cadence
+        await asyncio.sleep(other_fcns_wait)
+        assert calls == 2
+
+        # we skip forward 2 * cadence delay, only one more call should have
+        # been made
+        current_time = 3.2 * cadence
+        await asyncio.sleep(other_fcns_wait)
+        assert calls == 3
+        LOGGER.debug('here')
+
+        # final call should have been made
+        current_time = 5 * cadence
+        await asyncio.sleep(other_fcns_wait)
+        assert calls == 4
+
+    total_calls = 4  # needs to equal the highest number of calls in control()
+    test_functions = [test_func() for _ in range(total_calls)]
+    await asyncio.gather(*test_functions, control())
+
+
 @pytest.mark.asyncio
 async def test_session_contextmanager():
     async with http.Session():
diff --git a/tests/unit/test_order_request.py b/tests/unit/test_order_request.py
index 0953e4e..0163357 100644
--- a/tests/unit/test_order_request.py
+++ b/tests/unit/test_order_request.py
@@ -52,13 +52,15 @@ def test_build_request():
     }
     order_type = 'partial'
     tool = {'band_math': 'jsonstring'}
+    stac_json = {'stac': {}}
 
     request = order_request.build_request('test_name', [product],
                                           subscription_id=subscription_id,
                                           delivery=delivery,
                                           notifications=notifications,
                                           order_type=order_type,
-                                          tools=[tool])
+                                          tools=[tool],
+                                          stac=stac_json)
     expected = {
         'name': 'test_name',
         'products': [product],
@@ -66,7 +68,8 @@ def test_build_request():
         'delivery': delivery,
         'notifications': notifications,
         'order_type': order_type,
-        'tools': [tool]
+        'tools': [tool],
+        'metadata': stac_json
     }
     assert request == expected
 
@@ -77,7 +80,8 @@ def test_build_request():
                                         delivery=delivery,
                                         notifications=notifications,
                                         order_type=order_type,
-                                        tools=[tool])
+                                        tools=[tool],
+                                        stac=stac_json)
 
 
 def test_product():
diff --git a/tests/unit/test_specs.py b/tests/unit/test_specs.py
index 6f721e9..e7c17bf 100644
--- a/tests/unit/test_specs.py
+++ b/tests/unit/test_specs.py
@@ -23,16 +23,34 @@ LOGGER = logging.getLogger(__name__)
 TEST_PRODUCT_BUNDLE = 'visual'
 # must be a valid item type for TEST_PRODUCT_BUNDLE
 TEST_ITEM_TYPE = 'PSScene'
+ALL_ITEM_TYPES = [
+    'PSOrthoTile',
+    'Sentinel1',
+    'REOrthoTile',
+    'PSScene',
+    'PSScene4Band',
+    'Landsat8L1G',
+    'PSScene3Band',
+    'REScene',
+    'MOD09GA',
+    'MYD09GA',
+    'MOD09GQ',
+    'SkySatCollect',
+    'Sentinel2L1C',
+    'MYD09GQ',
+    'SkySatScene'
+]
 
 
 def test_get_type_match():
     spec_list = ['Locket', 'drop', 'DEER']
 
     test_entry = 'locket'
-    assert 'Locket' == specs.get_match(test_entry, spec_list)
+    field_name = 'field_name'
+    assert 'Locket' == specs.get_match(test_entry, spec_list, field_name)
 
-    with pytest.raises(specs.NoMatchException):
-        specs.get_match('a', ['b'])
+    with pytest.raises(specs.SpecificationException):
+        specs.get_match('a', ['b'], field_name)
 
 
 def test_validate_bundle_supported():
@@ -90,6 +108,12 @@ def test_get_product_bundles():
     assert TEST_PRODUCT_BUNDLE in bundles
 
 
-def test_get_item_types():
-    item_types = specs.get_item_types(TEST_PRODUCT_BUNDLE)
+def test_get_item_types_with_bundle():
+    item_types = specs.get_item_types(product_bundle=TEST_PRODUCT_BUNDLE)
     assert TEST_ITEM_TYPE in item_types
+
+
+def test_get_item_types_without_bundle():
+    item_types = specs.get_item_types()
+    for item in item_types:
+        assert item in ALL_ITEM_TYPES