openrvdas.logger.readers.http_reader

HTTPReader
==========

This module defines the `HTTPReader`, a polling reader for retrieving
data from HTTP or HTTPS endpoints at a fixed interval.

The reader performs periodic HTTP GET or POST requests to a configured URL,
optionally including headers and a JSON payload. Responses are returned either
as decoded text or raw bytes, depending on the encoding configuration.

Key features
------------
- Supports HTTP GET and POST methods
- Configurable request headers and JSON payloads
- Enforces a minimum polling interval between requests
- Thread-safe access using an internal lock
- Optional URL verification on first read
- Supports decoded text output or raw byte output
- Integrates with the OpenRVDAS Reader framework

Typical usage
-------------
Example usage with a GET request:

    reader = HTTPReader(
        url="https://example.com/data",
        interval=10
    )

    record = reader.read()

Example usage with a POST request and JSON payload:

    reader = HTTPReader(
        url="https://example.com/api",
        method="post",
        headers={"Authorization": "Bearer TOKEN"},
        payload={"sensor": "temp"},
        interval=5
    )

    record = reader.read()

Output behavior
---------------
- If ``encoding`` is set (default: ``'utf-8'``), responses are decoded into
  strings using the specified encoding and error-handling strategy.
- If ``encoding`` is ``None`` or empty, raw response bytes are returned.
- On failure, ``read()`` returns ``None``.

Error handling
--------------
- On the first read, the reader verifies that the configured URL is reachable.
  URL verification fails if:
  - The server returns HTTP 404 (resource not found), or
  - The server returns any HTTP status code >= 500, or
  - A network or request error occurs (e.g., timeout, DNS failure).
- HTTP 401, 403, and 405 responses are treated as valid during verification,
  since they indicate a reachable endpoint.
- If URL verification fails, ``read()`` returns ``None``.
- During normal operation, HTTP 4xx or 5xx responses and network errors are
  treated as read failures, reset URL verification, and cause ``read()``
  to return ``None``.

Thread safety
-------------
All calls to ``read()`` are protected by an internal lock, making this reader
safe for use in multi-threaded environments.

  1#!/usr/bin/env python3
  2"""
  3```
  4HTTPReader
  5==========
  6
  7This module defines the :class:`HTTPReader`, a polling reader for retrieving
  8data from HTTP or HTTPS endpoints at a fixed interval.
  9
 10The reader performs periodic HTTP GET or POST requests to a configured URL,
 11optionally including headers and a JSON payload. Responses are returned either
 12as decoded text or raw bytes, depending on the encoding configuration.
 13
 14Key features
 15------------
 16- Supports HTTP GET and POST methods
 17- Configurable request headers and JSON payloads
 18- Enforces a minimum polling interval between requests
 19- Thread-safe access using an internal lock
 20- Optional URL verification on first read
 21- Supports decoded text output or raw byte output
 22- Integrates with the OpenRVDAS Reader framework
 23
 24Typical usage
 25-------------
 26Example usage with a GET request:
 27
 28    reader = HTTPReader(
 29        url="https://example.com/data",
 30        interval=10
 31    )
 32
 33    record = reader.read()
 34
 35Example usage with a POST request and JSON payload:
 36
 37    reader = HTTPReader(
 38        url="https://example.com/api",
 39        method="post",
 40        headers={"Authorization": "Bearer TOKEN"},
 41        payload={"sensor": "temp"},
 42        interval=5
 43    )
 44
 45    record = reader.read()
 46
 47Output behavior
 48---------------
 49- If ``encoding`` is set (default: ``'utf-8'``), responses are decoded into
 50  strings using the specified encoding and error-handling strategy.
 51- If ``encoding`` is ``None`` or empty, raw response bytes are returned.
 52- On failure, ``read()`` returns ``None``.
 53
 54Error handling
 55--------------
 56- On the first read, the reader verifies that the configured URL is reachable.
 57  URL verification fails if:
 58  - The server returns HTTP 404 (resource not found), or
 59  - The server returns any HTTP status code >= 500, or
 60  - A network or request error occurs (e.g., timeout, DNS failure).
 61- HTTP 401, 403, and 405 responses are treated as valid during verification,
 62  since they indicate a reachable endpoint.
 63- If URL verification fails, ``read()`` returns ``None``.
 64- During normal operation, HTTP 4xx or 5xx responses and network errors are
 65  treated as read failures, reset URL verification, and cause ``read()``
 66  to return ``None``.
 67
 68Thread safety
 69-------------
 70All calls to ``read()`` are protected by an internal lock, making this reader
 71safe for use in multi-threaded environments.
 72
 73```
 74"""
 75
 76import threading
 77import time
 78import logging
 79import json
 80
 81from urllib.request import Request, urlopen
 82from urllib.error import HTTPError, URLError
 83
 84from logger.readers.reader import Reader  # noqa: E402
 85
 86
 87################################################################################
 88class HTTPReader(Reader):  # noqa: R0913
 89    """
 90    Read data from a URL at a set interval.
 91    """
 92
 93    _ALLOWED_METHODS = {"get", "post"}
 94
 95    ############################
 96    def __init__(self, url: str, method: str = 'get',  # noqa: R0902
 97                 headers: dict | None = None, payload: dict | None = None,
 98                 interval: float = 5.0, timeout: float = 2.0,
 99                 encoding: str | None = 'utf-8',
100                 encoding_errors: str = 'ignore', **kwargs):
101        """
102        ```
103        url - Network url to read, in protocol://host:port format (e.g.
104              'https://example.com' 'http://example.com:8000').
105
106        method - type of http request
107
108        headers - Headers to set for the request
109
110        payload - Payload to set for the request. Only applicable for
111                  POST requests
112
113        interval - Seconds between update requests, must be >=0
114
115        timeout - Max time to wait for device to respond AND read the response.
116
117        encoding - 'utf-8' by default. If empty or None, do not attempt any
118                decoding and return raw bytes. Other possible encodings are
119                listed in online documentation here:
120                https://docs.python.org/3/library/codecs.html#standard-encodings
121
122        encoding_errors - 'ignore' by default. Other error strategies are
123                'strict', 'replace', and 'backslashreplace', described here:
124                https://docs.python.org/3/howto/unicode.html#encodings
125        ```
126        """
127        super().__init__(encoding=encoding, encoding_errors=encoding_errors, **kwargs)
128
129        if interval < 0:
130            raise ValueError('Interval must be greater or equal to zero')
131
132        self.url = self._validate_url(url)
133        self.method = self._validate_method(method)
134        self.headers = headers or {}
135        self.payload = payload
136        self.interval = interval
137        self.timeout = timeout
138
139        self._read_lock = threading.Lock()
140        self._verified = False
141        self._next_read_time = 0.0
142
143    ############################
144    def _verify_url(self):
145        """Check URL reachability on first read."""
146        if self._verified:
147            return
148
149        def _check_status(code: int):
150            if code == 404 or code >= 500:
151                raise ValueError(
152                    f"invalid url: {self.url}; return code: {code}"
153                )
154
155        try:
156            req = Request(self.url, method="HEAD", headers=self.headers)
157            with urlopen(req, timeout=self.timeout) as resp:
158                _check_status(resp.status)
159                self._verified = True
160                return
161
162        except HTTPError as exc:
163            # Some servers block or mishandle HEAD
164            _check_status(exc.code)
165            self._verified = True
166            return
167
168        except URLError:
169            pass
170
171        # Fallback to GET (do not explicitly read body)
172        try:
173            req = Request(self.url, method="GET", headers=self.headers)
174            with urlopen(req, timeout=self.timeout) as resp:
175                _check_status(resp.status)
176                self._verified = True
177                return
178
179        except (HTTPError, URLError) as exc:
180            raise ValueError(f"invalid url: {self.url}, {exc}")
181
182    ############################
183    def _validate_url(self, url: str) -> str:
184        if not (url.startswith("http://") or url.startswith("https://")):
185            raise ValueError(f"Invalid URL scheme: {url}")
186        if len(url.split("://")[-1]) == 0:
187            raise ValueError(f"Invalid URL: {url}")
188        return url
189
190    ############################
191    def _validate_method(self, method: str) -> str:
192        method = method.lower()
193        if method not in self._ALLOWED_METHODS:
194            raise ValueError(f"Unsupported HTTP method: {method}")
195        return method
196
197    ############################
198    def _make_request(self):
199        """Perform a single HTTP request."""
200        headers = dict(self.headers)
201        data = None
202
203        if self.method == "post" and self.payload is not None:
204            data = json.dumps(self.payload).encode("utf-8")
205            headers.setdefault("Content-Type", "application/json")
206
207        req = Request(
208            self.url,
209            method=self.method.upper(),
210            headers=headers,
211            data=data,
212        )
213
214        return urlopen(req, timeout=self.timeout)
215
216    ############################
217    def _parse_response(self, response) -> str | bytes:
218        data = response.read()
219        if self.encoding:
220            return data.decode(self.encoding, errors=self.encoding_errors)
221        return data
222
223    ############################
224    def read(self) -> str | bytes | None:
225        """
226        Read from the HTTP endpoint. Returns immediately after the HTTP request
227        completes, while enforcing a minimum interval between requests.
228        """
229
230        with self._read_lock:
231            now = time.monotonic()
232
233            # Enforce polling interval BEFORE the request
234            if now < self._next_read_time:
235                time.sleep(self._next_read_time - now)
236
237            start = time.monotonic()
238
239            try:
240                self._verify_url()
241                with self._make_request() as resp:
242                    record = self._parse_response(resp)
243
244            except (HTTPError, URLError, ValueError) as exc:
245                self._verified = False
246                logging.warning(
247                    "HTTP read failed: %s %s: %s",
248                    self.method,
249                    self.url,
250                    exc,
251                )
252                record = None
253
254            finally:
255                self._next_read_time = start + self.interval
256
257            return record
class HTTPReader(logger.readers.reader.Reader):
 89class HTTPReader(Reader):  # noqa: R0913
 90    """
 91    Read data from a URL at a set interval.
 92    """
 93
 94    _ALLOWED_METHODS = {"get", "post"}
 95
 96    ############################
 97    def __init__(self, url: str, method: str = 'get',  # noqa: R0902
 98                 headers: dict | None = None, payload: dict | None = None,
 99                 interval: float = 5.0, timeout: float = 2.0,
100                 encoding: str | None = 'utf-8',
101                 encoding_errors: str = 'ignore', **kwargs):
102        """
103        ```
104        url - Network url to read, in protocol://host:port format (e.g.
105              'https://example.com' 'http://example.com:8000').
106
107        method - type of http request
108
109        headers - Headers to set for the request
110
111        payload - Payload to set for the request. Only applicable for
112                  POST requests
113
114        interval - Seconds between update requests, must be >=0
115
116        timeout - Max time to wait for device to respond AND read the response.
117
118        encoding - 'utf-8' by default. If empty or None, do not attempt any
119                decoding and return raw bytes. Other possible encodings are
120                listed in online documentation here:
121                https://docs.python.org/3/library/codecs.html#standard-encodings
122
123        encoding_errors - 'ignore' by default. Other error strategies are
124                'strict', 'replace', and 'backslashreplace', described here:
125                https://docs.python.org/3/howto/unicode.html#encodings
126        ```
127        """
128        super().__init__(encoding=encoding, encoding_errors=encoding_errors, **kwargs)
129
130        if interval < 0:
131            raise ValueError('Interval must be greater or equal to zero')
132
133        self.url = self._validate_url(url)
134        self.method = self._validate_method(method)
135        self.headers = headers or {}
136        self.payload = payload
137        self.interval = interval
138        self.timeout = timeout
139
140        self._read_lock = threading.Lock()
141        self._verified = False
142        self._next_read_time = 0.0
143
144    ############################
145    def _verify_url(self):
146        """Check URL reachability on first read."""
147        if self._verified:
148            return
149
150        def _check_status(code: int):
151            if code == 404 or code >= 500:
152                raise ValueError(
153                    f"invalid url: {self.url}; return code: {code}"
154                )
155
156        try:
157            req = Request(self.url, method="HEAD", headers=self.headers)
158            with urlopen(req, timeout=self.timeout) as resp:
159                _check_status(resp.status)
160                self._verified = True
161                return
162
163        except HTTPError as exc:
164            # Some servers block or mishandle HEAD
165            _check_status(exc.code)
166            self._verified = True
167            return
168
169        except URLError:
170            pass
171
172        # Fallback to GET (do not explicitly read body)
173        try:
174            req = Request(self.url, method="GET", headers=self.headers)
175            with urlopen(req, timeout=self.timeout) as resp:
176                _check_status(resp.status)
177                self._verified = True
178                return
179
180        except (HTTPError, URLError) as exc:
181            raise ValueError(f"invalid url: {self.url}, {exc}")
182
183    ############################
184    def _validate_url(self, url: str) -> str:
185        if not (url.startswith("http://") or url.startswith("https://")):
186            raise ValueError(f"Invalid URL scheme: {url}")
187        if len(url.split("://")[-1]) == 0:
188            raise ValueError(f"Invalid URL: {url}")
189        return url
190
191    ############################
192    def _validate_method(self, method: str) -> str:
193        method = method.lower()
194        if method not in self._ALLOWED_METHODS:
195            raise ValueError(f"Unsupported HTTP method: {method}")
196        return method
197
198    ############################
199    def _make_request(self):
200        """Perform a single HTTP request."""
201        headers = dict(self.headers)
202        data = None
203
204        if self.method == "post" and self.payload is not None:
205            data = json.dumps(self.payload).encode("utf-8")
206            headers.setdefault("Content-Type", "application/json")
207
208        req = Request(
209            self.url,
210            method=self.method.upper(),
211            headers=headers,
212            data=data,
213        )
214
215        return urlopen(req, timeout=self.timeout)
216
217    ############################
218    def _parse_response(self, response) -> str | bytes:
219        data = response.read()
220        if self.encoding:
221            return data.decode(self.encoding, errors=self.encoding_errors)
222        return data
223
224    ############################
225    def read(self) -> str | bytes | None:
226        """
227        Read from the HTTP endpoint. Returns immediately after the HTTP request
228        completes, while enforcing a minimum interval between requests.
229        """
230
231        with self._read_lock:
232            now = time.monotonic()
233
234            # Enforce polling interval BEFORE the request
235            if now < self._next_read_time:
236                time.sleep(self._next_read_time - now)
237
238            start = time.monotonic()
239
240            try:
241                self._verify_url()
242                with self._make_request() as resp:
243                    record = self._parse_response(resp)
244
245            except (HTTPError, URLError, ValueError) as exc:
246                self._verified = False
247                logging.warning(
248                    "HTTP read failed: %s %s: %s",
249                    self.method,
250                    self.url,
251                    exc,
252                )
253                record = None
254
255            finally:
256                self._next_read_time = start + self.interval
257
258            return record

Read data from a URL at a set interval.

HTTPReader( url: str, method: str = 'get', headers: dict | None = None, payload: dict | None = None, interval: float = 5.0, timeout: float = 2.0, encoding: str | None = 'utf-8', encoding_errors: str = 'ignore', **kwargs)
 97    def __init__(self, url: str, method: str = 'get',  # noqa: R0902
 98                 headers: dict | None = None, payload: dict | None = None,
 99                 interval: float = 5.0, timeout: float = 2.0,
100                 encoding: str | None = 'utf-8',
101                 encoding_errors: str = 'ignore', **kwargs):
102        """
103        ```
104        url - Network url to read, in protocol://host:port format (e.g.
105              'https://example.com' 'http://example.com:8000').
106
107        method - type of http request
108
109        headers - Headers to set for the request
110
111        payload - Payload to set for the request. Only applicable for
112                  POST requests
113
114        interval - Seconds between update requests, must be >=0
115
116        timeout - Max time to wait for device to respond AND read the response.
117
118        encoding - 'utf-8' by default. If empty or None, do not attempt any
119                decoding and return raw bytes. Other possible encodings are
120                listed in online documentation here:
121                https://docs.python.org/3/library/codecs.html#standard-encodings
122
123        encoding_errors - 'ignore' by default. Other error strategies are
124                'strict', 'replace', and 'backslashreplace', described here:
125                https://docs.python.org/3/howto/unicode.html#encodings
126        ```
127        """
128        super().__init__(encoding=encoding, encoding_errors=encoding_errors, **kwargs)
129
130        if interval < 0:
131            raise ValueError('Interval must be greater or equal to zero')
132
133        self.url = self._validate_url(url)
134        self.method = self._validate_method(method)
135        self.headers = headers or {}
136        self.payload = payload
137        self.interval = interval
138        self.timeout = timeout
139
140        self._read_lock = threading.Lock()
141        self._verified = False
142        self._next_read_time = 0.0
url - Network url to read, in protocol://host:port format (e.g.
      'https://example.com' 'http://example.com:8000').

method - type of http request

headers - Headers to set for the request

payload - Payload to set for the request. Only applicable for
          POST requests

interval - Seconds between update requests, must be >=0

timeout - Max time to wait for device to respond AND read the response.

encoding - 'utf-8' by default. If empty or None, do not attempt any
        decoding and return raw bytes. Other possible encodings are
        listed in online documentation here:
        https://docs.python.org/3/library/codecs.html#standard-encodings

encoding_errors - 'ignore' by default. Other error strategies are
        'strict', 'replace', and 'backslashreplace', described here:
        https://docs.python.org/3/howto/unicode.html#encodings
url
method
headers
payload
interval
timeout
def read(self) -> str | bytes | None:
225    def read(self) -> str | bytes | None:
226        """
227        Read from the HTTP endpoint. Returns immediately after the HTTP request
228        completes, while enforcing a minimum interval between requests.
229        """
230
231        with self._read_lock:
232            now = time.monotonic()
233
234            # Enforce polling interval BEFORE the request
235            if now < self._next_read_time:
236                time.sleep(self._next_read_time - now)
237
238            start = time.monotonic()
239
240            try:
241                self._verify_url()
242                with self._make_request() as resp:
243                    record = self._parse_response(resp)
244
245            except (HTTPError, URLError, ValueError) as exc:
246                self._verified = False
247                logging.warning(
248                    "HTTP read failed: %s %s: %s",
249                    self.method,
250                    self.url,
251                    exc,
252                )
253                record = None
254
255            finally:
256                self._next_read_time = start + self.interval
257
258            return record

Read from the HTTP endpoint. Returns immediately after the HTTP request completes, while enforcing a minimum interval between requests.